diff --git a/supacode/Features/Repositories/Views/RepoHeaderRow.swift b/supacode/Features/Repositories/Views/RepoHeaderRow.swift index 7314d1db..51342fd7 100644 --- a/supacode/Features/Repositories/Views/RepoHeaderRow.swift +++ b/supacode/Features/Repositories/Views/RepoHeaderRow.swift @@ -1,3 +1,4 @@ +import Sharing import SwiftUI struct RepoHeaderRow: View { @@ -25,9 +26,11 @@ struct RepoHeaderRow: View { size: 14 ) } - Text(name) - .foregroundStyle(.secondary) - .help(nameTooltip ?? "") + RepoHeaderTitleText( + fallbackName: name, + repositoryRootURL: repositoryRootURL, + nameTooltip: nameTooltip + ) if isRemoving { Text("Removing...") .font(.caption) @@ -47,6 +50,48 @@ struct RepoHeaderRow: View { } } +/// Resolves the repo header label, preferring the user's custom +/// title from `RepositorySettings` over the folder-derived fallback. +/// Subscription is isolated to this leaf so the parent header view +/// doesn't re-evaluate when unrelated settings churn. +private struct RepoHeaderTitleText: View { + let fallbackName: String + let repositoryRootURL: URL? + let nameTooltip: String? + + var body: some View { + if let repositoryRootURL { + RepoHeaderTitleTextResolved( + rootURL: repositoryRootURL, + fallbackName: fallbackName, + nameTooltip: nameTooltip + ) + } else { + Text(fallbackName) + .foregroundStyle(.secondary) + .help(nameTooltip ?? "") + } + } +} + +private struct RepoHeaderTitleTextResolved: View { + let fallbackName: String + let nameTooltip: String? + @Shared private var settings: RepositorySettings + + init(rootURL: URL, fallbackName: String, nameTooltip: String?) { + self.fallbackName = fallbackName + self.nameTooltip = nameTooltip + _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) + } + + var body: some View { + Text(settings.customTitle ?? fallbackName) + .foregroundStyle(.secondary) + .help(nameTooltip ?? "") + } +} + /// Leaf view that renders the open-tab count badge for a repository. /// /// Lives in its own `View` so the read of `terminalManager` (an diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 7e4df9df..6d4b8388 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -275,6 +275,11 @@ struct RepositorySettingsFeature { normalizedSettings.worktreeBaseDirectoryPath, repositoryRootURL: rootURL ) + let trimmedCustomTitle = + normalizedSettings.customTitle? + .trimmingCharacters(in: .whitespacesAndNewlines) + normalizedSettings.customTitle = + (trimmedCustomTitle?.isEmpty ?? true) ? nil : trimmedCustomTitle @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings $repositorySettings.withLock { $0 = normalizedSettings } diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index 3fb9a7bc..79ceb7b0 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -15,6 +15,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var copyIgnoredOnWorktreeCreate: Bool? var copyUntrackedOnWorktreeCreate: Bool? var pullRequestMergeStrategy: PullRequestMergeStrategy? + var customTitle: String? private var schemaVersion: Int private enum CodingKeys: String, CodingKey { @@ -28,6 +29,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case copyIgnoredOnWorktreeCreate case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy + case customTitle } static let `default` = RepositorySettings( @@ -39,7 +41,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { worktreeBaseDirectoryPath: nil, copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, - pullRequestMergeStrategy: nil + pullRequestMergeStrategy: nil, + customTitle: nil ) init( @@ -51,7 +54,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { worktreeBaseDirectoryPath: String? = nil, copyIgnoredOnWorktreeCreate: Bool? = nil, copyUntrackedOnWorktreeCreate: Bool? = nil, - pullRequestMergeStrategy: PullRequestMergeStrategy? = nil + pullRequestMergeStrategy: PullRequestMergeStrategy? = nil, + customTitle: String? = nil ) { self.setupScript = setupScript self.archiveScript = archiveScript @@ -62,6 +66,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy + self.customTitle = customTitle schemaVersion = Self.currentSchemaVersion } @@ -86,6 +91,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.decodeIfPresent(String.self, forKey: .worktreeBaseRef) worktreeBaseDirectoryPath = try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) + customTitle = + try container.decodeIfPresent(String.self, forKey: .customTitle) if decodedSchemaVersion >= Self.currentSchemaVersion { copyIgnoredOnWorktreeCreate = try container.decodeIfPresent( diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index dfcec8cc..11b57cb0 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -63,9 +63,25 @@ struct RepositorySettingsView: View { get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, ) + let customTitle = Binding( + get: { settings.customTitle.wrappedValue ?? "" }, + set: { settings.customTitle.wrappedValue = $0 }, + ) let exampleWorktreePath = store.exampleWorktreePath + let folderName = Repository.name(for: store.rootURL) Form { + Section { + TextField(folderName, text: customTitle) + .textFieldStyle(.roundedBorder) + } header: { + VStack(alignment: .leading, spacing: 4) { + Text("Display Name") + Text("Custom name shown in the sidebar. Leave empty to use the folder name.") + .foregroundStyle(.secondary) + } + } + Section { RepositoryAppearancePickerView(store: store) } header: { diff --git a/supacodeTests/RepositorySettingsFeatureTests.swift b/supacodeTests/RepositorySettingsFeatureTests.swift index e4c16d70..c1eddfdc 100644 --- a/supacodeTests/RepositorySettingsFeatureTests.swift +++ b/supacodeTests/RepositorySettingsFeatureTests.swift @@ -135,7 +135,7 @@ struct RepositorySettingsFeatureTests { key: "b", modifiers: UserCustomShortcutModifiers(command: true) ) - ) + ), ] ) @@ -149,6 +149,78 @@ struct RepositorySettingsFeatureTests { #expect(decoded.customCommands.first?.shortcut == conflicted.customCommands.first?.shortcut) } + @Test(.dependencies) func customTitleBindingPersistsToRepositoryFile() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") + let settingsStorage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + + // Pre-seed a per-repo settings file so save() writes through to it + // instead of falling back to the global settings file. + let seedData = try #require(try? JSONEncoder().encode(RepositorySettings.default)) + try #require(try? localStorage.save(seedData, at: repositorySettingsURL)) + + let store = TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: rootURL, + repositoryKind: .plain, + settings: .default, + userSettings: .default + ) + ) { + RepositorySettingsFeature() + } withDependencies: { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } + + await store.send(.binding(.set(\.settings.customTitle, "My Custom Repo"))) { + $0.settings.customTitle = "My Custom Repo" + } + await store.receive(\.delegate.settingsChanged) + + let savedData = try #require(localStorage.data(at: repositorySettingsURL)) + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: savedData) + #expect(decoded.customTitle == "My Custom Repo") + } + + @Test(.dependencies) func customTitleWhitespaceOnlyPersistsAsNil() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") + let settingsStorage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + + let seedData = try #require(try? JSONEncoder().encode(RepositorySettings.default)) + try #require(try? localStorage.save(seedData, at: repositorySettingsURL)) + + let store = TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: rootURL, + repositoryKind: .plain, + settings: .default, + userSettings: .default + ) + ) { + RepositorySettingsFeature() + } withDependencies: { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } + + await store.send(.binding(.set(\.settings.customTitle, " "))) { + $0.settings.customTitle = " " + } + await store.receive(\.delegate.settingsChanged) + + let savedData = try #require(localStorage.data(at: repositorySettingsURL)) + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: savedData) + #expect(decoded.customTitle == nil) + } + @Test(.dependencies) func taskLoadsLatestUserSettingsAfterAsyncGitProbe() async throws { let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") let settingsStorage = SettingsTestStorage() @@ -167,7 +239,7 @@ struct RepositorySettingsFeatureTests { command: "echo updated", execution: .shellScript, shortcut: nil - ) + ), ] ) -- 2.51.2 From 193b3d1ca8835f5897468032df9d14ce9ef066e3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 14:56:51 +0900 Subject: [PATCH 002/528] build: stop swiftlint trailing-comma fixups conflicting with swift-format The previous setup let `swiftlint --fix` enforce `trailing_comma: mandatory_comma: true`, but swift-format 602 actively strips trailing commas in multi-line collection literals whose last element is a multi-line function call. The two tools end up oscillating, and any `make check` would surface a flood of unrelated reformatting in files that hadn't been touched. - Disable swiftlint's `trailing_comma` rule and remove the `mandatory_comma` block so swift-format is the sole authority on trailing-comma placement. - Drop `swiftlint --fix` from the `lint` Makefile target; lint remains a pure check, formatting belongs to swift-format. --- .swiftlint.yml | 7 ++++--- Makefile | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 93e032eb..4d1e3553 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,6 +10,10 @@ disabled_rules: - file_length - closure_parameter_position - type_body_length + # swift-format owns trailing comma policy in this project. Letting + # swiftlint --fix add commas that swift-format then strips creates a + # check ↔ format ping-pong, so swiftlint stays out of trailing commas. + - trailing_comma opt_in_rules: - accessibility_label_for_image @@ -21,9 +25,6 @@ opening_brace: ignore_multiline_statement_conditions: true ignore_multiline_type_headers: true -trailing_comma: - mandatory_comma: true - cyclomatic_complexity: warning: 15 error: 20 diff --git a/Makefile b/Makefile index 407db6bb..81643119 100644 --- a/Makefile +++ b/Makefile @@ -307,7 +307,6 @@ format: # Format code with swift-format (local only) swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests lint: # Lint code with swiftlint - mise exec -- swiftlint --fix --quiet mise exec -- swiftlint lint --quiet --config .swiftlint.yml check: format lint # Format and lint -- 2.51.2 From e9c72fac0d1674d20ca048b8c950e2231e0bb68c Mon Sep 17 00:00:00 2001 From: onevpaw Date: Wed, 29 Apr 2026 15:04:21 +0900 Subject: [PATCH 003/528] Polish repository display settings layout --- .../RepositoryAppearancePickerView.swift | 11 +++----- .../Views/RepositorySettingsView.swift | 26 +++++-------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index da370a6a..8e7c3699 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -35,7 +35,7 @@ struct RepositoryAppearancePickerView: View { private let swatchRingLineWidth: CGFloat = 1.5 var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { iconRow colorRow if let message = store.appearanceImportError { @@ -201,15 +201,10 @@ struct RepositoryAppearancePickerView: View { @ViewBuilder private var colorRow: some View { - VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { Text("Color") .font(.headline) - Text( - "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." - ) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + .frame(width: previewSize, alignment: .center) HStack(spacing: 8) { ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in colorSwatch(for: choice) diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 11b57cb0..609971bb 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -71,27 +71,15 @@ struct RepositorySettingsView: View { let folderName = Repository.name(for: store.rootURL) Form { - Section { - TextField(folderName, text: customTitle) - .textFieldStyle(.roundedBorder) - } header: { - VStack(alignment: .leading, spacing: 4) { - Text("Display Name") - Text("Custom name shown in the sidebar. Leave empty to use the folder name.") - .foregroundStyle(.secondary) - } - } + Section("Display") { + VStack(alignment: .leading, spacing: 12) { + TextField("", text: customTitle, prompt: Text(folderName)) + .labelsHidden() + .textFieldStyle(.roundedBorder) - Section { - RepositoryAppearancePickerView(store: store) - } header: { - VStack(alignment: .leading, spacing: 4) { - Text("Appearance") - Text( - "Pick an icon and color to make this repository easy to spot in the sidebar, shelf, and canvas." - ) - .foregroundStyle(.secondary) + RepositoryAppearancePickerView(store: store) } + .frame(maxWidth: .infinity, alignment: .leading) } if store.showsWorktreeSettings { -- 2.51.2 From 13d80c2d75c7965dae295417c49fa6c9697f9757 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 16:02:09 +0900 Subject: [PATCH 004/528] Restore color swatch caption in repository appearance picker The previous polish pass tightened color row to a single line but dropped the caption explaining where the chosen color shows up (sidebar row, shelf spine, canvas title bar). Re-add that line as a caption beneath the swatches, indented to align with the swatch row so the leading "Color" label keeps its centered alignment. --- .../RepositoryAppearancePickerView.swift | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index 8e7c3699..f4d792ca 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -201,17 +201,26 @@ struct RepositoryAppearancePickerView: View { @ViewBuilder private var colorRow: some View { - HStack(alignment: .center, spacing: 12) { - Text("Color") - .font(.headline) - .frame(width: previewSize, alignment: .center) - HStack(spacing: 8) { - ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in - colorSwatch(for: choice) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 12) { + Text("Color") + .font(.headline) + .frame(width: previewSize, alignment: .center) + HStack(spacing: 8) { + ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in + colorSwatch(for: choice) + } + noColorSwatch + Spacer(minLength: 0) } - noColorSwatch - Spacer(minLength: 0) } + Text( + "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, previewSize + 12) } } -- 2.51.2 From 45445c0af1be5bc293e5ff3b817a8d079cc64d74 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 16:22:19 +0900 Subject: [PATCH 005/528] Surface custom repo title in settings list, settings nav title, and repo detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the per-leaf @Shared(.repositorySettings(rootURL)) subscription used by the sidebar header into a reusable `RepoDisplayName` view, then applies it to the settings repository list and `RepositoryDetailView`. The settings detail's `.navigationTitle` needs a `String`, so a thin `RepositorySettingsDetailContainer` wrapper holds the @Shared subscription and resolves the title. Hot-path readers (Shelf spine, Canvas card title, toolbar notification groups) are left on `repository.name` for now — those methods rebuild a fresh @Shared wrapper per call per frame and trigger a write/notify storm through the global settings file. A safe fix needs a single shared subscription threaded through those methods rather than per-call wrappers. --- .../Repositories/Views/RepoDisplayName.swift | 48 +++++++++++++++++++ .../Repositories/Views/RepoHeaderRow.swift | 48 ++----------------- .../Views/RepositoryDetailView.swift | 7 ++- .../Settings/Views/SettingsView.swift | 42 +++++++++++++--- 4 files changed, 92 insertions(+), 53 deletions(-) create mode 100644 supacode/Features/Repositories/Views/RepoDisplayName.swift diff --git a/supacode/Features/Repositories/Views/RepoDisplayName.swift b/supacode/Features/Repositories/Views/RepoDisplayName.swift new file mode 100644 index 00000000..6e1f66be --- /dev/null +++ b/supacode/Features/Repositories/Views/RepoDisplayName.swift @@ -0,0 +1,48 @@ +import Sharing +import SwiftUI + +/// Renders the repository display label, preferring the user's custom +/// title from `RepositorySettings` over the folder-derived fallback. +/// +/// Subscription is isolated to this leaf view so callers don't pull in +/// `@Shared(.repositorySettings(...))` themselves and parent views +/// don't churn on settings changes. Mirrors the per-leaf-subscription +/// pattern used by `RepoHeaderTabCountBadge`. +/// +/// The view emits a plain `Text` — callers apply their own font / +/// foreground style modifiers so this view stays appearance-agnostic. +struct RepoDisplayName: View { + let fallbackName: String + let repositoryRootURL: URL? + var tooltip: String? + + var body: some View { + if let repositoryRootURL { + RepoDisplayNameResolved( + rootURL: repositoryRootURL, + fallbackName: fallbackName, + tooltip: tooltip + ) + } else { + Text(fallbackName) + .help(tooltip ?? "") + } + } +} + +private struct RepoDisplayNameResolved: View { + let fallbackName: String + let tooltip: String? + @Shared private var settings: RepositorySettings + + init(rootURL: URL, fallbackName: String, tooltip: String?) { + self.fallbackName = fallbackName + self.tooltip = tooltip + _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) + } + + var body: some View { + Text(settings.customTitle ?? fallbackName) + .help(tooltip ?? "") + } +} diff --git a/supacode/Features/Repositories/Views/RepoHeaderRow.swift b/supacode/Features/Repositories/Views/RepoHeaderRow.swift index 51342fd7..7117b2fe 100644 --- a/supacode/Features/Repositories/Views/RepoHeaderRow.swift +++ b/supacode/Features/Repositories/Views/RepoHeaderRow.swift @@ -1,4 +1,3 @@ -import Sharing import SwiftUI struct RepoHeaderRow: View { @@ -26,11 +25,12 @@ struct RepoHeaderRow: View { size: 14 ) } - RepoHeaderTitleText( + RepoDisplayName( fallbackName: name, repositoryRootURL: repositoryRootURL, - nameTooltip: nameTooltip + tooltip: nameTooltip ) + .foregroundStyle(.secondary) if isRemoving { Text("Removing...") .font(.caption) @@ -50,48 +50,6 @@ struct RepoHeaderRow: View { } } -/// Resolves the repo header label, preferring the user's custom -/// title from `RepositorySettings` over the folder-derived fallback. -/// Subscription is isolated to this leaf so the parent header view -/// doesn't re-evaluate when unrelated settings churn. -private struct RepoHeaderTitleText: View { - let fallbackName: String - let repositoryRootURL: URL? - let nameTooltip: String? - - var body: some View { - if let repositoryRootURL { - RepoHeaderTitleTextResolved( - rootURL: repositoryRootURL, - fallbackName: fallbackName, - nameTooltip: nameTooltip - ) - } else { - Text(fallbackName) - .foregroundStyle(.secondary) - .help(nameTooltip ?? "") - } - } -} - -private struct RepoHeaderTitleTextResolved: View { - let fallbackName: String - let nameTooltip: String? - @Shared private var settings: RepositorySettings - - init(rootURL: URL, fallbackName: String, nameTooltip: String?) { - self.fallbackName = fallbackName - self.nameTooltip = nameTooltip - _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) - } - - var body: some View { - Text(settings.customTitle ?? fallbackName) - .foregroundStyle(.secondary) - .help(nameTooltip ?? "") - } -} - /// Leaf view that renders the open-tab count badge for a repository. /// /// Lives in its own `View` so the read of `terminalManager` (an diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index 43da604f..3710dbe9 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -8,8 +8,11 @@ struct RepositoryDetailView: View { Image(systemName: repository.kind == .git ? "folder.badge.gearshape" : "folder") .font(.largeTitle) .accessibilityHidden(true) - Text(repository.name) - .font(.title3.weight(.semibold)) + RepoDisplayName( + fallbackName: repository.name, + repositoryRootURL: repository.rootURL + ) + .font(.title3.weight(.semibold)) Text(repository.rootURL.path(percentEncoded: false)) .font(.subheadline.monospaced()) .foregroundStyle(.secondary) diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 82bd0c76..9aeb6e8f 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import Sharing import SwiftUI extension View { @@ -46,8 +47,11 @@ struct SettingsView: View { Section("Repositories") { ForEach(repositories) { repository in - Text(repository.name) - .tag(SettingsSection.repository(repository.id)) + RepoDisplayName( + fallbackName: repository.name, + repositoryRootURL: repository.rootURL + ) + .tag(SettingsSection.repository(repository.id)) } } } @@ -106,10 +110,10 @@ struct SettingsView: View { IfLetStore( settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) ) { repositorySettingsStore in - RepositorySettingsView(store: repositorySettingsStore) - .id(repository.id) - .navigationTitle(repository.name) - .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) + RepositorySettingsDetailContainer( + store: repositorySettingsStore, + repository: repository + ) } } } else { @@ -132,3 +136,29 @@ struct SettingsView: View { .ignoresSafeArea(.container, edges: .top) } } + +/// Wraps `RepositorySettingsView` with a `@Shared` subscription on the +/// repo's settings file so the navigation title can reflect the user's +/// custom title (when set) instead of the folder-derived `Repository.name`. +/// Lives here rather than inside `RepositorySettingsView` because the +/// `.navigationTitle` modifier needs a `String`, not a view, and reading +/// `@Shared` requires a struct property — wrapping at this layer keeps +/// `RepositorySettingsView`'s interface untouched. +private struct RepositorySettingsDetailContainer: View { + let store: StoreOf + let repository: Repository + @Shared private var settings: RepositorySettings + + init(store: StoreOf, repository: Repository) { + self.store = store + self.repository = repository + _settings = Shared(wrappedValue: .default, .repositorySettings(repository.rootURL)) + } + + var body: some View { + RepositorySettingsView(store: store) + .id(repository.id) + .navigationTitle(settings.customTitle ?? repository.name) + .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) + } +} -- 2.51.2 From de944e990f7da7e324fb8e53631db9b5dfd70ae9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 16:46:51 +0900 Subject: [PATCH 006/528] Surface custom repo title across shelf, canvas, toolbar, and settings via static state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-leaf @Shared(.repositorySettings(rootURL)) subscriptions from the previous attempt with a reducer-managed dictionary on RepositoriesFeature.State. The old approach created a fresh @Shared wrapper instance per call inside hot-path methods (orderedShelfBooks, toolbarNotificationGroups, the canvas card loop), which on cache miss triggered a settingsFile write that notified all settingsFile subscribers, re-ran view bodies, re-built the wrappers, and looped — pegging CPU at 90%+ on launch. Now the flow is: - RepositoriesFeature.State gains `repositoryCustomTitles: [Repository.ID: String]`. - Four new actions manage the dict: refreshAllCustomTitles (full batch reload, runs in a reducer effect that's safe to read @Shared from), refreshCustomTitle(URL) (single-repo refresh), customTitlesLoaded(dict), customTitleUpdated(id, title?). - AppFeature wires the triggers: it forwards `.repositories(.delegate(.repositoriesChanged))` to `.repositories(.refreshAllCustomTitles)`, and forwards `.settings(.repositorySettings(.delegate(.settingsChanged(url))))` to `.repositories(.refreshCustomTitle(url))`. - All display sites read the dict statically: - RepoDisplayName is now a pure-props view (no @Shared). - Sidebar (RepoHeaderRow/RepositorySectionView), settings list, settings detail nav title, and RepositoryDetailView take the resolved custom title as a String parameter. - ShelfBook.orderedShelfBooks and toolbarNotificationGroups accept a customTitles dict; the shelf spine, toolbar notifications popover, and canvas card all pass `state.repositoryCustomTitles`. Tests cover the dict-mutation actions (customTitlesLoaded/customTitleUpdated state guards) and parameterized model methods (orderedShelfBooks + toolbarNotificationGroups override + fallback). The previous attempt's hot-path tests are dropped — the methods are now pure with no @Shared dependency to mock. --- .../Features/App/Reducer/AppFeature.swift | 10 ++- .../Features/Canvas/Views/CanvasView.swift | 31 ++++++-- .../Models/ToolbarNotificationGroup.swift | 8 ++- .../Reducer/RepositoriesFeature.swift | 57 +++++++++++++++ .../Repositories/Views/RepoDisplayName.swift | 47 +++--------- .../Repositories/Views/RepoHeaderRow.swift | 5 +- .../Views/RepositoryDetailView.swift | 5 +- .../Views/RepositorySectionView.swift | 1 + .../Views/WorktreeDetailView.swift | 13 +++- .../Settings/Views/SettingsView.swift | 38 ++-------- .../Features/Shelf/Models/ShelfBook.swift | 20 ++++-- supacode/Features/Shelf/Views/ShelfView.swift | 2 +- supacodeTests/RepositoriesFeatureTests.swift | 53 ++++++++++++-- supacodeTests/ShelfBookOrderingTests.swift | 72 +++++++++++++++++++ .../ToolbarNotificationGroupingTests.swift | 41 +++++++++++ 15 files changed, 309 insertions(+), 94 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 232b39c1..4dd2bb92 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -335,6 +335,7 @@ struct AppFeature { var effects: [Effect] = [ .send(.settings(.setSelection(.general))), .send(.commandPalette(.pruneRecency(recencyIDs))), + .send(.repositories(.refreshAllCustomTitles)), .run { _ in await terminalClient.send(.prune(ids)) }, @@ -353,6 +354,7 @@ struct AppFeature { } var effects: [Effect] = [ .send(.commandPalette(.pruneRecency(recencyIDs))), + .send(.repositories(.refreshAllCustomTitles)), .run { _ in await terminalClient.send(.prune(ids)) }, @@ -787,15 +789,21 @@ struct AppFeature { } case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL)))): + // Always refresh the repo's custom title cache — display sites + // (sidebar, shelf, canvas, toolbar, settings list) read it from + // `RepositoriesFeature.State.repositoryCustomTitles` rather + // than subscribing to the per-repo settings file directly. + let refreshCustomTitle = Effect.send(.repositories(.refreshCustomTitle(rootURL))) guard let selectedWorktree = state.repositories.selectedTerminalWorktree, selectedWorktree.repositoryRootURL == rootURL else { - return .none + return refreshCustomTitle } let worktreeID = selectedWorktree.id @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings return .concatenate( + refreshCustomTitle, .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) ) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 2ac88b39..f9895643 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -7,6 +7,11 @@ struct CanvasView: View { @Environment(\.resolvedKeybindings) private var resolvedKeybindings let terminalManager: WorktreeTerminalManager + /// Per-repo display titles resolved by the parent reducer. Used to + /// override the folder-derived `Repository.name` on each card title + /// bar without subscribing to per-repo settings files on the + /// per-frame canvas hot path. + var repositoryCustomTitles: [Repository.ID: String] = [:] var onExitToTab: () -> Void = {} @State private var layoutStore = CanvasLayoutStore() @Shared(.repositoryAppearances) private var repositoryAppearances @@ -85,8 +90,9 @@ struct CanvasView: View { let cardTotalHeight = resized.size.height + titleBarHeight let repositoryAppearance = appearance(for: state.repositoryRootURL) + let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) CanvasCardView( - repositoryName: Repository.name(for: state.repositoryRootURL), + repositoryName: resolvedRepositoryName, worktreeName: tab.title, repositoryIcon: repositoryAppearance.icon, repositoryColor: repositoryAppearance.color?.color, @@ -780,12 +786,27 @@ struct CanvasView: View { /// dict. Returns `.empty` when no entry exists, which keeps cards /// visually identical to before the appearance feature shipped. private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { - let id = - PathPolicy.normalizePath( - repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true - ) ?? repositoryRootURL.path(percentEncoded: false) + let id = repositoryID(for: repositoryRootURL) return repositoryAppearances[id] ?? .empty } + + /// Resolves the user-defined display title for the repo at this root + /// URL, falling back to `Repository.name(for:)` (folder name) when no + /// custom title was set. Reads from the static dictionary populated + /// by the parent reducer — no per-call `@Shared` subscription on the + /// canvas hot path. + private func repositoryDisplayName(for repositoryRootURL: URL) -> String { + let id = repositoryID(for: repositoryRootURL) + return repositoryCustomTitles[id] ?? Repository.name(for: repositoryRootURL) + } + + /// Mirrors the same path normalization the `Repository.ID` is built + /// from, so dict lookups match what the reducer stores. + private func repositoryID(for repositoryRootURL: URL) -> Repository.ID { + PathPolicy.normalizePath( + repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true + ) ?? repositoryRootURL.path(percentEncoded: false) + } } private struct ActiveResize { diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index e0258caf..7d503de0 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -26,8 +26,12 @@ struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { } extension RepositoriesFeature.State { + /// `customTitles` is an optional per-repo display-name dictionary; + /// when an entry exists the group's `name` uses it instead of + /// `repository.name`. Defaults to empty for legacy callers/tests. func toolbarNotificationGroups( - terminalManager: WorktreeTerminalManager + terminalManager: WorktreeTerminalManager, + customTitles: [Repository.ID: String] = [:] ) -> [ToolbarNotificationRepositoryGroup] { let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) var groups: [ToolbarNotificationRepositoryGroup] = [] @@ -54,7 +58,7 @@ extension RepositoriesFeature.State { groups.append( ToolbarNotificationRepositoryGroup( id: repository.id, - name: repository.name, + name: customTitles[repository.id] ?? repository.name, worktrees: worktreeGroups ) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 68296eeb..a3d85ed7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -187,6 +187,13 @@ struct RepositoriesFeature { var repositoryRoots: [URL] = [] var repositoryOrderIDs: [Repository.ID] = [] var loadFailuresByID: [Repository.ID: String] = [:] + /// User-defined display titles indexed by `Repository.ID`. Resolved + /// once on repo discovery (and refreshed when settings change) so + /// hot-path display sites — sidebar, shelf spine, canvas card, + /// toolbar notifications, settings list — read a plain dictionary + /// instead of subscribing to `@Shared(.repositorySettings(...))` + /// per row per frame. Absent entries fall back to `repository.name`. + var repositoryCustomTitles: [Repository.ID: String] = [:] var selection: SidebarSelection? var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] @@ -276,6 +283,10 @@ struct RepositoriesFeature { case refreshWorktrees case reloadRepositories(animated: Bool) case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) + case refreshAllCustomTitles + case refreshCustomTitle(URL) + case customTitlesLoaded([Repository.ID: String]) + case customTitleUpdated(Repository.ID, String?) case codeHostsDetected([Repository.ID: CodeHost]) case selectArchivedWorktrees case selectCanvas @@ -632,6 +643,52 @@ struct RepositoriesFeature { } return .merge(allEffects) + case .refreshAllCustomTitles: + // Fan out across the current repository list, reading each + // per-repo settings file via `@Shared`. Runs in a reducer + // effect (not in a view body), so even when the first cache + // miss triggers a `settingsFile` write the resulting view + // re-render can't loop back into this action. + let repositoriesForTitleRefresh = Array(state.repositories) + return .run { send in + var dict: [Repository.ID: String] = [:] + for repository in repositoriesForTitleRefresh { + @Shared(.repositorySettings(repository.rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + dict[repository.id] = trimmed + } + } + await send(.customTitlesLoaded(dict)) + } + + case .refreshCustomTitle(let rootURL): + guard let repository = state.repositories.first(where: { $0.rootURL == rootURL }) else { + return .none + } + let repositoryID = repository.id + return .run { send in + @Shared(.repositorySettings(rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = (trimmed?.isEmpty ?? true) ? nil : trimmed + await send(.customTitleUpdated(repositoryID, normalized)) + } + + case .customTitlesLoaded(let dict): + guard state.repositoryCustomTitles != dict else { return .none } + state.repositoryCustomTitles = dict + return .none + + case .customTitleUpdated(let id, let title): + if let title { + guard state.repositoryCustomTitles[id] != title else { return .none } + state.repositoryCustomTitles[id] = title + } else { + guard state.repositoryCustomTitles[id] != nil else { return .none } + state.repositoryCustomTitles.removeValue(forKey: id) + } + return .none + case .codeHostsDetected(let codeHostByRepositoryID): let knownIDs = Set(state.repositories.ids) var updated = state.codeHostByRepositoryID.filter { knownIDs.contains($0.key) } diff --git a/supacode/Features/Repositories/Views/RepoDisplayName.swift b/supacode/Features/Repositories/Views/RepoDisplayName.swift index 6e1f66be..485e70ff 100644 --- a/supacode/Features/Repositories/Views/RepoDisplayName.swift +++ b/supacode/Features/Repositories/Views/RepoDisplayName.swift @@ -1,48 +1,21 @@ -import Sharing import SwiftUI -/// Renders the repository display label, preferring the user's custom -/// title from `RepositorySettings` over the folder-derived fallback. +/// Renders the repository display label, preferring a user-defined +/// `customTitle` over the folder-derived `fallbackName`. /// -/// Subscription is isolated to this leaf view so callers don't pull in -/// `@Shared(.repositorySettings(...))` themselves and parent views -/// don't churn on settings changes. Mirrors the per-leaf-subscription -/// pattern used by `RepoHeaderTabCountBadge`. -/// -/// The view emits a plain `Text` — callers apply their own font / -/// foreground style modifiers so this view stays appearance-agnostic. +/// Stateless on purpose: the source of truth for `customTitle` lives +/// in `RepositoriesFeature.State.repositoryCustomTitles` (refreshed by +/// the reducer when settings change), so display sites read a plain +/// string and avoid per-row `@Shared(.repositorySettings(...))` +/// subscriptions on the hot path. Callers apply their own font / +/// foreground style modifiers — this view stays appearance-agnostic. struct RepoDisplayName: View { let fallbackName: String - let repositoryRootURL: URL? + var customTitle: String? var tooltip: String? var body: some View { - if let repositoryRootURL { - RepoDisplayNameResolved( - rootURL: repositoryRootURL, - fallbackName: fallbackName, - tooltip: tooltip - ) - } else { - Text(fallbackName) - .help(tooltip ?? "") - } - } -} - -private struct RepoDisplayNameResolved: View { - let fallbackName: String - let tooltip: String? - @Shared private var settings: RepositorySettings - - init(rootURL: URL, fallbackName: String, tooltip: String?) { - self.fallbackName = fallbackName - self.tooltip = tooltip - _settings = Shared(wrappedValue: .default, .repositorySettings(rootURL)) - } - - var body: some View { - Text(settings.customTitle ?? fallbackName) + Text(customTitle ?? fallbackName) .help(tooltip ?? "") } } diff --git a/supacode/Features/Repositories/Views/RepoHeaderRow.swift b/supacode/Features/Repositories/Views/RepoHeaderRow.swift index 7117b2fe..c6fc9212 100644 --- a/supacode/Features/Repositories/Views/RepoHeaderRow.swift +++ b/supacode/Features/Repositories/Views/RepoHeaderRow.swift @@ -3,6 +3,9 @@ import SwiftUI struct RepoHeaderRow: View { private static let debugHeaderLayers = false let name: String + /// User-defined display title resolved by the parent reducer. When + /// non-nil, takes precedence over `name` for display. + var customTitle: String? let isRemoving: Bool /// User-pinned icon, when set. Renders before the repo name. /// `nil` keeps the historical text-only layout intact. @@ -27,7 +30,7 @@ struct RepoHeaderRow: View { } RepoDisplayName( fallbackName: name, - repositoryRootURL: repositoryRootURL, + customTitle: customTitle, tooltip: nameTooltip ) .foregroundStyle(.secondary) diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index 3710dbe9..8ddfcb55 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -2,6 +2,9 @@ import SwiftUI struct RepositoryDetailView: View { let repository: Repository + /// Resolved by the parent reducer. When non-nil, takes precedence + /// over `repository.name` for display. + var customTitle: String? var body: some View { VStack(spacing: 12) { @@ -10,7 +13,7 @@ struct RepositoryDetailView: View { .accessibilityHidden(true) RepoDisplayName( fallbackName: repository.name, - repositoryRootURL: repository.rootURL + customTitle: customTitle ) .font(.title3.weight(.semibold)) Text(repository.rootURL.path(percentEncoded: false)) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 7bec12b1..51ff229b 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -46,6 +46,7 @@ struct RepositorySectionView: View { HStack { RepoHeaderRow( name: repository.name, + customTitle: store.repositoryCustomTitles[repository.id], isRemoving: isRemovingRepository, icon: appearance.icon, iconTint: appearance.color?.color, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f293c1d5..21d15aef 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -48,7 +48,10 @@ struct WorktreeDetailView: View { let runScriptEnabled = hasActiveTerminalTarget let runScriptIsRunning = selectedTerminalWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true let customCommands = state.selectedCustomCommands - let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) + let notificationGroups = repositories.toolbarNotificationGroups( + terminalManager: terminalManager, + customTitles: repositories.repositoryCustomTitles + ) let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in count + repository.unseenWorktreeCount } @@ -221,6 +224,7 @@ struct WorktreeDetailView: View { if repositories.isShowingCanvas { CanvasView( terminalManager: terminalManager, + repositoryCustomTitles: repositories.repositoryCustomTitles, onExitToTab: { store.send(.repositories(.toggleCanvas)) }) @@ -260,7 +264,10 @@ struct WorktreeDetailView: View { } } } else if let selectedRepository = repositories.selectedRepository { - RepositoryDetailView(repository: selectedRepository) + RepositoryDetailView( + repository: selectedRepository, + customTitle: repositories.repositoryCustomTitles[selectedRepository.id] + ) } else { EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) } @@ -911,7 +918,7 @@ private struct WorktreeToolbarPreview: View { key: "u", modifiers: UserCustomShortcutModifiers() ) - ) + ), ], isUpdateAvailable: true, availableUpdateVersion: "2026.5.1" diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 9aeb6e8f..8b891f67 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Sharing import SwiftUI extension View { @@ -25,6 +24,7 @@ struct SettingsView: View { var body: some View { let updatesStore = store.scope(state: \.updates, action: \.updates) let repositories = store.repositories.repositories + let customTitles = store.repositories.repositoryCustomTitles let selection = settingsStore.selection ?? .general NavigationSplitView(columnVisibility: .constant(.all)) { @@ -49,7 +49,7 @@ struct SettingsView: View { ForEach(repositories) { repository in RepoDisplayName( fallbackName: repository.name, - repositoryRootURL: repository.rootURL + customTitle: customTitles[repository.id] ) .tag(SettingsSection.repository(repository.id)) } @@ -110,10 +110,10 @@ struct SettingsView: View { IfLetStore( settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) ) { repositorySettingsStore in - RepositorySettingsDetailContainer( - store: repositorySettingsStore, - repository: repository - ) + RepositorySettingsView(store: repositorySettingsStore) + .id(repository.id) + .navigationTitle(customTitles[repository.id] ?? repository.name) + .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) } } } else { @@ -136,29 +136,3 @@ struct SettingsView: View { .ignoresSafeArea(.container, edges: .top) } } - -/// Wraps `RepositorySettingsView` with a `@Shared` subscription on the -/// repo's settings file so the navigation title can reflect the user's -/// custom title (when set) instead of the folder-derived `Repository.name`. -/// Lives here rather than inside `RepositorySettingsView` because the -/// `.navigationTitle` modifier needs a `String`, not a view, and reading -/// `@Shared` requires a struct property — wrapping at this layer keeps -/// `RepositorySettingsView`'s interface untouched. -private struct RepositorySettingsDetailContainer: View { - let store: StoreOf - let repository: Repository - @Shared private var settings: RepositorySettings - - init(store: StoreOf, repository: Repository) { - self.store = store - self.repository = repository - _settings = Shared(wrappedValue: .default, .repositorySettings(repository.rootURL)) - } - - var body: some View { - RepositorySettingsView(store: store) - .id(repository.id) - .navigationTitle(settings.customTitle ?? repository.name) - .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) - } -} diff --git a/supacode/Features/Shelf/Models/ShelfBook.swift b/supacode/Features/Shelf/Models/ShelfBook.swift index d95733dd..672fdab3 100644 --- a/supacode/Features/Shelf/Models/ShelfBook.swift +++ b/supacode/Features/Shelf/Models/ShelfBook.swift @@ -38,7 +38,16 @@ extension RepositoriesFeature.State { /// Clicking a previously-unopened worktree in the left navigation /// while in Shelf mode adds its ID here, which causes its spine to /// materialize (with the standard spine-flow animation). - func orderedShelfBooks() -> [ShelfBook] { + /// Builds the ordered list of shelf books from current state. + /// + /// `customTitles` is an optional dictionary providing user-defined + /// display names per repository. Defaults to empty for callers that + /// don't care (e.g. legacy tests). The resolved `projectName` (and + /// `displayName` for plain folders) prefers the custom title when + /// present and falls back to `repository.name` otherwise. + func orderedShelfBooks( + customTitles: [Repository.ID: String] = [:] + ) -> [ShelfBook] { // `ShelfView.body` re-runs on every TCA state change, so this method // is on the per-frame hot path. The previous implementation built a // `Dictionary(uniqueKeysWithValues:)` per call and routed worktree @@ -51,14 +60,15 @@ extension RepositoriesFeature.State { var books: [ShelfBook] = [] for repositoryID in orderedRepositoryIDs() { guard let repository = repositories[id: repositoryID] else { continue } + let projectName = customTitles[repositoryID] ?? repository.name if repository.kind == .plain { guard openedWorktreeIDs.contains(repository.id) else { continue } books.append( ShelfBook( id: repository.id, repositoryID: repository.id, - displayName: repository.name, - projectName: repository.name, + displayName: projectName, + projectName: projectName, branchName: nil, kind: .plainFolder )) @@ -71,7 +81,7 @@ extension RepositoriesFeature.State { id: worktree.id, repositoryID: repositoryID, displayName: worktree.name, - projectName: repository.name, + projectName: projectName, branchName: worktree.name, kind: .worktree )) @@ -90,7 +100,7 @@ extension RepositoriesFeature.State { id: pending.id, repositoryID: repositoryID, displayName: pending.progress.titleText, - projectName: repository.name, + projectName: projectName, branchName: pending.progress.titleText, kind: .worktree )) diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 5215d4b2..a8f4b50b 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -31,7 +31,7 @@ struct ShelfView: View { // sanity-checking how often the root re-renders during animation. let _ = shelfLogger.event("ShelfView.body") let state = store.state - let books = state.orderedShelfBooks() + let books = state.orderedShelfBooks(customTitles: state.repositoryCustomTitles) let openBookID = state.openShelfBookID let openIndex = openBookID.flatMap { id in books.firstIndex(where: { $0.id == id }) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2e93ded8..418d10d2 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -67,6 +67,47 @@ struct RepositoriesFeatureTests { } } + @Test func customTitlesLoadedReplacesEntireDictionary() async { + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) { + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] + } + + // Re-sending the same dict is a no-op (state mutation guard avoids + // gratuitous TCA-driven view refreshes). + await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) + + await store.send(.customTitlesLoaded(["repo-c": "Gamma"])) { + $0.repositoryCustomTitles = ["repo-c": "Gamma"] + } + } + + @Test func customTitleUpdatedSetsAndRemovesSingleEntry() async { + var initialState = RepositoriesFeature.State() + initialState.repositoryCustomTitles = ["repo-a": "Alpha"] + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.customTitleUpdated("repo-b", "Beta")) { + $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] + } + + // Same value → no state change + await store.send(.customTitleUpdated("repo-b", "Beta")) + + // nil removes the entry + await store.send(.customTitleUpdated("repo-a", nil)) { + $0.repositoryCustomTitles = ["repo-b": "Beta"] + } + + // Removing a non-existent entry is a no-op + await store.send(.customTitleUpdated("repo-a", nil)) + } + @Test func updateWorktreeLineChangesReturnsFalseWhenCountsMatchExistingEntry() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -622,7 +663,7 @@ struct RepositoriesFeatureTests { [ PersistedRepositoryEntry(path: repoRoot, kind: .git), PersistedRepositoryEntry(path: plainRoot, kind: .plain), - ] + ], ] #expect(savedEntries.value == expectedSavedEntries) } @@ -1953,7 +1994,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ) + ), ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1991,7 +2032,7 @@ struct RepositoriesFeatureTests { stage: .checkingRepositoryMode, worktreeName: "swift-otter" ) - ) + ), ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2186,7 +2227,7 @@ struct RepositoriesFeatureTests { addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "MERGED") - ) + ), ] let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { @@ -2880,7 +2921,7 @@ struct RepositoriesFeatureTests { id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) - ) + ), ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ @@ -2967,7 +3008,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ) + ), ] initialState.selection = .worktree(pendingID) initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] diff --git a/supacodeTests/ShelfBookOrderingTests.swift b/supacodeTests/ShelfBookOrderingTests.swift index 30ea311d..a1e919a0 100644 --- a/supacodeTests/ShelfBookOrderingTests.swift +++ b/supacodeTests/ShelfBookOrderingTests.swift @@ -196,4 +196,76 @@ struct ShelfBookOrderingTests { #expect(state.openShelfBookID == repository.id) } + + @Test func customTitleOverridesProjectNameForWorktreeBooks() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let main = Worktree( + id: "/tmp/repo", + name: "main", + detail: "", + workingDirectory: rootURL, + repositoryRootURL: rootURL + ) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main]) + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [main.id] + + let books = state.orderedShelfBooks(customTitles: [repository.id: "My Custom Repo"]) + + #expect(books.count == 1) + #expect(books[0].projectName == "My Custom Repo") + // Worktree's own displayName stays as the worktree branch — only + // the repo-level project label is overridden. + #expect(books[0].displayName == "main") + } + + @Test func customTitleOverridesBothNamesForPlainFolderBook() { + let rootURL = URL(fileURLWithPath: "/tmp/folder") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "folder", + kind: .plain, + worktrees: [] + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [repository.id] + + let books = state.orderedShelfBooks(customTitles: [repository.id: "Plain Folder Alias"]) + + #expect(books.count == 1) + #expect(books[0].kind == .plainFolder) + #expect(books[0].projectName == "Plain Folder Alias") + #expect(books[0].displayName == "Plain Folder Alias") + } + + @Test func missingCustomTitleFallsBackToRepositoryName() { + let rootURL = URL(fileURLWithPath: "/tmp/folder") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "folder", + kind: .plain, + worktrees: [] + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.openedWorktreeIDs = [repository.id] + + let books = state.orderedShelfBooks(customTitles: [:]) + + #expect(books.count == 1) + #expect(books[0].projectName == "folder") + #expect(books[0].displayName == "folder") + } } diff --git a/supacodeTests/ToolbarNotificationGroupingTests.swift b/supacodeTests/ToolbarNotificationGroupingTests.swift index 79919726..7231615a 100644 --- a/supacodeTests/ToolbarNotificationGroupingTests.swift +++ b/supacodeTests/ToolbarNotificationGroupingTests.swift @@ -117,6 +117,47 @@ struct ToolbarNotificationGroupingTests { #expect(groups[0].unseenWorktreeCount == 0) } + @Test func customTitleOverridesGroupName() { + let repoPath = "/tmp/repo" + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) + var state = RepositoriesFeature.State(repositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + manager.state(for: feature).notifications = [ + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") + ] + + let groups = state.toolbarNotificationGroups( + terminalManager: manager, + customTitles: [repo.id: "Aliased Repo"] + ) + + #expect(groups.count == 1) + #expect(groups[0].name == "Aliased Repo") + } + + @Test func missingCustomTitleFallsBackToRepositoryName() { + let repoPath = "/tmp/repo" + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) + var state = RepositoriesFeature.State(repositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + manager.state(for: feature).notifications = [ + WorktreeTerminalNotification(surfaceId: UUID(), title: "Note", body: "done") + ] + + let groups = state.toolbarNotificationGroups(terminalManager: manager, customTitles: [:]) + + #expect(groups.count == 1) + #expect(groups[0].name == "Repo") + } + private func makeWorktree( id: String, name: String, -- 2.51.2 From 90aba90b8cb149809795f1a534b68cdd08e7f30f Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 17:31:48 +0900 Subject: [PATCH 007/528] Update style --- .../Features/Settings/Views/RepositorySettingsView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 609971bb..d07c6a13 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -73,10 +73,9 @@ struct RepositorySettingsView: View { Form { Section("Display") { VStack(alignment: .leading, spacing: 12) { - TextField("", text: customTitle, prompt: Text(folderName)) - .labelsHidden() + TextField("Name", text: customTitle, prompt: Text(folderName)) .textFieldStyle(.roundedBorder) - + Divider() RepositoryAppearancePickerView(store: store) } .frame(maxWidth: .infinity, alignment: .leading) -- 2.51.2 From ef9bfc9f96fbdbb325d17f5c0872f27b4044546f Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 18:17:55 +0900 Subject: [PATCH 008/528] Fix swallowed trailing space in repo Display name field The TextField sat directly inside a `.formStyle(.grouped)` Form, which auto-applied LabeledContent rendering and forced trailing-aligned text inside the field. Trailing spaces visually clipped against the right edge, making typed spaces appear lost. Wrap the field in an explicit HStack with a Name label and `.labelsHidden()` so Form leaves the TextField's internal layout alone and the cursor moves leading-aligned as expected. --- .../Settings/Views/RepositorySettingsView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index d07c6a13..51202dd7 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -73,8 +73,14 @@ struct RepositorySettingsView: View { Form { Section("Display") { VStack(alignment: .leading, spacing: 12) { - TextField("Name", text: customTitle, prompt: Text(folderName)) - .textFieldStyle(.roundedBorder) + HStack { + Text("Name") + Spacer().frame(width: 20) + TextField("", text: customTitle, prompt: Text(folderName)) + .frame(width: 300) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } Divider() RepositoryAppearancePickerView(store: store) } -- 2.51.2 From 693b46ecd75821eff783fed36dd881d1bbadb725 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 18:22:27 +0900 Subject: [PATCH 009/528] bump v2026.4.29 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 8a9b6371..d2c1f015 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260428; + CURRENT_PROJECT_VERSION = 20260429; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.28; + MARKETING_VERSION = 2026.4.29; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260428; + CURRENT_PROJECT_VERSION = 20260429; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.28; + MARKETING_VERSION = 2026.4.29; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260428; + CURRENT_PROJECT_VERSION = 20260429; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.28; + MARKETING_VERSION = 2026.4.29; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260428; + CURRENT_PROJECT_VERSION = 20260429; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.28; + MARKETING_VERSION = 2026.4.29; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index a01d298d..55d1bb18 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.4.28" + public static let current = "2026.4.29" } -- 2.51.2 From b6f84783073faddd904a4b2d6a14c625d3e85340 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 18:22:28 +0900 Subject: [PATCH 010/528] Update CHANGELOG for 2026.4.29 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fd1dfc..10414337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026.4.29](https://github.com/onevcat/Prowl/releases/tag/v2026.4.29) + +This release focuses on Shelf-mode responsiveness — switching books, especially via keyboard shortcuts, is noticeably snappier after a sweep of unnecessary SwiftUI invalidations. + +**New** + +- Repositories can now have a custom display name. Open Repo Settings and set a **Display Name** to override the folder-derived title in the sidebar, toolbar, and canvas. Useful when multiple checkouts share a generic folder name like `src`. Clearing the field reverts to the original folder name. + +**Fixed** + +- Switching between books in Shelf mode is noticeably smoother, particularly when using keyboard shortcuts. A cascade of unnecessary SwiftUI invalidations was traced and removed. +- A trailing space typed at the end of the Display Name field is no longer silently dropped. + ## [2026.4.28](https://github.com/onevcat/Prowl/releases/tag/v2026.4.28) This release adds per-repository visual identity across the entire app. -- 2.51.2 From ee466ee452926c51769878fe680cf040ab036974 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 18:32:11 +0900 Subject: [PATCH 011/528] Enforce `## New`/`## Fixed`/`## Improved` headings in release notes The release-notes prompt previously framed section names as `**New**` / `**Fixed**`, which is Markdown emphasis. Claude usually upgraded these to `## ` h2 headings when there were many bullets, but for small releases (one or two fixes) it treated `**Fixed**` as the literal output format, producing bold-paragraph pseudo-headings that render as plain `

` on Prowl-Site instead of the styled `

` everywhere else. Affected 2026.4.7, 4.16, 4.27, and 4.29. - Rewrite the prompt to require literal `## New` / `## Fixed` / `## Improved` headings and explicitly forbid `**...**` and `### ...`. - Add a lint pass at the end of release-notes.sh that fails fast when generated notes use a forbidden heading style. - Add a defensive grep in release.sh before the CHANGELOG is written so manually edited notes cannot reintroduce the bad format either. - Backfill the four affected CHANGELOG entries to use `## ` headings. --- CHANGELOG.md | 10 +++--- doc-onevcat/scripts/release-notes.sh | 48 ++++++++++++++++++++++++++-- doc-onevcat/scripts/release.sh | 9 ++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10414337..e4d3d59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ This release focuses on Shelf-mode responsiveness — switching books, especially via keyboard shortcuts, is noticeably snappier after a sweep of unnecessary SwiftUI invalidations. -**New** +## New - Repositories can now have a custom display name. Open Repo Settings and set a **Display Name** to override the folder-derived title in the sidebar, toolbar, and canvas. Useful when multiple checkouts share a generic folder name like `src`. Clearing the field reverts to the original folder name. -**Fixed** +## Fixed - Switching between books in Shelf mode is noticeably smoother, particularly when using keyboard shortcuts. A cascade of unnecessary SwiftUI invalidations was traced and removed. - A trailing space typed at the end of the Display Name field is no longer silently dropped. @@ -31,7 +31,7 @@ This release adds per-repository visual identity across the entire app. ## [2026.4.27](https://github.com/onevcat/Prowl/releases/tag/v2026.4.27) -**Fixed** +## Fixed - Fixed a bug where terminal windows could open in the wrong Light or Dark appearance at startup. @@ -134,7 +134,7 @@ This release focuses on Custom Command power-ups and two Canvas reliability fixe ## [2026.4.16](https://github.com/onevcat/Prowl/releases/tag/v2026.4.16) -**Fixed** +## Fixed - Fixed a race condition when entering Canvas view that could leave the terminal surface blank. @@ -180,7 +180,7 @@ Tab layout and Worktrees menu discoverability are the main themes of this releas ## [2026.4.7](https://github.com/onevcat/Prowl/releases/tag/v2026.4.7) -**Fixed** +## Fixed - When using a transparent background (`background-opacity < 1`) in dark mode on macOS 26, the titlebar and window border now correctly appear dark-tinted instead of showing an unwanted light glass effect. - The sidebar footer now displays a proper frosted glass effect when the background is transparent, rather than a plain semi-transparent fill that let the wallpaper bleed through without blur. diff --git a/doc-onevcat/scripts/release-notes.sh b/doc-onevcat/scripts/release-notes.sh index 614c8e70..2ed5500a 100755 --- a/doc-onevcat/scripts/release-notes.sh +++ b/doc-onevcat/scripts/release-notes.sh @@ -129,9 +129,11 @@ concise, user-facing changelog in Markdown. exclamation marks, no "we're excited". 5. **Format**: - Start with a one-line summary sentence of the release theme if there is a - clear one; otherwise jump straight to the list. - - Group into two sections: **New** for features/enhancements, and **Fixed** - for bug fixes. Omit a section if it has no items. + clear one; otherwise jump straight to the section. + - Group items into sections using literal Markdown level-2 headings: + `## New` for features/enhancements, `## Fixed` for bug fixes. Omit a + section if it has no items. Always use the `## ` heading syntax — never + bold-paragraph forms like `**New**` or `**Fixed**`, and never `### `. - Use a flat bullet list (`-`) within each section. - Each bullet should be one or two sentences maximum. - End with nothing — no sign-off, no footer. @@ -174,6 +176,39 @@ generate_fallback_notes() { fi } +# ── Validation ─────────────────────────────────────────────────────────────── + +# Lint a release-notes file against the CHANGELOG format used by Prowl-Site. +# Returns 0 on clean, 1 if violations were found (also prints them). +# Section headings must be `## New` / `## Fixed` / `## Improved` (level 2), +# never bold paragraphs (`**Fixed**`) or level-3 headings (`### Fixed`), since +# the site CSS targets `:global(h3)` after a one-level heading shift. +lint_release_notes() { + local file="$1" + local violations=() + + # Bold-paragraph section headers (whole-line) + if grep -nE '^\*\*(New|Fixed|Improved)\*\*[[:space:]]*$' "$file" >/dev/null; then + while IFS= read -r line; do + violations+=("$line (use '## …' instead of bold paragraph)") + done < <(grep -nE '^\*\*(New|Fixed|Improved)\*\*[[:space:]]*$' "$file") + fi + + # Level-3 section headers + if grep -nE '^### (New|Fixed|Improved)[[:space:]]*$' "$file" >/dev/null; then + while IFS= read -r line; do + violations+=("$line (use '## …' instead of '### …')") + done < <(grep -nE '^### (New|Fixed|Improved)[[:space:]]*$' "$file") + fi + + if [[ ${#violations[@]} -gt 0 ]]; then + echo "release-notes format violations in $file:" >&2 + printf ' %s\n' "${violations[@]}" >&2 + return 1 + fi + return 0 +} + # ── Generate ───────────────────────────────────────────────────────────────── NOTES_FILE="build/release-notes.md" @@ -201,5 +236,12 @@ cat "$NOTES_FILE" echo "──────────────────────────────────" echo log "saved to $NOTES_FILE" + +if ! lint_release_notes "$NOTES_FILE"; then + log "fix the headings above (use '## New' / '## Fixed' / '## Improved')," + log "then re-run this script or edit $NOTES_FILE before invoking release.sh." + exit 1 +fi + log "review and edit the file if needed, then run:" log " ./doc-onevcat/scripts/release.sh $VERSION" diff --git a/doc-onevcat/scripts/release.sh b/doc-onevcat/scripts/release.sh index dc94fb60..b9567bcd 100755 --- a/doc-onevcat/scripts/release.sh +++ b/doc-onevcat/scripts/release.sh @@ -104,6 +104,15 @@ SPARKLE_KEY_FILE="${SPARKLE_PRIVATE_KEY_FILE:-$HOME/.prowl-sparkle-private-key}" NOTES_FILE="build/release-notes.md" [[ -s "$NOTES_FILE" ]] || die "$NOTES_FILE not found — run release-notes.sh first" +# Section headings must be `## New` / `## Fixed` / `## Improved` so they render +# as

on Prowl-Site (which shifts headings one level). Reject bold-paragraph +# pseudo-headings and level-3 headings — both render as plain text on the site. +if bad_lines="$(grep -nE '^(\*\*(New|Fixed|Improved)\*\*|### (New|Fixed|Improved))[[:space:]]*$' "$NOTES_FILE")"; then + echo "error: invalid section headings in $NOTES_FILE:" >&2 + echo "$bad_lines" | sed 's/^/ /' >&2 + die "use '## New' / '## Fixed' / '## Improved' (level-2 headings) instead" +fi + log "repository: $REPO" log "signing identity: $SIGNING_IDENTITY" log "team ID: $TEAM_ID" -- 2.51.2 From ca6d602219189b0b22518197b0d5a549de7e287f Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 19:14:05 +0900 Subject: [PATCH 012/528] =?UTF-8?q?Demote=20release-note=20section=20headi?= =?UTF-8?q?ngs=20to=20`###=20=E2=80=A6`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section headings (New/Fixed/Improved) were rendering as `

` on Prowl-Site because the markdown used `## `, the same level as the version header. Prowl-Site's CSS targets `:global(h3)`, so the styled green monospace look only applied to older entries that already used `### …`. Demote the canonical form to `### New` / `### Fixed` / `### Improved`, sitting one level below the `## [VERSION]` header that release.sh prepends. - Backfill all 28 affected entries in CHANGELOG.md. - Flip release-notes.sh's prompt and lint to require `### …` and reject both `## …` and `**…**`. - Flip release.sh's inline grep guard to the same direction. - Companion change: 19 GitHub release bodies (v2026.3.27..v2026.4.29) were rewritten via `gh release edit` so the public release pages match. --- CHANGELOG.md | 56 ++++++++++++++-------------- doc-onevcat/scripts/release-notes.sh | 29 +++++++------- doc-onevcat/scripts/release.sh | 11 +++--- 3 files changed, 50 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d3d59f..61ed1aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ This release focuses on Shelf-mode responsiveness — switching books, especially via keyboard shortcuts, is noticeably snappier after a sweep of unnecessary SwiftUI invalidations. -## New +### New - Repositories can now have a custom display name. Open Repo Settings and set a **Display Name** to override the folder-derived title in the sidebar, toolbar, and canvas. Useful when multiple checkouts share a generic folder name like `src`. Clearing the field reverts to the original folder name. -## Fixed +### Fixed - Switching between books in Shelf mode is noticeably smoother, particularly when using keyboard shortcuts. A cascade of unnecessary SwiftUI invalidations was traced and removed. - A trailing space typed at the end of the Display Name field is no longer silently dropped. @@ -17,13 +17,13 @@ This release focuses on Shelf-mode responsiveness — switching books, especiall This release adds per-repository visual identity across the entire app. -## New +### New - Each repository can now have a custom icon and color. Pick an SF Symbol or upload any image, and choose from 10 system colors in Repo Settings. The identity appears in the sidebar row, shelf spine header and background, and canvas card title bar. - Custom Command tabs now display the command's configured icon for the lifetime of the tab. Run Script tabs keep the play icon throughout the run instead of briefly flashing before switching to the detected command icon. - Repo Settings is now accessible from the shelf spine context menu. -## Fixed +### Fixed - The "Choose Image" file picker in Repo Settings now opens inside the repository's working directory instead of the last-used Finder location. - Hovering the spine's New Tab, Split Vertically, and Split Horizontally buttons now shows the correct per-button tooltip with the associated shortcut key. Previously, the book-level tooltip masked all three buttons. @@ -31,7 +31,7 @@ This release adds per-repository visual identity across the entire app. ## [2026.4.27](https://github.com/onevcat/Prowl/releases/tag/v2026.4.27) -## Fixed +### Fixed - Fixed a bug where terminal windows could open in the wrong Light or Dark appearance at startup. @@ -39,12 +39,12 @@ This release adds per-repository visual identity across the entire app. This release brings mouse-driven Canvas navigation and improves reliability for long-running sessions. -## New +### New - **Canvas zoom with Cmd+scroll wheel**: hold Cmd and scroll to zoom in or out, anchored on the cursor position. Works with both mouse wheels and trackpads. - **Canvas pan with middle-click drag**: press and drag the middle mouse button to pan the Canvas. Terminals never see the click, and middle-click works normally outside Canvas mode. -## Fixed +### Fixed - The `prowl` CLI no longer loses its connection to the app after a few days. macOS periodically cleans `/tmp`, which was deleting the socket file and causing `APP_NOT_RUNNING` errors even with the app running. The socket is now stored in `~/Library/Application Support/com.onevcat.prowl/`. A one-time app relaunch is required after upgrading to bind the new path. - Prowl now applies a runtime Ghostty theme fallback when you have a single theme configured and it mismatches the current macOS appearance (light/dark). No changes are written to your Ghostty config file. @@ -55,12 +55,12 @@ This release brings mouse-driven Canvas navigation and improves reliability for Tab icons now update automatically based on the running command, making it easy to tell at a glance what each terminal tab is doing. -## New +### New - **Auto-detecting tab icons**: Prowl now detects the running command from the terminal title and displays a matching icon in the tab bar and Shelf spine. Brand icons are available for coding agents (Claude, Codex, Gemini, Copilot, Amp, and more), editors, package managers, runtimes, VCS tools, containers, and databases — over 55 command mappings in total. The icon stays visible after a short-lived command finishes as a "what is this tab for" hint, and is never overridden if you have manually locked an icon via the Icon Picker. - **Context-aware Shelf close action**: The Shelf spine context menu now shows "Close Worktree" or "Close Folder" depending on the book type, replacing the old "Remove Book" entry. Closing removes the book from the Shelf without touching the underlying directory or worktree. This also works on the main worktree, which previously showed the option but did nothing. -## Fixed +### Fixed - Staggered background refresh schedules across worktrees so periodic git and pull-request checks no longer fire simultaneously, reducing CPU spikes when many repos are open. - Shelf empty-state wording now consistently refers to worktrees, matching the rest of the UI. @@ -69,18 +69,18 @@ Tab icons now update automatically based on the running command, making it easy This release introduces Shelf, a new way to view and navigate your worktrees, along with a significant performance improvement that eliminates a source of main-thread hangs. -## New +### New - **Shelf view**: a new presentation mode that stacks your worktrees as books with vertical spines. Press `Cmd+Shift+Enter` or click the Shelf button in the sidebar toolbar to toggle it. Each spine shows the worktree name, branch, and tab slots; click any spine to open that book. - **Navigation shortcuts in Shelf**: navigate between books with `Cmd+Ctrl+←` / `Cmd+Ctrl+→`, navigate between tabs with `Cmd+Ctrl+↑` / `Cmd+Ctrl+↓`, or jump directly to a specific book with `Ctrl+Option+1–9`. All bindings are rebindable in Settings → Shortcuts. - **Command-key tab hints**: hold `Cmd` while in Shelf to swap each tab slot's icon for its `1–9` digit, making keyboard switching more discoverable. - **Default View setting**: choose whether Prowl launches into the standard view or Shelf in Settings → General. -## Improved +### Improved - Eliminated a main-thread hang (App Hang) triggered by rapid file-change or pull-request-refresh bursts. A repeated `standardizedFileURL` comparison in the sidebar render loop was accumulating enough work to stall the UI for 3+ seconds; the result is now computed once per worktree at construction time, so the sidebar stays responsive under heavy activity. -## Fixed +### Fixed - Shelf now correctly restores focus to the open book's terminal after SwiftUI reparenting, and properly tracks which worktrees the user has actually opened rather than showing all known worktrees. - Toggling into Shelf from Canvas now honors the card that was focused in Canvas as the open book, rather than falling back to a default. @@ -89,7 +89,7 @@ This release introduces Shelf, a new way to view and navigate your worktrees, al This release focuses on canvas usability improvements and broader code host support. -## New +### New - Canvas cards now show close and expand buttons in the title bar when you hover over them, letting you act on any card without focusing it first. - When a focused canvas card is closed (via button, Cmd+W, or any other method), focus automatically moves to the nearest surviving card so the highlighted state stays consistent. @@ -97,7 +97,7 @@ This release focuses on canvas usability improvements and broader code host supp - Code host actions in the toolbar and command palette are now labeled with the detected host name (e.g., "Open on GitHub" vs. "Open on GitLab"). - "Change Tab Icon..." and "Open Repository on Code Host" are now hidden from the command palette's empty-query list to reduce noise. Type to search for either action. -## Fixed +### Fixed - Restored two-finger scroll for TUI programs (pagers, editors, etc.) inside canvas mode. A previous optimization incorrectly forwarded scroll events to the canvas when Ghostty reported no scrollback buffer, breaking apps like `nvim`, `less`, and `htop`. - Fixed a crash (EXC_BREAKPOINT abort) that could occur during ANR detection due to Sentry invoking a Swift concurrency callback off the main thread. @@ -106,14 +106,14 @@ This release focuses on canvas usability improvements and broader code host supp This release focuses on tab customization and a less-interrupting update experience. -## New +### New - **Tab icons**: Right-click any terminal tab and choose "Change Tab Icon..." to pick from a curated SF Symbol preset grid or enter any SF Symbol name directly. You can also invoke this from the Command Palette (Cmd+P, search "icon"). Custom icons survive app restarts when *Restore Terminal Layout on Launch* is enabled. - **Rename from context menu**: "Change Tab Title..." is now available directly in the tab right-click menu, in addition to the existing keyboard shortcut flow. - **Quiet update notifications**: Available updates no longer interrupt your session with a dialog. A badge appears in the toolbar instead; click it (or use "Check for Updates...") when you are ready to install. - **Anonymous quality telemetry**: To help improve Prowl, this release adds lightweight anonymous crash reporting and memory usage telemetry. No personal data is collected. If you prefer not to participate, you can opt out in Settings. -## Fixed +### Fixed - The "Download and install automatically" setting has been removed; it conflicted with the new silent update detection flow and was not functional in this build. @@ -121,32 +121,32 @@ This release focuses on tab customization and a less-interrupting update experie This release focuses on Custom Command power-ups and two Canvas reliability fixes. -## New +### New - **Custom Commands can now open a New Split**, running your command in a new pane alongside the current terminal. Choose split direction (left, right, up, down) per command in Settings. - **Close on success** toggle for New Tab and New Split targets: when enabled, the tab or split is automatically dismissed after the command exits with code 0, leaving it open on failure so you can inspect the output. - The toolbar status badge now animates in and out smoothly, and a brief toast appears when a Custom Command completes successfully. -## Fixed +### Fixed - Creating split panes with Cmd+D or Cmd+Shift+D while in Canvas mode no longer freezes rendering. All panes now display and accept input correctly. - Two-finger pan on the Canvas is no longer interrupted when the cursor drifts over a focused terminal card mid-gesture. Scrolling on a card with no scrollback content now pans the canvas instead of being silently consumed. ## [2026.4.16](https://github.com/onevcat/Prowl/releases/tag/v2026.4.16) -## Fixed +### Fixed - Fixed a race condition when entering Canvas view that could leave the terminal surface blank. ## [2026.4.15](https://github.com/onevcat/Prowl/releases/tag/v2026.4.15) -## New +### New - **Fetch before worktree creation**: Prowl can now run `git fetch` against the relevant remote before creating a new worktree. The option is on by default and can be toggled in Settings > Worktree. Fetch errors are logged but do not block worktree creation. - **Merged worktree action**: The "auto-archive on merge" toggle has been replaced with a three-option picker — Do Nothing, Archive, or Delete — controlling what happens to a worktree when its pull request is merged. Find it in Settings > Worktree. Existing configurations migrate automatically. - **Global defaults for copy flags and merge strategy**: The "copy ignored files", "copy untracked files", and "pull request merge strategy" settings can now be configured once as global defaults in Settings, with optional per-repository overrides. Repository-level pickers show the current global value when no override is set. -## Fixed +### Fixed - Terminals could appear blank after exiting Canvas view due to the surface losing its host attachment. Prowl now detects and recovers from this state automatically. @@ -154,13 +154,13 @@ This release focuses on Custom Command power-ups and two Canvas reliability fixe This release focuses on worktree management improvements and quality-of-life fixes. -## New +### New - **Auto-delete archived worktrees**: A new setting in Worktree Settings lets you configure a period (1, 3, 7, 14, or 30 days) after which archived worktrees are deleted automatically. - **Reveal in Sidebar**: Press Shift+Cmd+L to scroll the sidebar to the currently selected worktree, expanding its repository section if collapsed. - **Archived worktrees discoverability**: Archive confirmation dialogs now tell you where to find archived worktrees (Menu Bar > Worktrees, or Control+Cmd+A). A "View Archived Worktrees" entry is also available in the command palette. -## Fixed +### Fixed - Restored terminal surfaces no longer spin the CPU and GPU when they are not displayed, keeping resource usage low for non-visible tabs after session restore. @@ -168,19 +168,19 @@ This release focuses on worktree management improvements and quality-of-life fix Tab layout and Worktrees menu discoverability are the main themes of this release. -## New +### New - All worktrees and plain folders now appear in the Worktrees menu, regardless of count. Previously only the first 9 were shown. Items beyond the 9th no longer have keyboard shortcuts but remain reachable via the menu or **Help > Search**. - Manually renamed tab titles and icons are now saved in the terminal layout snapshot and restored when the layout is reloaded. - Added Homepage and Release Notes links to the Help menu and sidebar footer. -## Fixed +### Fixed - Plain folders were missing from the Worktrees menu entirely; they now appear in the same order as the sidebar. ## [2026.4.7](https://github.com/onevcat/Prowl/releases/tag/v2026.4.7) -## Fixed +### Fixed - When using a transparent background (`background-opacity < 1`) in dark mode on macOS 26, the titlebar and window border now correctly appear dark-tinted instead of showing an unwanted light glass effect. - The sidebar footer now displays a proper frosted glass effect when the background is transparent, rather than a plain semi-transparent fill that let the wallpaper bleed through without blur. @@ -190,7 +190,7 @@ Tab layout and Worktrees menu discoverability are the main themes of this releas This release brings a redesigned sidebar with a modern, cleaner, and more compact layout, along with reliability fixes across the terminal surface and CLI. -## New +### New - **Redesigned Sidebar** — the sidebar has been completely re-laid out for a modern, cleaner, and more compact look, giving you more room to focus on your work. - **Reveal in Finder** is now available in the worktree context menu, opening the worktree directory directly in Finder. @@ -199,7 +199,7 @@ This release brings a redesigned sidebar with a modern, cleaner, and more compac - CLI tool install and uninstall results now show a toolbar toast on the main window for all entry points (Command Palette, menu bar), so you always get feedback regardless of whether Settings is open. - `prowl key` now correctly emits ANSI control characters for `Ctrl-[`, `Ctrl-\`, `Ctrl-]`, `Ctrl-^`, and `Ctrl-_` combos, and uppercase letters preserve their shift meaning. -## Fixed +### Fixed - Hovering a worktree row no longer causes a vertical layout jump when pin and archive buttons appear. - Archive, Delete, pin, and archive buttons are now hidden for the main worktree, where those actions do not apply. diff --git a/doc-onevcat/scripts/release-notes.sh b/doc-onevcat/scripts/release-notes.sh index 2ed5500a..a37b8315 100755 --- a/doc-onevcat/scripts/release-notes.sh +++ b/doc-onevcat/scripts/release-notes.sh @@ -130,10 +130,12 @@ concise, user-facing changelog in Markdown. 5. **Format**: - Start with a one-line summary sentence of the release theme if there is a clear one; otherwise jump straight to the section. - - Group items into sections using literal Markdown level-2 headings: - `## New` for features/enhancements, `## Fixed` for bug fixes. Omit a - section if it has no items. Always use the `## ` heading syntax — never - bold-paragraph forms like `**New**` or `**Fixed**`, and never `### `. + - Group items into sections using literal Markdown level-3 headings: + `### New` for features/enhancements, `### Fixed` for bug fixes, and + optionally `### Improved` for non-feature, non-bug enhancements. Omit a + section if it has no items. Always use the `### ` heading syntax — never + bold-paragraph forms like `**New**` or `**Fixed**`, and never `## ` + (which is reserved for the version header). - Use a flat bullet list (`-`) within each section. - Each bullet should be one or two sentences maximum. - End with nothing — no sign-off, no footer. @@ -180,9 +182,10 @@ generate_fallback_notes() { # Lint a release-notes file against the CHANGELOG format used by Prowl-Site. # Returns 0 on clean, 1 if violations were found (also prints them). -# Section headings must be `## New` / `## Fixed` / `## Improved` (level 2), -# never bold paragraphs (`**Fixed**`) or level-3 headings (`### Fixed`), since -# the site CSS targets `:global(h3)` after a one-level heading shift. +# Section headings must be `### New` / `### Fixed` / `### Improved` (level 3) +# so they sit one level below the `## [VERSION]` header that release.sh +# prepends. Bold paragraphs (`**Fixed**`) and `## Fixed` are both rejected: +# the site CSS targets `:global(h3)`, so anything else renders unstyled. lint_release_notes() { local file="$1" local violations=() @@ -190,15 +193,15 @@ lint_release_notes() { # Bold-paragraph section headers (whole-line) if grep -nE '^\*\*(New|Fixed|Improved)\*\*[[:space:]]*$' "$file" >/dev/null; then while IFS= read -r line; do - violations+=("$line (use '## …' instead of bold paragraph)") + violations+=("$line (use '### …' instead of bold paragraph)") done < <(grep -nE '^\*\*(New|Fixed|Improved)\*\*[[:space:]]*$' "$file") fi - # Level-3 section headers - if grep -nE '^### (New|Fixed|Improved)[[:space:]]*$' "$file" >/dev/null; then + # Level-2 section headers (collide with the version header) + if grep -nE '^## (New|Fixed|Improved)[[:space:]]*$' "$file" >/dev/null; then while IFS= read -r line; do - violations+=("$line (use '## …' instead of '### …')") - done < <(grep -nE '^### (New|Fixed|Improved)[[:space:]]*$' "$file") + violations+=("$line (use '### …' instead of '## …')") + done < <(grep -nE '^## (New|Fixed|Improved)[[:space:]]*$' "$file") fi if [[ ${#violations[@]} -gt 0 ]]; then @@ -238,7 +241,7 @@ echo log "saved to $NOTES_FILE" if ! lint_release_notes "$NOTES_FILE"; then - log "fix the headings above (use '## New' / '## Fixed' / '## Improved')," + log "fix the headings above (use '### New' / '### Fixed' / '### Improved')," log "then re-run this script or edit $NOTES_FILE before invoking release.sh." exit 1 fi diff --git a/doc-onevcat/scripts/release.sh b/doc-onevcat/scripts/release.sh index b9567bcd..17511041 100755 --- a/doc-onevcat/scripts/release.sh +++ b/doc-onevcat/scripts/release.sh @@ -104,13 +104,14 @@ SPARKLE_KEY_FILE="${SPARKLE_PRIVATE_KEY_FILE:-$HOME/.prowl-sparkle-private-key}" NOTES_FILE="build/release-notes.md" [[ -s "$NOTES_FILE" ]] || die "$NOTES_FILE not found — run release-notes.sh first" -# Section headings must be `## New` / `## Fixed` / `## Improved` so they render -# as

on Prowl-Site (which shifts headings one level). Reject bold-paragraph -# pseudo-headings and level-3 headings — both render as plain text on the site. -if bad_lines="$(grep -nE '^(\*\*(New|Fixed|Improved)\*\*|### (New|Fixed|Improved))[[:space:]]*$' "$NOTES_FILE")"; then +# Section headings must be `### New` / `### Fixed` / `### Improved` so they +# render as

on Prowl-Site (its CSS targets `:global(h3)`) and sit one +# level below the `## [VERSION]` header that this script prepends. Reject +# bold-paragraph pseudo-headings and `## …` headings — both render unstyled. +if bad_lines="$(grep -nE '^(\*\*(New|Fixed|Improved)\*\*|## (New|Fixed|Improved))[[:space:]]*$' "$NOTES_FILE")"; then echo "error: invalid section headings in $NOTES_FILE:" >&2 echo "$bad_lines" | sed 's/^/ /' >&2 - die "use '## New' / '## Fixed' / '## Improved' (level-2 headings) instead" + die "use '### New' / '### Fixed' / '### Improved' (level-3 headings) instead" fi log "repository: $REPO" -- 2.51.2 From ccdf9e193069280aac77e71682fe564aa298fa91 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 22:10:37 +0900 Subject: [PATCH 013/528] fix(shelf): refocus terminal after sidebar selection --- .../Repositories/Views/SidebarListView.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 7149863c..63617d5b 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -67,6 +67,7 @@ struct SidebarListView: View { } else { sidebarSelections = [.repository(repositoryID)] store.send(.selectRepository(repositoryID)) + focusShelfTerminalAfterSidebarInteraction(worktreeID: store.state.selectedTerminalWorktree?.id) } return } @@ -81,12 +82,14 @@ struct SidebarListView: View { if let selectedWorktreeID = state.selectedWorktreeID, worktreeIDs.contains(selectedWorktreeID) { + focusShelfTerminalAfterSidebarInteraction(worktreeID: selectedWorktreeID) return } let nextPrimarySelection = hotkeyRows.map(\.id).first(where: worktreeIDs.contains) ?? worktreeIDs.first store.send(.selectWorktree(nextPrimarySelection, focusTerminal: true)) + focusShelfTerminalAfterSidebarInteraction(worktreeID: nextPrimarySelection) } ) let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) @@ -221,12 +224,29 @@ struct SidebarListView: View { ) ) .focused($isSidebarFocused) + .onChange(of: isSidebarFocused) { _, isFocused in + guard isFocused else { return } + focusShelfTerminalAfterSidebarInteraction(worktreeID: store.state.selectedTerminalWorktree?.id) + } .task(id: pendingSidebarReveal?.id) { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } } // ScrollViewReader } + private func focusShelfTerminalAfterSidebarInteraction(worktreeID: Worktree.ID?) { + guard store.state.isShelfActive, let worktreeID else { return } + Task { @MainActor [terminalManager] in + for _ in 0..<4 { + await Task.yield() + if let terminalState = terminalManager.stateIfExists(for: worktreeID) { + terminalState.focusSelectedTab() + return + } + } + } + } + @MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, -- 2.51.2 From 9030147d840bc1df33dd486b71e99434280bd75f Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 22:27:06 +0900 Subject: [PATCH 014/528] fix(sidebar): refocus terminal on single selection --- .../Repositories/Views/SidebarListView.swift | 71 +++---------------- 1 file changed, 11 insertions(+), 60 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 63617d5b..d58ba7d3 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -67,7 +67,7 @@ struct SidebarListView: View { } else { sidebarSelections = [.repository(repositoryID)] store.send(.selectRepository(repositoryID)) - focusShelfTerminalAfterSidebarInteraction(worktreeID: store.state.selectedTerminalWorktree?.id) + focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) } return } @@ -78,18 +78,23 @@ struct SidebarListView: View { store.send(.selectWorktree(nil)) return } + let shouldFocusTerminal = worktreeIDs.count == 1 sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) if let selectedWorktreeID = state.selectedWorktreeID, worktreeIDs.contains(selectedWorktreeID) { - focusShelfTerminalAfterSidebarInteraction(worktreeID: selectedWorktreeID) + if shouldFocusTerminal { + focusTerminalAfterSidebarSelection(worktreeID: selectedWorktreeID) + } return } let nextPrimarySelection = hotkeyRows.map(\.id).first(where: worktreeIDs.contains) ?? worktreeIDs.first - store.send(.selectWorktree(nextPrimarySelection, focusTerminal: true)) - focusShelfTerminalAfterSidebarInteraction(worktreeID: nextPrimarySelection) + store.send(.selectWorktree(nextPrimarySelection, focusTerminal: shouldFocusTerminal)) + if shouldFocusTerminal { + focusTerminalAfterSidebarSelection(worktreeID: nextPrimarySelection) + } } ) let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) @@ -215,27 +220,15 @@ struct SidebarListView: View { store.send(.repositoryManagement(.openRepositories(fileURLs))) return true } - .modifier( - SidebarKeyForwardingModifier( - isEnabled: !state.isShelfActive, - selectedWorktreeID: state.selectedWorktreeID, - sidebarSelectedWorktreeIDs: state.sidebarSelectedWorktreeIDs, - terminalManager: terminalManager - ) - ) .focused($isSidebarFocused) - .onChange(of: isSidebarFocused) { _, isFocused in - guard isFocused else { return } - focusShelfTerminalAfterSidebarInteraction(worktreeID: store.state.selectedTerminalWorktree?.id) - } .task(id: pendingSidebarReveal?.id) { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } } // ScrollViewReader } - private func focusShelfTerminalAfterSidebarInteraction(worktreeID: Worktree.ID?) { - guard store.state.isShelfActive, let worktreeID else { return } + private func focusTerminalAfterSidebarSelection(worktreeID: Worktree.ID?) { + guard let worktreeID else { return } Task { @MainActor [terminalManager] in for _ in 0..<4 { await Task.yield() @@ -264,48 +257,6 @@ struct SidebarListView: View { } } -private struct SidebarKeyForwardingModifier: ViewModifier { - let isEnabled: Bool - let selectedWorktreeID: Worktree.ID? - let sidebarSelectedWorktreeIDs: Set - let terminalManager: WorktreeTerminalManager - - @ViewBuilder - func body(content: Content) -> some View { - if isEnabled { - content - .onKeyPress { keyPress in - handleKeyPress(keyPress) - } - } else { - content - } - } - - private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result { - guard !keyPress.characters.isEmpty else { return .ignored } - let isNavigationKey = - keyPress.key == .upArrow - || keyPress.key == .downArrow - || keyPress.key == .leftArrow - || keyPress.key == .rightArrow - || keyPress.key == .home - || keyPress.key == .end - || keyPress.key == .pageUp - || keyPress.key == .pageDown - if isNavigationKey { return .ignored } - let hasCommandModifier = keyPress.modifiers.contains(.command) - if hasCommandModifier { return .ignored } - guard let worktreeID = selectedWorktreeID, - sidebarSelectedWorktreeIDs.count == 1, - sidebarSelectedWorktreeIDs.contains(worktreeID), - let terminalState = terminalManager.stateIfExists(for: worktreeID) - else { return .ignored } - terminalState.focusAndInsertText(keyPress.characters) - return .handled - } -} - // MARK: - Previews #if DEBUG -- 2.51.2 From e6fff5b77db8994e707d8d668aa654bd81261a3c Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 22:41:13 +0900 Subject: [PATCH 015/528] Close canvas exit investigation --- .../canvas-exit-terminal-blank-tracking.md | 264 +++--------------- .../Views/WorktreeTerminalTabsView.swift | 24 +- .../Ghostty/GhosttySurfaceView.swift | 125 +-------- .../Ghostty/GhosttyTerminalView.swift | 11 - 4 files changed, 48 insertions(+), 376 deletions(-) diff --git a/doc-onevcat/canvas-exit-terminal-blank-tracking.md b/doc-onevcat/canvas-exit-terminal-blank-tracking.md index 15dc3042..989a0520 100644 --- a/doc-onevcat/canvas-exit-terminal-blank-tracking.md +++ b/doc-onevcat/canvas-exit-terminal-blank-tracking.md @@ -1,235 +1,61 @@ -# Canvas Exit Terminal Blank Tracking +# Canvas Exit Terminal Blank Closure -Last updated: 2026-04-15 -Status: Open, intermittent, now confirmed to affect both Canvas exit and Canvas entry via host ownership races +Last updated: 2026-04-29 +Status: Closed -## Symptom +## Outcome -When leaving Canvas and returning to the normal worktree terminal view, the terminal area can appear blank. +The Canvas exit / entry blank terminal issue has not reappeared after the host +ownership fix and occlusion reapply safeguards landed. Treat this investigation +as closed unless a new report includes a fresh reproduction pattern. -As of 2026-04-15, the reverse direction is also reproducible: +## Root Cause -- after the app has been running for a while, entering Canvas from a normal worktree tab can open a blank Canvas card -- the selected tab/worktree remains logically correct -- unlike the earlier exit symptom, tab switching, creating a new tab, or switching away and back does not reliably recover the blank card +The most likely failure mode was host ownership loss during SwiftUI/AppKit +reparenting: -Typical behavior: +- Canvas and terminal wrappers both host the same `GhosttySurfaceView`. +- A stale terminal wrapper could attempt to reattach a surface that was already + owned by the live Canvas wrapper. +- When that stale wrapper later deinitialized, AppKit removed the surface again, + leaving the active host blank even though reducer selection and tab state were + still correct. -- The normal terminal view is visible in SwiftUI. -- The selected worktree and tab are correct. -- The terminal becomes visible again only after switching to another tab and back. +## Fixes Kept -## Reproduction Profile +- Terminal hosts only defensively reattach orphaned surfaces; they do not steal a + surface from another live host. +- Occlusion state invalidates on attachment changes so the latest desired value + is resent after reattachment. +- Un-occluding is deferred until a surface has both a superview and a window; + occluding remains immediate so detached surfaces do not keep rendering. +- Canvas-managed terminal states avoid normal window-activity sync while Canvas + owns visibility. -Current evidence suggests this is not a fresh-session deterministic bug. +## Remaining Logs -- In a newly launched Prowl session, the bug is difficult to reproduce. -- After the app has been running for a long time, especially across system sleep/wake, the bug may start happening. -- Once it starts happening in a given app session, it tends to reproduce reliably on every Canvas exit until the app is restarted. +Most investigation logs were removed. The retained low-frequency logs are: -This strongly suggests a sticky stale state in the long-lived terminal/native view stack rather than a simple `toggleCanvas` reducer bug. +- `[CanvasExit] enteringCanvas` +- `[CanvasExit] setSelectedWorktreeID` +- `[CanvasExit] deferOcclusion` +- `[CanvasExit] hostReattach` +- `[CanvasExit] hostReattachComplete` +- `[TerminalWake]` runtime sleep/wake summaries -## Relevant Areas +These are enough to identify a regression without keeping wrapper lifecycle, +tab appear/disappear, attachment-change, or call-stack logging in normal builds. -- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` -- `supacode/Features/App/Reducer/AppFeature.swift` -- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` -- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` -- `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift` -- `supacode/Features/Terminal/Views/WindowFocusObserverView.swift` -- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` -- `supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift` -- `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` -- `supacode/Features/Canvas/Views/CanvasView.swift` +## Residual Risk -## Repair History +The remaining risk is in AppKit view lifecycle ordering. If a future SwiftUI +layout change introduces another host that can own `GhosttySurfaceView`, it must +follow the same rule: only adopt orphaned surfaces and never move a surface away +from another live host. -Known commits that attempted to address this family of issues: +Relevant coverage: -- `161f38a0` Fix blank surface when exiting canvas via toggle shortcut -- `516103e4` fix: invalidate occlusion cache when exiting canvas to prevent blank surfaces -- `11e7d16c` Fix occlusion cache invalidation on surface reattachment -- `7ed53813` fix: defer occlusion apply until surface is attached -- `26273089` fix: simplify canvas exit occlusion handling -- `d4e59155` Add canvas exit terminal diagnostics and occlusion refresh -- `e2a29b2c` test: cover Ghostty attachment occlusion behavior -- `32f51451` Fix canvas exit terminal occlusion recovery -- current branch `fix/canvas-exit-surface-reattach` - - add detach-intent stack logging before `GhosttySurfaceView` loses its superview/window - - add wrapper host diagnostics (`hostKind`, wrapper id, surface id) - - add terminal-only defensive reattach in `GhosttySurfaceScrollView.ensureSurfaceAttached()` - - add focused tests for terminal-vs-canvas wrapper ownership behavior - -Relevant changelog entries: - -- `2026.4.2`: occlusion restored whenever a surface is reattached -- `2026.4.5`: surface state refreshed immediately on Canvas exit -- current unreleased `main`: occlusion recovery also resumes from `updateSurfaceSize()` - -## Current Working Theory - -The highest-probability root cause is no longer "occlusion state got stale while the surface stayed attached". - -New evidence from a reduced two-tab repro points to a more concrete failure mode: - -- the selected surface (`desired=Optional(true)`, focused, first responder) is briefly attached during Canvas exit -- it reaches the normal terminal layout size -- it is later detached (`attached=false window=false`) -- no subsequent log shows that surface reattached to the final terminal host - -This suggests the blank terminal is caused by host ownership loss: - -- SwiftUI/AppKit reparenting during Canvas teardown temporarily moves the `GhosttySurfaceView` -- a later teardown or host rebuild removes the surface from the active view tree -- the normal terminal host does not currently guarantee that its `documentView` still owns the surface after updates -- once detached, occlusion recovery is irrelevant because there is no live host left to present the surface - -The newly observed Canvas-entry failure sharpens the theory further: - -- the canvas host can successfully take ownership of the selected surface -- the previous terminal host may still run a defensive `ensureSurfaceAttached()` while it is already leaving the window hierarchy -- because that reattach path only checked "not attached to my document view", it could steal the surface back from the live canvas host -- once the stale terminal host deinitializes, AppKit removes that stolen surface again, leaving Canvas blank with no active host - -What now looks less likely: - -- wrong worktree selection -- wrong selected tab restoration -- pure reducer ordering bug in `toggleCanvas` -- occlusion cache invalidation as the sole root cause - -## Logs Added For Ongoing Investigation - -Two log markers should be collected together: - -- `[CanvasExit]` -- `[TerminalWake]` - -### `[CanvasExit]` - -Existing and previous diagnostics already cover: - -- `WorktreeTerminalTabsView.onAppear` -- `WorktreeTerminalTabsView.onDisappear` -- surface attachment changes -- deferred occlusion -- reapply occlusion -- selected worktree transition when leaving Canvas -- surface detach intent (`viewWillMove(toSuperview:/toWindow:)`) with call stack -- host wrapper lifecycle (`hostMake`, `hostInit`, `hostUpdate`, `hostDeinit`, `hostReattach`) -- host reattach completion snapshot (`hostReattachComplete`) -- per-surface host metadata (`hostKind`, wrapper id) - -### `[TerminalWake]` - -Added on 2026-04-11 to correlate future failures with sleep/wake and long-lived surface state: - -- `GhosttyRuntime` - - `workspaceWillSleep` - - `workspaceDidWake` - - `screensDidSleep` - - `screensDidWake` - - runtime surface count -- `GhosttySurfaceView` - - per-surface state snapshot on workspace sleep/wake - - per-surface state snapshot on `viewDidMoveToWindow` - - per-surface state snapshot on `viewDidMoveToSuperview` - - detach-time safety-net request back to the last known terminal host -- `WindowFocusObserverView` - - window activity changes (`key`, `visible`, `force`, `windowNumber`) - -Per-surface wake logs include: - -- `surface` -- `hasSurface` -- `attached` -- `window` -- `desired` -- `focused` -- `firstResponder` -- `bounds` -- `backing` -- `windowVisible` -- `windowKey` - -## How To Collect Logs - -Use `make log-stream`, then reproduce the issue and save the section covering: - -- the last successful Canvas exit before the bug starts -- the first failed Canvas exit after the bug starts -- any sleep/wake events before that failure - -If filtering manually, focus on lines containing: - -- `[CanvasExit]` -- `[TerminalWake]` - -If using `log stream` directly, a useful predicate is: - -```bash -log stream --style compact \ - --predicate 'subsystem == "com.onevcat.prowl" && (eventMessage CONTAINS[c] "[CanvasExit]" || eventMessage CONTAINS[c] "[TerminalWake]")' -``` - -## What To Compare Next Time - -When the bug reproduces again, compare a healthy exit and a broken exit for: - -- whether `workspaceDidWake` or `screensDidWake` happened shortly before failures started -- whether the affected surface logs `detachIntent` before going blank, and which stack removes it -- whether a terminal host logs `hostUpdate` but never `hostReattach` for the affected surface -- whether the terminal host does log `hostReattach`, but the surface still remains blank afterward -- whether `WindowFocusObserverView` still reports the window as visible/key when the blank terminal is shown - -## Open Questions - -- Is sleep/wake the true trigger, or just the most common way to enter the stale state? -- Which host teardown path actually performs the final detach: Canvas wrapper cleanup, terminal wrapper replacement, or another AppKit rebuild? -- If terminal-side defensive reattach works, is it sufficient as the durable fix or just masking a lower-level host lifecycle race? -- If reattach does not work, is the detached native view still valid, or do we need to recreate the underlying `ghostty_surface_t`? - -## Next Step Candidates - -Do not do these preemptively unless new logs support them: - -- widen defensive reattach beyond the normal terminal host -- recreate a surface when host reattach fails -- add explicit post-wake repair for all active surfaces -- force-resend occlusion and size after wake -- force content-scale/display-id refresh after wake - -## Notes - -This document should be updated every time: - -- a new hypothesis is formed -- a new instrumentation point is added -- a repro pattern changes -- a candidate fix is attempted -- a failed fix is ruled out - -## 2026-04-15 Snapshot - -Latest reduced repro: - -- two tabs only -- selected tab surface detached after briefly reaching terminal-sized bounds -- no reattach log observed afterward -- reverse repro also confirmed: entering Canvas can blank the selected card immediately -- in the failing entry log, `hostReattach wrapper=` fires after `host=canvas` is already attached and visible -- the stale terminal wrapper later deinitializes and the surface ends up detached (`attached=false window=false`) - -Current tactical response: - -- add detach stack logging to identify who removes the surface -- add host wrapper diagnostics to correlate `surface ↔ wrapper ↔ canvas/terminal` -- attempt a narrow fix: terminal host reattaches the surface if updates/layout find it missing -- add a detach-time safety net so a just-detached surface asks its last terminal host to try reattachment on the next main-loop turn -- refine that narrow fix so terminal reattach only runs after the surface has actually left the view tree; it must not steal a surface currently owned by Canvas - -Expected interpretation of the next repro: - -- if `hostReattach` appears and the terminal becomes visible again, the bug is likely host ownership loss during Canvas teardown -- if `hostReattach` appears but the terminal stays blank, the issue may still involve stale native surface/render state after detach -- if no terminal `hostReattach` appears, the active terminal host may not be rebuilding/updating as expected +- `GhosttySurfaceViewTests.terminalHostDoesNotStealSurfaceFromCanvasHost` +- `GhosttySurfaceViewTests.canvasHostDoesNotStealDetachedSurfaceBack` +- `GhosttySurfaceViewTests.terminalHostReattachesSurfaceOnlyAfterItLeavesTheViewTree` +- occlusion reattachment tests in `GhosttySurfaceViewTests` diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index ca193772..2de65709 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -1,8 +1,6 @@ import AppKit import SwiftUI -private let terminalTabsLogger = SupaLogger("TerminalTabs") - struct WorktreeTerminalTabsView: View { let worktree: Worktree let manager: WorktreeTerminalManager @@ -84,33 +82,13 @@ struct WorktreeTerminalTabsView: View { state.focusSelectedTab() } let activity = resolvedWindowActivity - terminalTabsLogger.info( - "[CanvasExit] onAppear worktree=\(worktree.id) " - + "selectedTab=\(state.tabManager.selectedTabId?.rawValue.uuidString ?? "nil") " - + "autoFocus=\(shouldAutoFocusTerminal) " - + "windowKey=\(activity.isKeyWindow) windowVisible=\(activity.isVisible)" - ) state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - .onDisappear { - let activity = resolvedWindowActivity - terminalTabsLogger.info( - "[CanvasExit] onDisappear worktree=\(worktree.id) " - + "selectedTab=\(state.tabManager.selectedTabId?.rawValue.uuidString ?? "nil") " - + "windowKey=\(activity.isKeyWindow) windowVisible=\(activity.isVisible)" - ) - } - .onChange(of: state.tabManager.selectedTabId) { _, newValue in + .onChange(of: state.tabManager.selectedTabId) { _, _ in if shouldAutoFocusTerminal { state.focusSelectedTab() } let activity = resolvedWindowActivity - terminalTabsLogger.info( - "[CanvasExit] selectedTabChanged worktree=\(worktree.id) " - + "selectedTab=\(newValue?.rawValue.uuidString ?? "nil") " - + "autoFocus=\(shouldAutoFocusTerminal) " - + "windowKey=\(activity.isKeyWindow) windowVisible=\(activity.isVisible)" - ) state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 84f7ec8a..d9324b9d 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -6,7 +6,6 @@ import QuartzCore import SwiftUI private let surfaceLogger = SupaLogger("Surface") -private let surfaceHostLogger = SupaLogger("SurfaceHost") final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { @@ -129,7 +128,6 @@ final class GhosttySurfaceView: NSView, Identifiable { private var lastSurfaceFocus: Bool? private var eventMonitor: Any? private var notificationObservers: [NSObjectProtocol] = [] - private var workspaceObservers: [NSObjectProtocol] = [] private var prevPressureStage: Int = 0 private var isBackgroundOpaqueOverride = false private lazy var cachedScreenContents = CachedValue(duration: .milliseconds(500)) { @@ -283,7 +281,6 @@ final class GhosttySurfaceView: NSView, Identifiable { } } registerForDraggedTypes(Array(Self.dropTypes)) - registerWorkspaceObservers() eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyUp, .leftMouseDown]) { [weak self] event in @@ -300,7 +297,6 @@ final class GhosttySurfaceView: NSView, Identifiable { NSEvent.removeMonitor(eventMonitor) } clearNotificationObservers() - clearWorkspaceObservers() let id = ObjectIdentifier(self) MainActor.assumeIsolated { SecureInput.shared.removeScoped(id) @@ -401,50 +397,6 @@ final class GhosttySurfaceView: NSView, Identifiable { }) } - private func registerWorkspaceObservers() { - let center = NSWorkspace.shared.notificationCenter - workspaceObservers.append( - center.addObserver( - forName: NSWorkspace.willSleepNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.logLifecycleState("workspaceWillSleep") - } - }) - workspaceObservers.append( - center.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.logLifecycleState("workspaceDidWake") - } - }) - workspaceObservers.append( - center.addObserver( - forName: NSWorkspace.screensDidSleepNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.logLifecycleState("screensDidSleep") - } - }) - workspaceObservers.append( - center.addObserver( - forName: NSWorkspace.screensDidWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.logLifecycleState("screensDidWake") - } - }) - } - private func windowDidChangeScreen() { guard let surface, let screen = window?.screen else { return } let displayID = @@ -463,14 +415,6 @@ final class GhosttySurfaceView: NSView, Identifiable { notificationObservers.removeAll() } - private func clearWorkspaceObservers() { - let center = NSWorkspace.shared.notificationCenter - for observer in workspaceObservers { - center.removeObserver(observer) - } - workspaceObservers.removeAll() - } - override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -497,30 +441,14 @@ final class GhosttySurfaceView: NSView, Identifiable { updateContentScale() updateSurfaceSize() applyWindowBackgroundAppearance() - logLifecycleState("viewDidMoveToWindow") handleAttachmentChange() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - logLifecycleState("viewDidMoveToSuperview") handleAttachmentChange() } - override func viewWillMove(toSuperview newSuperview: NSView?) { - if newSuperview == nil { - logDetachIntent(event: "viewWillMoveToSuperview") - } - super.viewWillMove(toSuperview: newSuperview) - } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - if newWindow == nil { - logDetachIntent(event: "viewWillMoveToWindow") - } - super.viewWillMove(toWindow: newWindow) - } - override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { @@ -1185,13 +1113,6 @@ final class GhosttySurfaceView: NSView, Identifiable { // Re-parenting can temporarily detach the Metal layer from the visible // tree and pause Ghostty's renderer. Invalidate the applied cache so the // currently desired occlusion value is sent again after reattachment. - surfaceLogger.info( - "[CanvasExit] attachmentChange surface=\(debugID) " - + "desired=\(String(describing: occlusionState.desired)) " - + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow) " - + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " - + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none")" - ) _ = occlusionState.invalidateForAttachmentChange() if superview == nil { DispatchQueue.main.async { [weak self] in @@ -1217,40 +1138,8 @@ final class GhosttySurfaceView: NSView, Identifiable { reapplyOcclusionIfNeeded() } - private func logLifecycleState(_ event: String) { - let windowVisible = window?.occlusionState.contains(.visible) ?? false - let windowKey = window?.isKeyWindow ?? false - let firstResponderMatches = window?.firstResponder === self - surfaceLogger.info( - "[TerminalWake] event=\(event) surface=\(debugID) hasSurface=\(surface != nil) " - + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow) " - + "desired=\(String(describing: occlusionState.desired)) " - + "focused=\(focused) firstResponder=\(firstResponderMatches) " - + "bounds=\(Int(bounds.width))x\(Int(bounds.height)) " - + "backing=\(Int(lastBackingSize.width))x\(Int(lastBackingSize.height)) " - + "windowVisible=\(windowVisible) windowKey=\(windowKey) " - + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " - + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none")" - ) - } - - private func logDetachIntent(event: String) { - let stack = Thread.callStackSymbols.prefix(12).joined(separator: " | ") - surfaceLogger.info( - "[CanvasExit] detachIntent event=\(event) surface=\(debugID) " - + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " - + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none") " - + "superview=\(String(describing: superview)) window=\(window != nil) " - + "stack=\(stack)" - ) - } - private func reapplyOcclusionIfNeeded() { guard isReadyToApplyOcclusion, let desired = occlusionState.desired else { return } - surfaceLogger.info( - "[CanvasExit] reapplyOcclusion surface=\(debugID) desired=\(desired) " - + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" - ) setOcclusion(desired) } @@ -2459,11 +2348,6 @@ final class GhosttySurfaceScrollView: NSView { super.init(frame: .zero) addSubview(scrollView) surfaceView.scrollWrapper = self - surfaceHostLogger.info( - "[CanvasExit] hostInit wrapper=\(debugID) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "attached=\(isSurfaceAttachedToDocumentView)" - ) refreshAppearance() scrollView.contentView.postsBoundsChangedNotifications = true @@ -2540,11 +2424,6 @@ final class GhosttySurfaceScrollView: NSView { } isolated deinit { - surfaceHostLogger.info( - "[CanvasExit] hostDeinit wrapper=\(debugID) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "attached=\(isSurfaceAttachedToDocumentView)" - ) observers.forEach { NotificationCenter.default.removeObserver($0) } } @@ -2582,7 +2461,7 @@ final class GhosttySurfaceScrollView: NSView { guard !isSurfaceAttachedToDocumentView else { return } // Only adopt an orphaned surface; never steal it from a live host such as Canvas. guard surfaceView.superview == nil else { return } - surfaceHostLogger.info( + surfaceLogger.info( "[CanvasExit] hostReattach wrapper=\(debugID) host=\(hostKind.rawValue) " + "surface=\(surfaceView.debugIdentifierForLogging) " + "currentSuperview=\(String(describing: surfaceView.superview)) " @@ -2590,7 +2469,7 @@ final class GhosttySurfaceScrollView: NSView { ) documentView.addSubview(surfaceView) surfaceView.scrollWrapper = self - surfaceHostLogger.info( + surfaceLogger.info( "[CanvasExit] hostReattachComplete wrapper=\(debugID) host=\(hostKind.rawValue) " + "surface=\(surfaceView.debugIdentifierForLogging) " + "superview=\(surfaceView.superview != nil) " diff --git a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift index 9cd7ec9b..0d71a359 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -20,11 +20,6 @@ struct GhosttyTerminalView: NSViewRepresentable { return terminalHostLogger.interval("Ghostty.makeNSView") { let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind) view.pinnedSize = pinnedSize - terminalHostLogger.info( - "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "pinned=\(pinnedSize != nil)" - ) return view } } @@ -32,12 +27,6 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { terminalHostLogger.interval("Ghostty.updateNSView") { view.pinnedSize = pinnedSize - terminalHostLogger.info( - "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "pinned=\(pinnedSize != nil) " - + "attached=\(view.isSurfaceAttachedToDocumentView)" - ) view.ensureSurfaceAttached() } } -- 2.51.2 From ca5867345d88084d60c7c4ff91465d5bcbf73b82 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Apr 2026 22:59:25 +0900 Subject: [PATCH 016/528] Fix Shelf empty state fallback --- .../Features/Shelf/Models/ShelfBook.swift | 11 ++++++++ supacode/Features/Shelf/Views/ShelfView.swift | 9 ++++--- supacodeTests/ShelfBookOrderingTests.swift | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Shelf/Models/ShelfBook.swift b/supacode/Features/Shelf/Models/ShelfBook.swift index 672fdab3..79a2d991 100644 --- a/supacode/Features/Shelf/Models/ShelfBook.swift +++ b/supacode/Features/Shelf/Models/ShelfBook.swift @@ -115,4 +115,15 @@ extension RepositoriesFeature.State { var openShelfBookID: Worktree.ID? { selectedTerminalWorktree?.id } + + /// The rendered book matching the current Shelf selection, if any. + /// + /// `openShelfBookID` can briefly point at a worktree/folder that has + /// just been retired from `openedWorktreeIDs` after its last tab closes. + /// Views should use this lookup rather than assuming a non-nil open ID + /// means an open book is still present in `orderedShelfBooks()`. + func openShelfBook(in books: [ShelfBook]) -> ShelfBook? { + guard let openShelfBookID else { return nil } + return books.first(where: { $0.id == openShelfBookID }) + } } diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index a8f4b50b..1aa07110 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -32,9 +32,10 @@ struct ShelfView: View { let _ = shelfLogger.event("ShelfView.body") let state = store.state let books = state.orderedShelfBooks(customTitles: state.repositoryCustomTitles) - let openBookID = state.openShelfBookID - let openIndex = openBookID.flatMap { id in - books.firstIndex(where: { $0.id == id }) + let openBook = state.openShelfBook(in: books) + let openBookID = openBook?.id + let openIndex = openBook.flatMap { book in + books.firstIndex(where: { $0.id == book.id }) } HStack(spacing: 0) { @@ -44,7 +45,7 @@ struct ShelfView: View { openBookArea(for: book, state: state) } } - if openBookID == nil { + if openBook == nil { emptyOpenArea() } } diff --git a/supacodeTests/ShelfBookOrderingTests.swift b/supacodeTests/ShelfBookOrderingTests.swift index a1e919a0..9212eecb 100644 --- a/supacodeTests/ShelfBookOrderingTests.swift +++ b/supacodeTests/ShelfBookOrderingTests.swift @@ -182,6 +182,33 @@ struct ShelfBookOrderingTests { #expect(state.openShelfBookID == worktree.id) } + @Test func openShelfBookReturnsNilWhenSelectedBookIsNoLongerRendered() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let worktree = Worktree( + id: "/tmp/repo", + name: "main", + detail: "", + workingDirectory: rootURL, + repositoryRootURL: rootURL + ) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repository.id] + state.selection = .worktree(worktree.id) + state.openedWorktreeIDs = [] + + let books = state.orderedShelfBooks() + #expect(books.isEmpty) + #expect(state.openShelfBookID == worktree.id) + #expect(state.openShelfBook(in: books) == nil) + } + @Test func openShelfBookIDResolvesPlainFolderViaRepositoryID() { let rootURL = URL(fileURLWithPath: "/tmp/plain") let repository = Repository( -- 2.51.2 From 4e053e5060d3d164c6ceb21c6347d50ea907e57b Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 30 Apr 2026 10:19:36 +0900 Subject: [PATCH 017/528] Add sidebar repository expand toggle --- .../Repositories/Views/SidebarListView.swift | 101 ++++++++++++++++++ .../RepositorySectionViewTests.swift | 63 +++++++++++ 2 files changed, 164 insertions(+) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index d58ba7d3..5d6300da 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -2,6 +2,33 @@ import ComposableArchitecture import SwiftUI struct SidebarListView: View { + enum RepositoryListHeaderAction: Equatable { + case expandAll + case collapseAll + + var title: String { + switch self { + case .expandAll: + return "Expand All" + case .collapseAll: + return "Collapse All" + } + } + + var systemImageName: String { + "chevron.right" + } + + var rotation: Angle { + switch self { + case .expandAll: + return .zero + case .collapseAll: + return .degrees(90) + } + } + } + @Bindable var store: StoreOf @Binding var expandedRepoIDs: Set @Binding var sidebarSelections: Set @@ -13,6 +40,13 @@ struct SidebarListView: View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) let orderedRoots = state.orderedRepositoryRoots() + let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) + let repositoryListHeaderAction = Self.repositoryListHeaderAction( + expandedRepoIDs: expandedRepoIDs, + expandableRepositoryIDs: expandableRepositoryIDs + ) + let visibleRepositoryCount = orderedRoots.isEmpty ? state.repositories.count : orderedRoots.count + let showsRepositoryListHeader = Self.showsRepositoryListHeader(repositoryCount: visibleRepositoryCount) let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) let selection = Binding>( get: { @@ -102,6 +136,14 @@ struct SidebarListView: View { ScrollViewReader { scrollProxy in List(selection: selection) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + .listRowInsets(EdgeInsets()) + } + if orderedRoots.isEmpty { let repositories = store.repositories ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in @@ -240,6 +282,42 @@ struct SidebarListView: View { } } + private func repositoryListHeader( + action: RepositoryListHeaderAction, + expandableRepositoryIDs: Set + ) -> some View { + HStack(spacing: 8) { + Text("Repositories") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + if !expandableRepositoryIDs.isEmpty { + Button { + withAnimation(.easeOut(duration: 0.2)) { + switch action { + case .expandAll: + expandedRepoIDs.formUnion(expandableRepositoryIDs) + case .collapseAll: + expandedRepoIDs.subtract(expandableRepositoryIDs) + } + } + } label: { + Label(action.title, systemImage: action.systemImageName) + .labelStyle(.iconOnly) + .frame(width: 20, height: 20) + .rotationEffect(action.rotation) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(action.title) + } + } + .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) + .padding(.top, 8) + .padding(.bottom, 4) + } + @MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, @@ -255,6 +333,29 @@ struct SidebarListView: View { } store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } + + static func expandableRepositoryIDs( + in repositories: Repositories + ) -> Set where Repositories.Element == Repository { + Set( + repositories + .filter(\.capabilities.supportsWorktrees) + .map(\.id) + ) + } + + static func repositoryListHeaderAction( + expandedRepoIDs: Set, + expandableRepositoryIDs: Set + ) -> RepositoryListHeaderAction { + !expandedRepoIDs.isDisjoint(with: expandableRepositoryIDs) + ? .collapseAll + : .expandAll + } + + static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { + repositoryCount > 10 + } } // MARK: - Previews diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index f0385838..05c64f24 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -6,6 +6,69 @@ import Testing @MainActor struct RepositorySectionViewTests { + @Test func sidebarHeaderActionCollapsesWhenAnyExpandableRepositoryIsOpen() { + let gitRepository = Repository( + id: "/tmp/git", + rootURL: URL(fileURLWithPath: "/tmp/git"), + name: "git", + kind: .git, + worktrees: [] + ) + let plainRepository = Repository( + id: "/tmp/plain", + rootURL: URL(fileURLWithPath: "/tmp/plain"), + name: "plain", + kind: .plain, + worktrees: [] + ) + let expandableIDs = SidebarListView.expandableRepositoryIDs( + in: [gitRepository, plainRepository] + ) + + #expect(expandableIDs == [gitRepository.id]) + #expect( + SidebarListView.repositoryListHeaderAction( + expandedRepoIDs: [], + expandableRepositoryIDs: [] + ) + == .expandAll + ) + #expect( + SidebarListView.repositoryListHeaderAction( + expandedRepoIDs: [], + expandableRepositoryIDs: expandableIDs + ) + == .expandAll + ) + #expect( + SidebarListView.repositoryListHeaderAction( + expandedRepoIDs: [gitRepository.id], + expandableRepositoryIDs: expandableIDs + ) + == .collapseAll + ) + #expect( + SidebarListView.repositoryListHeaderAction( + expandedRepoIDs: [gitRepository.id, plainRepository.id], + expandableRepositoryIDs: expandableIDs + ) + == .collapseAll + ) + #expect( + SidebarListView.repositoryListHeaderAction( + expandedRepoIDs: [plainRepository.id], + expandableRepositoryIDs: expandableIDs + ) + == .expandAll + ) + } + + @Test func sidebarHeaderOnlyShowsForLongRepositoryLists() { + #expect(!SidebarListView.showsRepositoryListHeader(repositoryCount: 0)) + #expect(!SidebarListView.showsRepositoryListHeader(repositoryCount: 10)) + #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) + } + @Test func openTabCountForGitRepositorySumsAllWorktrees() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo") -- 2.51.2 From aa7f81ab8b36ef66f85d9dcc932a3f1f65458f8a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 30 Apr 2026 14:24:53 +0900 Subject: [PATCH 018/528] bump v2026.4.30 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index d2c1f015..6297b677 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260429; + CURRENT_PROJECT_VERSION = 20260430; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.29; + MARKETING_VERSION = 2026.4.30; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260429; + CURRENT_PROJECT_VERSION = 20260430; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.29; + MARKETING_VERSION = 2026.4.30; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260429; + CURRENT_PROJECT_VERSION = 20260430; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.29; + MARKETING_VERSION = 2026.4.30; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260429; + CURRENT_PROJECT_VERSION = 20260430; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.29; + MARKETING_VERSION = 2026.4.30; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 55d1bb18..ac629973 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.4.29" + public static let current = "2026.4.30" } -- 2.51.2 From e7fc56144ae1d3870a75746399fa897312ec8692 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 30 Apr 2026 14:24:55 +0900 Subject: [PATCH 019/528] Update CHANGELOG for 2026.4.30 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ed1aa1..4204a609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026.4.30](https://github.com/onevcat/Prowl/releases/tag/v2026.4.30) + +### New + +- When you have more than 10 repositories in the sidebar, a "Repositories" header appears with an expand/collapse-all toggle. The toggle collapses all open repositories if any are currently expanded, and expands all when every repository is collapsed. + +### Fixed + +- The terminal now correctly regains focus when selecting a single item in the sidebar. +- The terminal now correctly regains focus after making a selection in the Shelf panel. +- Fixed an incorrect empty state displayed in the Shelf under certain conditions. + ## [2026.4.29](https://github.com/onevcat/Prowl/releases/tag/v2026.4.29) This release focuses on Shelf-mode responsiveness — switching books, especially via keyboard shortcuts, is noticeably snappier after a sweep of unnecessary SwiftUI invalidations. -- 2.51.2 From 82653f438f70954c52f902aba553e2da27f632e0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 2 May 2026 12:55:00 +0900 Subject: [PATCH 020/528] Add Shelf section to README highlights --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c2a5e406..c9007dfd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ Three agents running, one just finished — _where_? Canvas gives you a bird's-e
+### 📚 Shelf — your worktrees, lined up like books on a shelf + +Shelf view with vertical worktree spines and tabs + +Every worktree becomes a vertical **spine** stacked on the side, with its tabs nested underneath. Flip through your stack from the keyboard — **`⌘⌃←` / `⌘⌃→` cycles books · `⌘⌃↑` / `⌘⌃↓` cycles tabs** — so when you've got six agents in flight, you triage them one keystroke at a time, never losing your place. + +
+ ### ⚡ Custom Actions — one keystroke, any workflow Custom Actions with per-repo buttons and shortcuts -- 2.51.2 From 012b1dc02e3b77783b886230d80272ac20b31791 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 17:26:45 +0900 Subject: [PATCH 021/528] Fix Cmd-W terminal tab close fallback --- supacode/Commands/WindowCommands.swift | 27 ++++++++++- .../Views/WorktreeDetailView.swift | 44 +++++++++++++++-- .../Models/WorktreeTerminalState.swift | 14 ++++++ .../WindowCloseShortcutPolicyTests.swift | 48 +++++++++++++++++++ .../WorktreeTerminalManagerTests.swift | 22 +++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 supacodeTests/WindowCloseShortcutPolicyTests.swift diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index c44a3e9b..5f25aa85 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -3,6 +3,7 @@ import SwiftUI struct WindowCommands: Commands { let ghosttyShortcuts: GhosttyShortcutManager let resolvedKeybindings: ResolvedKeybindingMap + @FocusedValue(\.closeTabAction) private var closeTabAction @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction @FocusedValue(\.selectNextTerminalTabAction) private var selectNextTerminalTabAction @@ -15,7 +16,12 @@ struct WindowCommands: Commands { var body: some Commands { let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") - let isCloseSurfaceOverlapping = closeSurfaceHotkey?.key == "w" && closeSurfaceHotkey?.modifiers == .command + let closeTabHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_tab") + let closeWindowShortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: closeSurfaceHotkey, + closeTabShortcut: closeTabHotkey, + hasTerminalCloseTarget: closeTabAction != nil || closeSurfaceAction != nil + ) CommandGroup(replacing: .saveItem) { Button("Close Window", systemImage: "xmark") { @@ -23,7 +29,7 @@ struct WindowCommands: Commands { } .modifier( KeyboardShortcutModifier( - shortcut: !isCloseSurfaceOverlapping || closeSurfaceAction == nil ? .init("w") : nil + shortcut: closeWindowShortcut ) ) } @@ -125,6 +131,23 @@ struct WindowCommands: Commands { } } +enum WindowCloseShortcutPolicy { + static func closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut?, + closeTabShortcut: KeyboardShortcut?, + hasTerminalCloseTarget: Bool + ) -> KeyboardShortcut? { + if hasTerminalCloseTarget && (isCommandW(closeSurfaceShortcut) || isCommandW(closeTabShortcut)) { + return nil + } + return KeyboardShortcut("w") + } + + private static func isCommandW(_ shortcut: KeyboardShortcut?) -> Bool { + shortcut?.key == "w" && shortcut?.modifiers == .command + } +} + struct KeyboardShortcutModifier: ViewModifier { let shortcut: KeyboardShortcut? diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 21d15aef..970bd66b 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -280,8 +280,8 @@ struct WorktreeDetailView: View { content .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree) .focusedSceneValue(\.newTerminalAction, actions.newTerminal) - .focusedValue(\.closeTabAction, actions.closeTab) - .focusedValue(\.closeSurfaceAction, actions.closeSurface) + .focusedSceneValue(\.closeTabAction, actions.closeTab) + .focusedSceneValue(\.closeSurfaceAction, actions.closeSurface) .focusedSceneValue(\.resetFontSizeAction, actions.resetFontSize) .focusedSceneValue(\.increaseFontSizeAction, actions.increaseFontSize) .focusedSceneValue(\.decreaseFontSizeAction, actions.decreaseFontSize) @@ -353,11 +353,47 @@ struct WorktreeDetailView: View { } } + func closeTabAction() -> (() -> Void)? { + if repositories.isShowingCanvas { + guard let worktreeID = terminalManager.canvasFocusedWorktreeID, + let state = terminalManager.stateIfExists(for: worktreeID), + state.canCloseFocusedTab + else { + return nil + } + return { _ = state.closeFocusedTab() } + } + guard hasActiveWorktree, let selectedWorktree = repositories.selectedTerminalWorktree, + terminalManager.stateIfExists(for: selectedWorktree.id)?.canCloseFocusedTab == true + else { + return nil + } + return { store.send(.closeTab) } + } + + func closeSurfaceAction() -> (() -> Void)? { + if repositories.isShowingCanvas { + guard let worktreeID = terminalManager.canvasFocusedWorktreeID, + let state = terminalManager.stateIfExists(for: worktreeID), + state.canCloseFocusedSurface + else { + return nil + } + return { _ = state.closeFocusedSurface() } + } + guard hasActiveWorktree, let selectedWorktree = repositories.selectedTerminalWorktree, + terminalManager.stateIfExists(for: selectedWorktree.id)?.canCloseFocusedSurface == true + else { + return nil + } + return { store.send(.closeSurface) } + } + return FocusedActions( openSelectedWorktree: action(.openSelectedWorktree), newTerminal: action(.newTerminal), - closeTab: canvasAction { $0.closeFocusedTab() } ?? action(.closeTab), - closeSurface: canvasAction { $0.closeFocusedSurface() } ?? action(.closeSurface), + closeTab: closeTabAction(), + closeSurface: closeSurfaceAction(), resetFontSize: fontSizeAction("reset_font_size"), increaseFontSize: fontSizeAction("increase_font_size:1"), decreaseFontSize: fontSizeAction("decrease_font_size:1"), diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 6c162118..8c52fcc1 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -69,6 +69,20 @@ final class WorktreeTerminalState { let surfaceIds = trees[tabId]?.leaves().map(\.id) ?? [] return notifications.contains { !$0.isRead && surfaceIds.contains($0.surfaceId) } } + + var canCloseFocusedTab: Bool { + tabManager.selectedTabId != nil + } + + var canCloseFocusedSurface: Bool { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId] + else { + return false + } + return surfaces[focusedId] != nil + } + var isSelected: () -> Bool = { false } var onNotificationReceived: ((String, String) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? diff --git a/supacodeTests/WindowCloseShortcutPolicyTests.swift b/supacodeTests/WindowCloseShortcutPolicyTests.swift new file mode 100644 index 00000000..e61fcd34 --- /dev/null +++ b/supacodeTests/WindowCloseShortcutPolicyTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Testing + +@testable import supacode + +struct WindowCloseShortcutPolicyTests { + @Test func closeWindowDoesNotClaimCommandWWhenCloseSurfaceUsesCommandW() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w"), + closeTabShortcut: nil, + hasTerminalCloseTarget: true + ) + + #expect(shortcut == nil) + } + + @Test func closeWindowDoesNotClaimCommandWWhenCloseTabUsesCommandW() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w", modifiers: [.option, .command]), + closeTabShortcut: KeyboardShortcut("w"), + hasTerminalCloseTarget: true + ) + + #expect(shortcut == nil) + } + + @Test func closeWindowKeepsCommandWWhenTerminalCloseActionsUseDifferentShortcuts() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w", modifiers: [.shift, .command]), + closeTabShortcut: KeyboardShortcut("w", modifiers: [.option, .command]), + hasTerminalCloseTarget: true + ) + + #expect(shortcut?.key == "w") + #expect(shortcut?.modifiers == .command) + } + + @Test func closeWindowKeepsCommandWWhenTerminalHasNoCloseTarget() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w"), + closeTabShortcut: KeyboardShortcut("w"), + hasTerminalCloseTarget: false + ) + + #expect(shortcut?.key == "w") + #expect(shortcut?.modifiers == .command) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 74ea8ab6..4b8c3a1d 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -66,6 +66,28 @@ struct WorktreeTerminalManagerTests { #expect(state.onFontSizeAdjusted != nil) } + @Test func closeTargetAvailabilityFollowsTerminalModelState() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + #expect(state.canCloseFocusedTab == false) + #expect(state.canCloseFocusedSurface == false) + + let tabId = state.createTab() + + #expect(tabId != nil) + #expect(state.canCloseFocusedTab == true) + #expect(state.canCloseFocusedSurface == true) + + if let tabId { + state.closeTab(tabId) + } + + #expect(state.canCloseFocusedTab == false) + #expect(state.canCloseFocusedSurface == false) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From b7f1699fbb079e38668895d10e90a1508b608f08 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 17:41:00 +0900 Subject: [PATCH 022/528] Align formatting rules with swift-format --- AGENTS.md | 2 +- supacode/Clients/Shell/ShellClient.swift | 4 +++- .../Repositories/Views/WorktreeDetailView.swift | 2 +- .../Infrastructure/Ghostty/GhosttySurfaceView.swift | 8 ++++++-- supacodeTests/RepositoriesFeatureTests.swift | 12 ++++++------ supacodeTests/RepositorySettingsFeatureTests.swift | 4 ++-- supacodeTests/WritableKeyPath+Sendable.swift | 1 + 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90689ddc..a0e0f793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream ### Formatting & Linting - 2-space indentation, 120 character line length (enforced by `.swift-format.json`) -- Trailing commas are mandatory (enforced by `.swiftlint.yml`) +- `swift-format` is the source of truth for trailing commas: multi-element collection literals keep trailing commas, while single-element collection literals may have them removed. - SwiftLint runs in strict mode; never disable lint rules without permission - Custom SwiftLint rule: `store_state_mutation_in_views` — do not mutate `store.*` directly in view files; send actions instead - Before creating a PR, run `make lint`. diff --git a/supacode/Clients/Shell/ShellClient.swift b/supacode/Clients/Shell/ShellClient.swift index f0fe17f7..94f0aeed 100644 --- a/supacode/Clients/Shell/ShellClient.swift +++ b/supacode/Clients/Shell/ShellClient.swift @@ -336,7 +336,9 @@ nonisolated private func lineStream(from handle: FileHandle) -> AsyncStream=6) + // swift-format-ignore: AvoidRetroactiveConformances extension WritableKeyPath: @retroactive @unchecked Sendable {} #endif -- 2.51.2 From 06d55593b8d0dbd68a0165e8106200e1432598b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 17:50:26 +0900 Subject: [PATCH 023/528] Tighten Swift format workflow Closes #195 --- AGENTS.md | 9 +++++---- Makefile | 25 ++++++++++++++++++++++--- README.md | 7 ++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0e0f793..a85a7da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,10 @@ make build-app # Build macOS app (Debug) via xcodebuild make run-app # Build and launch Debug app make install-dev-build # Build and copy to /Applications (Debug) make install-release # Build Release, sign locally, install to /Applications -make format # Run swift-format only -make lint # Run swiftlint only (fix + lint) -make check # Run both format and lint +make format-changed # Run swift-format on changed Swift files only +make format # Run full-tree swift-format cleanup +make lint # Run swiftlint only +make check # Run changed-file format, swift-format lint, and swiftlint make test # Run all tests make log-stream # Stream app logs (subsystem: com.onevcat.prowl) make build-cli # Build CLI (prowl) via SwiftPM @@ -103,7 +104,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream - `swift-format` is the source of truth for trailing commas: multi-element collection literals keep trailing commas, while single-element collection literals may have them removed. - SwiftLint runs in strict mode; never disable lint rules without permission - Custom SwiftLint rule: `store_state_mutation_in_views` — do not mutate `store.*` directly in view files; send actions instead -- Before creating a PR, run `make lint`. +- Before creating a PR, run `make check`. Use `make format` only for intentional full-tree formatting cleanup. ## UX Standards diff --git a/Makefile b/Makefile index 81643119..f23eeaf5 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ SPM_CACHE_DIR := /tmp/supacode-spm-cache/SourcePackages VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= +FORMAT_BASE_REF ?= origin/main # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. @@ -28,7 +29,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -303,13 +304,31 @@ test-cli-smoke: build-cli # Smoke test CLI executable test-cli-integration: # Run CLI integration tests via SwiftPM swift test --filter ProwlCLIIntegrationTests -format: # Format code with swift-format (local only) +format: # Format all Swift code with swift-format (full-tree cleanup) swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests +format-changed: # Format Swift files changed from FORMAT_BASE_REF (default: origin/main) + @base="$$(git merge-base HEAD "$(FORMAT_BASE_REF)" 2>/dev/null || git rev-parse HEAD)"; \ + mapfile -t files < <( \ + { \ + git diff --name-only --diff-filter=ACMR "$$base" -- supacode supacodeTests; \ + git ls-files --others --exclude-standard -- supacode supacodeTests; \ + } | awk '/\.swift$$/' | sort -u \ + ); \ + if [ "$${#files[@]}" -eq 0 ]; then \ + echo "No changed Swift files to format."; \ + else \ + printf 'Formatting %s changed Swift file(s) from %s\n' "$${#files[@]}" "$$base"; \ + swift-format -p --in-place --configuration ./.swift-format.json "$${files[@]}"; \ + fi + +format-lint: # Check Swift formatting without rewriting files + swift-format lint --strict --recursive --configuration ./.swift-format.json supacode supacodeTests + lint: # Lint code with swiftlint mise exec -- swiftlint lint --quiet --config .swiftlint.yml -check: format lint # Format and lint +check: format-changed format-lint lint # Format changed Swift files, then run swift-format lint and SwiftLint log-stream: # Stream logs from the app via log stream log stream --predicate 'subsystem == "com.onevcat.prowl"' --style compact --color always diff --git a/README.md b/README.md index c9007dfd..6536355f 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,10 @@ make install-release # Build Release, sign locally, install to /Appl ### Develop & test ```bash -make check # swift-format + swiftlint -make format # Format only -make lint # Lint only +make check # Format changed Swift files, then run swift-format lint + SwiftLint +make format-changed # Format changed Swift files only +make format # Full-tree Swift format cleanup +make lint # SwiftLint only make test # Run app/unit tests make log-stream # Stream app logs (subsystem: com.onevcat.prowl) ``` -- 2.51.2 From 46e539ee03715af26dd3ad1d1117ad60b29c8826 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 17:53:29 +0900 Subject: [PATCH 024/528] Document branch workflow for unrelated work --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a85a7da0..e29296c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream - After a task, ensure the app builds: `make build-app` - When working on CLI code (`ProwlCLI/`, `ProwlCLITests/`, `Package.swift`), run `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` before committing. +- When implementing a new feature or fixing a bug that is unrelated to the current branch's active work, first create a dedicated branch from the latest `origin/main`; then work, commit, push, and open a PR from that branch. - Automatically commit your changes and your changes only. Do not use `git add .` - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch - After implementing an execplan, always submit a PR if you're not in the main branch -- 2.51.2 From 4c9b72e3415cd1fa2f6aaf2c6b8867ad8ee586d4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 20:15:43 +0900 Subject: [PATCH 025/528] Plan sidebar container refactor Refs #249 --- ...6-05-03-sidebar-container-refactor-plan.md | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md diff --git a/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md b/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md new file mode 100644 index 00000000..9a71ed25 --- /dev/null +++ b/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md @@ -0,0 +1,508 @@ +# Sidebar Container Refactor Plan + +Status: planning +Issue: [#249](https://github.com/onevcat/Prowl/issues/249) +Related: [#222](https://github.com/onevcat/Prowl/issues/222) + +## Goal + +Refactor the repository sidebar so each repository behaves as one stable visual and drag unit, while worktrees remain selectable, reorderable, and efficient to update. + +This should fix the structural mismatch where the app treats repositories as reorderable units but SwiftUI `List` sees repository headers and worktree rows as separate rows. That mismatch shows up as: + +- incorrect repository drag insertion indicators when dragging downward across expanded repositories +- unstable bulk expand/collapse animations +- potential sidebar flicker during drag when live terminal / notification / ordering updates arrive + +## Current Findings + +### 1. Repository sections are not actual list rows + +`SidebarListView` renders repositories through an outer `ForEach(...).onMove`. + +`RepositorySectionView` then returns: + +```swift +Group { + header + .tag(SidebarSelection.repository(repository.id)) + if isExpanded { + WorktreeRowsView(...) + } +} +``` + +In practice, the outer data model says "repository row", but `List` receives separate rows: + +```text +Repository A header + Repository A worktree + Repository A worktree +Repository B header + Repository B worktree +``` + +This explains the observed downward-drag indicator bug: + +```text +Target Repo header +o----------- +Target Repo worktree +``` + +SwiftUI is placing the indicator between list rows. It does not know that the target repository header and its worktree rows should be treated as one repository-level drop zone. + +### 2. `List(selection:)` is doing too much + +The current `List` carries several behaviors at once: + +- special rows: Canvas, Shelf, archived worktrees, repository list header +- repository row selection for plain folders +- worktree multi-selection +- repository expand/collapse +- native repository reorder +- native worktree reorder for pinned and unpinned groups +- reveal-in-sidebar via `ScrollViewReader.scrollTo` +- native sidebar styling and accessibility + +This makes local fixes brittle because changing row structure affects selection, drag, animation, and scroll identity at the same time. + +### 3. Live state still reaches rows during drag + +Some expensive state reads have already been isolated, such as moving repository tab-count reads into `RepoHeaderTabCountBadge`. + +Remaining drag-time churn sources include: + +- `WorktreeRowsView` animates changes to `rowIDs` +- each worktree row reads terminal notification/task/run-script state +- notification-driven reorder can call `withAnimation(.snappy)` and mutate `worktreeOrderByRepository` +- row hover/action UI changes while a drag session is active + +These are not necessarily the root cause of the drop-indicator bug, but they are credible contributors to #222-style flicker. + +## Recommended Direction + +Use a custom sidebar scroll container rather than trying to keep the current flat `List` structure. + +Recommended shape: + +```text +ScrollViewReader +└── ScrollView + └── LazyVStack or VStack + ├── Special rows + ├── RepositoryContainerRow + │ ├── RepositoryHeaderRow + │ └── WorktreeRows + └── FailedRepositoryRow +``` + +Key property: repository containers are the only repository-level siblings in the outer stack. Expanded worktrees are children inside the container, not siblings beside it. + +This aligns UI boundaries with model boundaries: + +- repository reorder indicators target repository containers +- expand/collapse animates inside a container +- worktree reorder indicators target worktree rows inside one container +- live worktree updates do not change the outer repository list shape + +## Options Considered + +### Option A: Keep `List`, wrap worktrees inside one repository row + +Pros: + +- preserves some native sidebar styling +- repository-level `onMove` might remain mostly native + +Cons: + +- nested selectable worktree rows inside a single `List` row no longer participate naturally in `List(selection:)` +- worktree-level `onMove` becomes awkward inside a row +- native selection and keyboard behavior still need replacement +- likely keeps a hard-to-debug mix of native and custom drag logic + +This option reduces the indicator bug but does not cleanly solve the broader sidebar design. + +### Option B: Move fully to `ScrollView` + explicit rows + +Pros: + +- model and visual structure match +- repo and worktree drag/drop can be made explicit and testable +- selection, focus, and reveal behavior are owned by our code instead of `List` side effects +- easier to freeze drag-time updates intentionally +- eliminates `List` cell reuse as a class of expand/collapse animation bugs + +Cons: + +- must replace native `List(selection:)` +- must rebuild keyboard navigation, multi-selection, reorder, and accessibility affordances +- more implementation work + +This is the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom. + +### Option C: Short-term drag-time freeze only + +Pros: + +- small +- may help #222 flicker + +Cons: + +- does not fix repository insertion indicator because row boundaries remain wrong +- leaves the main structural mismatch in place + +This can be kept as a subset of Option B, but it is not enough alone. + +## Proposed Architecture + +### SidebarPresentationModel + +Introduce a pure presentation model that flattens current repository state into explicit sidebar units. + +Suggested model: + +```swift +struct SidebarPresentation: Equatable { + var items: [SidebarItem] +} + +enum SidebarItem: Equatable, Identifiable { + case listHeader(SidebarListHeaderModel) + case special(SidebarSpecialRowModel) + case repository(SidebarRepositoryContainerModel) + case failedRepository(FailedRepositoryModel) +} + +struct SidebarRepositoryContainerModel: Equatable, Identifiable { + var repositoryID: Repository.ID + var title: String + var rootURL: URL + var kind: Repository.Kind + var isExpanded: Bool + var isRemoving: Bool + var worktreeSections: WorktreeRowSections +} +``` + +Rules: + +- outer `items` contains one item per repository, not one item per row +- worktree sections remain inside the repository container +- presentation construction should be pure and unit-tested +- live terminal state should not be part of the broad presentation model unless required for layout identity + +### Selection + +Replace `List(selection:)` with explicit selection handling. + +Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions: + +- repository header click: + - plain folder: select repository + - git repository: toggle expand by default, or select repository if a future repository-detail mode needs it +- worktree row click: + - normal click: select worktree and focus terminal + - Cmd-click: toggle multi-selection + - Shift-click: optional follow-up, only if current behavior supports it through `List` +- Canvas / Shelf / Archived rows: dispatch existing actions + +Selection visuals should be explicit in `RepositoryHeaderRow` and `WorktreeRow`, not inherited from `List`. + +### Keyboard Navigation + +Preserve the existing command actions first: + +- `selectNextWorktree` +- `selectPreviousWorktree` +- `revealSelectedWorktreeInSidebar` +- numbered hotkeys + +Do not try to rebuild full Finder-like keyboard navigation in the first pass unless it is currently user-visible and relied upon. + +Required V1 behavior: + +- command shortcuts still select worktrees +- selected row is scrolled into view on reveal +- focus returns to terminal after single worktree selection +- sidebar focus does not accidentally forward text while Canvas / Shelf rules say it should not + +### Repository Reorder + +Replace `ForEach(...).onMove` with explicit repository drag/drop. + +Suggested approach: + +- make `RepositoryContainerRow` draggable with repository ID payload +- render a custom repository insertion indicator between repository containers +- compute drop destination as a repository index +- dispatch existing `.worktreeOrdering(.repositoriesMoved(offsets, destination))` or a new clearer action such as `.repositoriesReordered([Repository.ID])` + +The custom indicator should always render at repository container boundaries: + +```text +Target Repo header + Target Repo worktree +o----------- +``` + +This directly fixes the current downward-drag indicator bug. + +### Worktree Reorder + +Keep worktree reorder scoped inside one repository container. + +Suggested approach: + +- worktree rows are draggable with worktree ID payload +- pinned and unpinned sections keep separate drop zones +- main and pending rows remain non-movable +- drop destination maps to existing reducer actions: + - `.pinnedWorktreesMoved(repositoryID, offsets, destination)` + - `.unpinnedWorktreesMoved(repositoryID, offsets, destination)` + +Cross-repository worktree drag can stay out of scope. The current model does not appear to support moving worktrees between repositories. + +### Drag-Time Freeze + +Add a small sidebar drag state to suppress non-essential row churn. + +During any sidebar drag: + +- freeze hover-only row actions +- hide pull request / notification popover affordances that resize rows +- suppress row-ID animations caused by notification-driven reorder +- defer "move notified worktree to top" until drag ends, or apply it without animation after drop + +This addresses #222 without requiring every live data read to stop. + +### Expand / Collapse + +Move expand/collapse animation into `RepositoryContainerRow`. + +Rules: + +- outer repository container identity must not change when worktrees appear/disappear +- single repo expand/collapse animates child rows inside the container +- bulk expand/collapse updates many containers, but the outer stack still has stable repository items +- avoid animating row identity and live status changes in the same transaction + +### Reveal In Sidebar + +`ScrollViewReader.scrollTo` can still work, but scroll IDs must be explicit: + +- repository container: `SidebarScrollID.repository(repositoryID)` +- worktree row: `SidebarScrollID.worktree(worktreeID)` +- special rows: `SidebarScrollID.canvas`, etc. + +When revealing a collapsed worktree: + +1. expand its repository +2. yield for layout materialization +3. scroll to `SidebarScrollID.worktree(worktreeID)` +4. consume pending reveal + +This matches the current two-yield approach but removes dependency on `List` row materialization. + +### Accessibility + +Minimum accessibility requirements: + +- repository headers expose button/row labels and expanded state +- worktree rows expose selection state +- drag handles or rows expose reorder affordance where AppKit/SwiftUI can support it +- Canvas / Shelf / Archived rows keep meaningful labels + +If full native `List` accessibility cannot be matched in V1, document the gap and keep keyboard command coverage strong. + +## Implementation Plan + +### Phase 0: Baseline and Guardrails + +- Add a short manual repro checklist for: + - repository drag up/down over expanded target + - bulk expand/collapse with many repositories + - worktree reorder in pinned/unpinned groups + - sidebar multi-selection + - reveal-in-sidebar +- Add signposts around sidebar presentation build and drag state transitions if trace work is needed. +- Keep current `List` code untouched until presentation tests exist. + +### Phase 1: Pure Presentation and Reorder Mapping + +Files likely involved: + +- `supacode/Features/Repositories/Models/SidebarPresentation.swift` (new) +- `supacodeTests/SidebarPresentationTests.swift` (new) +- existing reducer ordering tests + +Deliver: + +- pure sidebar presentation builder +- stable scroll IDs +- pure drop-destination mapping for repository and worktree reorder +- tests for: + - expanded repository keeps one outer item with child rows + - failed repositories preserve order + - plain folders produce repository containers with no worktree children + - pinned/main/pending/unpinned sections are preserved + - repository drop destinations map to expected order + - worktree drop destinations map within pinned/unpinned sections + +### Phase 2: New Container Views Behind a Switch + +Files likely involved: + +- `SidebarListView.swift` +- `RepositorySectionView.swift` +- `WorktreeRowsView.swift` +- new `SidebarContainerListView.swift` +- new `RepositoryContainerRow.swift` + +Deliver: + +- render the new container sidebar behind a local compile-time or private runtime switch +- no reducer changes except new presentation helpers if needed +- preserve row styling visually before enabling custom drag/drop + +This phase should be screenshot/manual verified before deleting the old `List` path. + +### Phase 3: Explicit Selection and Reveal + +Deliver: + +- click handling for repository and worktree rows +- explicit selection visuals +- multi-selection behavior matching current sidebar expectations +- reveal-in-sidebar via new scroll IDs +- focused terminal handoff after single worktree selection + +Tests: + +- pure selection helper tests if logic is factored out +- existing reducer selection tests should keep passing + +### Phase 4: Custom Repository Reorder + +Deliver: + +- repository drag payload +- custom repo-level insertion indicator +- drop handling that dispatches repository reorder +- drag-time UI freeze for non-essential row affordances + +Manual verification: + +- dragging a repository upward shows indicator below the target repository container when appropriate +- dragging a repository downward never shows the indicator between target header and target worktree rows +- failed repository rows either reorder correctly or are explicitly non-reorderable + +### Phase 5: Custom Worktree Reorder + +Deliver: + +- pinned/unpinned scoped worktree drop zones +- custom worktree insertion indicator +- main/pending rows stay non-movable +- existing persistence paths remain unchanged + +Manual verification: + +- pinned worktree reorder persists +- unpinned worktree reorder persists +- dragging over main/pending rows does not create invalid moves + +### Phase 6: Remove Old `List` Path and Polish + +Deliver: + +- delete old `List(selection:)` implementation +- remove obsolete `RepositorySectionView` / `WorktreeRowsView` pieces or fold them into new components +- final accessibility pass +- final animation pass for bulk expand/collapse +- update issue #249 with final implementation notes + +## Verification Matrix + +Automated: + +- `SidebarPresentationTests` +- existing `RepositoriesFeatureTests` ordering tests +- existing `RepositorySectionViewTests` migrated or renamed +- `make check` +- `make build-app` + +Manual: + +1. Select a plain folder repository row. +2. Select a git repository worktree row and confirm terminal focus. +3. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows. +4. Expand/collapse one repository. +5. Bulk expand/collapse at least 10 repositories. +6. Drag repository upward and downward across expanded repositories. +7. Drag pinned worktrees within a repository. +8. Drag unpinned worktrees within a repository. +9. Trigger reveal-in-sidebar from Canvas or command. +10. Verify Canvas / Shelf / Archived rows remain selectable. +11. Verify notification/task/run-script indicators update without moving rows during a drag. + +## Risks + +### Native `List` behavior loss + +Risk: custom scroll rows may lose some free AppKit sidebar behavior. + +Mitigation: + +- preserve command-based navigation first +- add explicit accessibility labels/traits +- keep manual keyboard/accessibility checklist + +### Reorder implementation complexity + +Risk: custom drag/drop can become more complex than native `.onMove`. + +Mitigation: + +- keep pure drop-index mapping tested +- keep repository reorder and worktree reorder separate +- defer cross-repository worktree moves + +### UI regressions from broad rewrite + +Risk: replacing the sidebar in one PR touches selection, animation, and drag. + +Mitigation: + +- stage behind a private switch until visual behavior is verified +- land presentation model tests first +- keep reducer actions and persistence shape stable + +### Performance regressions + +Risk: replacing lazy `List` with `VStack` could render too much. + +Mitigation: + +- start with `LazyVStack` +- switch only repository containers to non-lazy child stacks if expand/collapse animation needs it +- measure with the existing signpost / Instruments workflow if the sidebar feels worse + +## Recommendation + +Proceed with Option B as the #249 plan: a custom `ScrollView` sidebar with repository containers as outer items. + +Do not attempt to fix the repository insertion indicator through reducer index changes. The indicator is a symptom of the current `List` row structure, not the persisted ordering logic. + +The safest execution path is: + +1. pure presentation model and tests +2. render-only new sidebar path +3. explicit selection/reveal +4. custom repository reorder +5. custom worktree reorder +6. remove old `List` path + +This is larger than a tactical #222 fix, but it addresses the underlying sidebar design mismatch and gives future sidebar features a cleaner foundation. -- 2.51.2 From 4ac50a7b390cec6cd98403a035a56ca604fb5bdf Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 20:33:58 +0900 Subject: [PATCH 026/528] Revise sidebar refactor plan from review --- ...6-05-03-sidebar-container-refactor-plan.md | 241 +++++++++++++----- 1 file changed, 175 insertions(+), 66 deletions(-) diff --git a/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md b/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md index 9a71ed25..b3d35e29 100644 --- a/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md +++ b/doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md @@ -12,7 +12,7 @@ This should fix the structural mismatch where the app treats repositories as reo - incorrect repository drag insertion indicators when dragging downward across expanded repositories - unstable bulk expand/collapse animations -- potential sidebar flicker during drag when live terminal / notification / ordering updates arrive +- potential sidebar flicker during drag when live terminal, notification, or ordering updates arrive ## Current Findings @@ -56,7 +56,7 @@ SwiftUI is placing the indicator between list rows. It does not know that the ta The current `List` carries several behaviors at once: -- special rows: Canvas, Shelf, archived worktrees, repository list header +- archived worktree selection and repository list header - repository row selection for plain folders - worktree multi-selection - repository expand/collapse @@ -65,7 +65,7 @@ The current `List` carries several behaviors at once: - reveal-in-sidebar via `ScrollViewReader.scrollTo` - native sidebar styling and accessibility -This makes local fixes brittle because changing row structure affects selection, drag, animation, and scroll identity at the same time. +Canvas, Shelf, and the footer are not `List` rows today; they are safe-area inset chrome around the list. The refactor should preserve that boundary unless a later design intentionally moves them into the scroll content. ### 3. Live state still reaches rows during drag @@ -80,21 +80,29 @@ Remaining drag-time churn sources include: These are not necessarily the root cause of the drop-indicator bug, but they are credible contributors to #222-style flicker. -## Recommended Direction +## Revised Direction Use a custom sidebar scroll container rather than trying to keep the current flat `List` structure. +Execution order matters: first stabilize the old `List` path with a reducer-level drag gate, then replace the visual structure. The drag gate is a hard prerequisite because it reduces #222 risk before the broader #249 rewrite starts. + Recommended shape: ```text -ScrollViewReader -└── ScrollView - └── LazyVStack or VStack - ├── Special rows - ├── RepositoryContainerRow - │ ├── RepositoryHeaderRow - │ └── WorktreeRows - └── FailedRepositoryRow +SidebarView chrome +├── top safeAreaInset buttons +│ ├── Canvas +│ └── Shelf +├── ScrollViewReader +│ └── ScrollView +│ └── LazyVStack or VStack +│ ├── repository list header +│ ├── RepositoryContainerRow +│ │ ├── RepositoryHeaderRow +│ │ └── WorktreeRows +│ ├── FailedRepositoryRow +│ └── ArchivedWorktreesRow +└── bottom safeAreaInset footer ``` Key property: repository containers are the only repository-level siblings in the outer stack. Expanded worktrees are children inside the container, not siblings beside it. @@ -140,7 +148,7 @@ Cons: - must rebuild keyboard navigation, multi-selection, reorder, and accessibility affordances - more implementation work -This is the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom. +This remains the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom. ### Option C: Short-term drag-time freeze only @@ -154,11 +162,47 @@ Cons: - does not fix repository insertion indicator because row boundaries remain wrong - leaves the main structural mismatch in place -This can be kept as a subset of Option B, but it is not enough alone. +This is now the mandatory M1 prerequisite for Option B, not a replacement for Option B. + +## Hard Requirements + +The refactor must preserve these behavior and state contracts. + +### Reducer actions and persistence + +- Keep the existing reducer actions and persistence paths for repository and worktree ordering unless a later implementation note explicitly proves a rename is worth it. +- Preserve calls behind repository reorder, pinned worktree reorder, unpinned worktree reorder, and notification-driven reorder. +- Treat failed repository reorder semantics as an explicit product decision: + - either failed repository rows are reorderable and their order persists through the same root ordering path + - or they are not reorderable and the UI gives consistent feedback with no insertion target around them + +### Expanded and collapsed state + +- Preserve `@Shared` write-back semantics for collapsed repository IDs. +- Preserve cleanup of invalid collapsed IDs when repository IDs change. +- Ensure bulk expand/collapse and single expand/collapse share the same model path. + +### Focused actions and selection synchronization + +- Preserve `SidebarView` focused values for `confirmWorktreeAction`, `archiveWorktreeAction`, `deleteWorktreeAction`, and `visibleHotkeyWorktreeRows`. +- Preserve the `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization currently owned by `SidebarView`. +- Do not regress menu commands or numbered worktree hotkeys when replacing `List(selection:)`. + +### Existing row affordances + +- Preserve repository and worktree context menus. +- Preserve drag previews. +- Preserve current worktree row type-select behavior. Worktree rows currently use `.typeSelectEquivalent("")`; V1 should keep type-select effectively disabled for those rows. +- Preserve root-level `dropDestination(for: URL.self)` on the sidebar container, including drops into blank sidebar space. + +### Ordered roots + +- Converge the current `orderedRoots.isEmpty` fallback and non-empty custom-order path into one presentation path. +- The empty ordered-roots case is a valid user state and must have tests. ## Proposed Architecture -### SidebarPresentationModel +### SidebarPresentation Introduce a pure presentation model that flattens current repository state into explicit sidebar units. @@ -171,9 +215,9 @@ struct SidebarPresentation: Equatable { enum SidebarItem: Equatable, Identifiable { case listHeader(SidebarListHeaderModel) - case special(SidebarSpecialRowModel) case repository(SidebarRepositoryContainerModel) case failedRepository(FailedRepositoryModel) + case archivedWorktrees(ArchivedWorktreesRowModel) } struct SidebarRepositoryContainerModel: Equatable, Identifiable { @@ -189,25 +233,31 @@ struct SidebarRepositoryContainerModel: Equatable, Identifiable { Rules: +- build `SidebarPresentation` from reducer/state-side pure functions or equivalent helpers - outer `items` contains one item per repository, not one item per row - worktree sections remain inside the repository container -- presentation construction should be pure and unit-tested -- live terminal state should not be part of the broad presentation model unless required for layout identity +- presentation construction is pure and unit-tested +- high-frequency terminal notification/task/run-script state stays in leaf views, not in broad presentation state +- Canvas, Shelf, and footer chrome remain outside `SidebarPresentation` in V1 ### Selection Replace `List(selection:)` with explicit selection handling. -Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions: +Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions. + +Compatibility matrix: -- repository header click: - - plain folder: select repository - - git repository: toggle expand by default, or select repository if a future repository-detail mode needs it -- worktree row click: - - normal click: select worktree and focus terminal - - Cmd-click: toggle multi-selection - - Shift-click: optional follow-up, only if current behavior supports it through `List` -- Canvas / Shelf / Archived rows: dispatch existing actions +| Interaction | State behavior | Focus behavior | +| --- | --- | --- | +| Canvas button | Selects Canvas and clears incompatible sidebar worktree selection. | Does not focus a terminal. | +| Shelf button | Selects Shelf and clears incompatible sidebar worktree selection. | Does not focus a terminal. | +| Archived worktrees row | Selects archived worktrees and clears incompatible worktree selection. | Does not focus a terminal. | +| Git repository header click | Toggles expanded state by default. | Does not focus a terminal. | +| Plain folder repository click | Selects the repository. | Does not focus a terminal unless current behavior already does. | +| Worktree row normal click | Selects one worktree and updates sidebar selected worktree IDs to that one ID. | Focuses the terminal for the selected worktree. | +| Worktree row Cmd-click | Toggles membership in sidebar selected worktree IDs, preserving multi-select priority. | Does not steal focus unless the resulting primary selection changes by existing rules. | +| Empty sidebar selection | Clears sidebar selected worktree IDs. | Does not focus a terminal. | Selection visuals should be explicit in `RepositoryHeaderRow` and `WorktreeRow`, not inherited from `List`. @@ -227,7 +277,7 @@ Required V1 behavior: - command shortcuts still select worktrees - selected row is scrolled into view on reveal - focus returns to terminal after single worktree selection -- sidebar focus does not accidentally forward text while Canvas / Shelf rules say it should not +- sidebar focus does not accidentally forward text while Canvas, Shelf, or Archived rules say it should not ### Repository Reorder @@ -238,7 +288,7 @@ Suggested approach: - make `RepositoryContainerRow` draggable with repository ID payload - render a custom repository insertion indicator between repository containers - compute drop destination as a repository index -- dispatch existing `.worktreeOrdering(.repositoriesMoved(offsets, destination))` or a new clearer action such as `.repositoriesReordered([Repository.ID])` +- dispatch existing repository-ordering actions or a new reducer action that delegates to the same persistence path The custom indicator should always render at repository container boundaries: @@ -267,7 +317,7 @@ Cross-repository worktree drag can stay out of scope. The current model does not ### Drag-Time Freeze -Add a small sidebar drag state to suppress non-essential row churn. +Add sidebar drag state at reducer level and use it in both the old and new sidebar paths. During any sidebar drag: @@ -276,6 +326,13 @@ During any sidebar drag: - suppress row-ID animations caused by notification-driven reorder - defer "move notified worktree to top" until drag ends, or apply it without animation after drop +Reducer behavior: + +- drag begin records that sidebar drag is active +- `worktreeNotificationReceived` while drag is active records pending reorder IDs instead of mutating row order immediately +- drag end flushes pending notification reorders in deterministic order, dropping stale worktree IDs +- `moveNotifiedWorktreeToTop == false` remains a no-op + This addresses #222 without requiring every live data read to stop. ### Expand / Collapse @@ -295,16 +352,16 @@ Rules: - repository container: `SidebarScrollID.repository(repositoryID)` - worktree row: `SidebarScrollID.worktree(worktreeID)` -- special rows: `SidebarScrollID.canvas`, etc. +- archived worktrees row: `SidebarScrollID.archivedWorktrees` When revealing a collapsed worktree: 1. expand its repository -2. yield for layout materialization +2. wait for an event-driven row availability signal 3. scroll to `SidebarScrollID.worktree(worktreeID)` 4. consume pending reveal -This matches the current two-yield approach but removes dependency on `List` row materialization. +Do not rely on a fixed number of `Task.yield()` calls in the new architecture. The implementation can use a scroll target registry, preference key, or equivalent view materialization signal. ### Accessibility @@ -328,7 +385,37 @@ If full native `List` accessibility cannot be matched in V1, document the gap an - sidebar multi-selection - reveal-in-sidebar - Add signposts around sidebar presentation build and drag state transitions if trace work is needed. -- Keep current `List` code untouched until presentation tests exist. +- Establish `LazyVStack` vs `VStack` decision metrics before replacing the list: + - expand/collapse latency for 10+ repositories + - frame stability during repository drag + - CPU peak during drag and bulk expand/collapse + - body recomputation count for repository container and worktree row views +- Keep current `List` code untouched until M1 and presentation tests exist. + +### M1: Stabilize Old `List` Drag Behavior + +Files likely involved: + +- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` +- `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift` +- `supacode/Features/Repositories/Views/SidebarListView.swift` +- `supacodeTests/RepositoriesFeatureTests.swift` + +Deliver: + +- reducer-level sidebar drag state +- view action for drag begin/end from the old `List` path +- delayed or no-animation handling for notification-driven reorder during drag +- deterministic pending reorder flush on drag end + +Tests: + +- notification during sidebar drag does not mutate visible worktree order immediately +- drag end applies the pending notification reorder in deterministic order +- multiple notifications during one drag produce stable ordering +- stale pending worktree IDs are ignored +- `moveNotifiedWorktreeToTop == false` remains a no-op +- persistence is called only when the reorder is actually applied ### Phase 1: Pure Presentation and Reorder Mapping @@ -343,13 +430,18 @@ Deliver: - pure sidebar presentation builder - stable scroll IDs - pure drop-destination mapping for repository and worktree reorder -- tests for: - - expanded repository keeps one outer item with child rows - - failed repositories preserve order - - plain folders produce repository containers with no worktree children - - pinned/main/pending/unpinned sections are preserved - - repository drop destinations map to expected order - - worktree drop destinations map within pinned/unpinned sections +- one unified presentation path for empty and non-empty ordered roots +- explicit failed repository row reorder semantics + +Tests: + +- expanded repository keeps one outer item with child rows +- failed repositories preserve the chosen reorder semantics +- plain folders produce repository containers with no worktree children +- pinned/main/pending/unpinned sections are preserved +- empty ordered roots and custom ordered roots produce equivalent presentation rules +- repository drop destinations map to expected order +- worktree drop destinations map within pinned/unpinned sections ### Phase 2: New Container Views Behind a Switch @@ -366,23 +458,28 @@ Deliver: - render the new container sidebar behind a local compile-time or private runtime switch - no reducer changes except new presentation helpers if needed - preserve row styling visually before enabling custom drag/drop +- preserve root-level URL drop for files dragged into blank sidebar space +- preserve context menus and drag previews This phase should be screenshot/manual verified before deleting the old `List` path. -### Phase 3: Explicit Selection and Reveal +### Phase 3: Explicit Selection, Focus, and Reveal Deliver: - click handling for repository and worktree rows - explicit selection visuals -- multi-selection behavior matching current sidebar expectations -- reveal-in-sidebar via new scroll IDs +- multi-selection behavior matching the compatibility matrix +- `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization +- focused actions and hotkey row values +- reveal-in-sidebar via new scroll IDs and row availability events - focused terminal handoff after single worktree selection Tests: -- pure selection helper tests if logic is factored out -- existing reducer selection tests should keep passing +- pure selection helper tests +- reducer tests for sidebar selected worktree synchronization +- focused action manual checklist for confirm/archive/delete and numbered hotkeys ### Phase 4: Custom Repository Reorder @@ -390,14 +487,14 @@ Deliver: - repository drag payload - custom repo-level insertion indicator -- drop handling that dispatches repository reorder +- drop handling that dispatches repository reorder through the existing persistence path - drag-time UI freeze for non-essential row affordances Manual verification: - dragging a repository upward shows indicator below the target repository container when appropriate - dragging a repository downward never shows the indicator between target header and target worktree rows -- failed repository rows either reorder correctly or are explicitly non-reorderable +- failed repository rows follow the documented reorder semantics ### Phase 5: Custom Worktree Reorder @@ -429,6 +526,9 @@ Deliver: Automated: - `SidebarPresentationTests` +- reducer tests for sidebar drag gate and notification reorder concurrency +- reducer tests for expanded/collapsed state write-back and invalid collapsed ID cleanup +- reducer tests for sidebar selected worktree synchronization - existing `RepositoriesFeatureTests` ordering tests - existing `RepositorySectionViewTests` migrated or renamed - `make check` @@ -437,16 +537,23 @@ Automated: Manual: 1. Select a plain folder repository row. -2. Select a git repository worktree row and confirm terminal focus. -3. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows. -4. Expand/collapse one repository. -5. Bulk expand/collapse at least 10 repositories. -6. Drag repository upward and downward across expanded repositories. -7. Drag pinned worktrees within a repository. -8. Drag unpinned worktrees within a repository. -9. Trigger reveal-in-sidebar from Canvas or command. -10. Verify Canvas / Shelf / Archived rows remain selectable. -11. Verify notification/task/run-script indicators update without moving rows during a drag. +2. Click a git repository header and confirm it expands/collapses without selecting a worktree. +3. Select a git repository worktree row and confirm terminal focus. +4. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows. +5. Verify confirm/archive/delete menu commands target the same worktrees as before. +6. Verify numbered worktree hotkeys use visible sidebar rows. +7. Expand/collapse one repository. +8. Bulk expand/collapse at least 10 repositories. +9. Drag repository upward and downward across expanded repositories. +10. Drag pinned worktrees within a repository. +11. Drag unpinned worktrees within a repository. +12. Trigger reveal-in-sidebar from Canvas or command. +13. Verify Canvas / Shelf / Archived interactions remain correct. +14. Verify notification/task/run-script indicators update without moving rows during a drag. +15. Drop a repository URL onto a visible row and onto blank sidebar space. +16. Verify repository and worktree context menus. +17. Verify drag previews. +18. Verify worktree rows do not gain type-select behavior in V1. ## Risks @@ -477,7 +584,7 @@ Risk: replacing the sidebar in one PR touches selection, animation, and drag. Mitigation: - stage behind a private switch until visual behavior is verified -- land presentation model tests first +- land M1 and presentation model tests first - keep reducer actions and persistence shape stable ### Performance regressions @@ -488,7 +595,7 @@ Mitigation: - start with `LazyVStack` - switch only repository containers to non-lazy child stacks if expand/collapse animation needs it -- measure with the existing signpost / Instruments workflow if the sidebar feels worse +- decide using the Phase 0 metrics rather than visual impression alone ## Recommendation @@ -498,11 +605,13 @@ Do not attempt to fix the repository insertion indicator through reducer index c The safest execution path is: -1. pure presentation model and tests -2. render-only new sidebar path -3. explicit selection/reveal -4. custom repository reorder -5. custom worktree reorder -6. remove old `List` path +1. baseline metrics and manual guardrails +2. M1 old `List` drag gate and reducer concurrency tests +3. pure presentation model and tests +4. render-only new sidebar path +5. explicit selection, focus, and reveal +6. custom repository reorder +7. custom worktree reorder +8. remove old `List` path This is larger than a tactical #222 fix, but it addresses the underlying sidebar design mismatch and gives future sidebar features a cleaner foundation. -- 2.51.2 From 2fbf85352b01aeb2eaa13b8b16ad721815d85236 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 20:52:44 +0900 Subject: [PATCH 027/528] Refactor sidebar presentation drag handling --- .../Models/SidebarPresentation.swift | 196 +++++++++++++++++ ...RepositoriesFeature+WorktreeOrdering.swift | 99 +++++++-- .../Reducer/RepositoriesFeature.swift | 5 +- .../Repositories/Views/SidebarListView.swift | 153 +++++++------- .../Repositories/Views/WorktreeRowsView.swift | 15 +- supacodeTests/RepositoriesFeatureTests.swift | 109 ++++++++++ supacodeTests/SidebarPresentationTests.swift | 197 ++++++++++++++++++ 7 files changed, 672 insertions(+), 102 deletions(-) create mode 100644 supacode/Features/Repositories/Models/SidebarPresentation.swift create mode 100644 supacodeTests/SidebarPresentationTests.swift diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift new file mode 100644 index 00000000..8fbc6053 --- /dev/null +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -0,0 +1,196 @@ +import Foundation + +struct SidebarPresentation: Equatable { + var items: [SidebarItem] + + static func showsListHeader(repositoryCount: Int) -> Bool { + repositoryCount > 10 + } + + var repositoryOrderIDs: [Repository.ID] { + items.compactMap(\.repositoryOrderID) + } + + func repositoryOrderAfterMove( + fromOffsets source: IndexSet, + toOffset destination: Int + ) -> [Repository.ID] { + var orderedIDs = repositoryOrderIDs + orderedIDs.moveElements(fromOffsets: source, toOffset: destination) + return orderedIDs + } +} + +enum SidebarItem: Equatable, Identifiable { + case listHeader(SidebarListHeaderModel) + case repository(SidebarRepositoryContainerModel) + case failedRepository(FailedRepositoryModel) + case archivedWorktrees(ArchivedWorktreesRowModel) + + var id: SidebarPresentationItemID { + switch self { + case .listHeader: + return .listHeader + case .repository(let model): + return .repository(model.id) + case .failedRepository(let model): + return .failedRepository(model.id) + case .archivedWorktrees: + return .archivedWorktrees + } + } + + var repositoryOrderID: Repository.ID? { + switch self { + case .repository(let model): + return model.id + case .failedRepository(let model) where model.isReorderable: + return model.id + case .listHeader, .failedRepository, .archivedWorktrees: + return nil + } + } +} + +enum SidebarPresentationItemID: Equatable, Hashable { + case listHeader + case repository(Repository.ID) + case failedRepository(Repository.ID) + case archivedWorktrees +} + +struct SidebarListHeaderModel: Equatable, Identifiable { + let id = SidebarPresentationItemID.listHeader + var repositoryCount: Int +} + +struct SidebarRepositoryContainerModel: Equatable, Identifiable { + var id: Repository.ID { repositoryID } + + var repositoryID: Repository.ID + var title: String + var rootURL: URL + var kind: Repository.Kind + var isExpanded: Bool + var isRemoving: Bool + var worktreeSections: WorktreeRowSections +} + +struct FailedRepositoryModel: Equatable, Identifiable { + var id: Repository.ID + var name: String + var path: String + var failureMessage: String + var isReorderable: Bool +} + +struct ArchivedWorktreesRowModel: Equatable, Identifiable { + let id = SidebarPresentationItemID.archivedWorktrees + var count: Int +} + +enum SidebarWorktreeSection: Equatable { + case pinned + case unpinned +} + +struct SidebarWorktreeDropTarget: Equatable { + var repositoryID: Repository.ID + var section: SidebarWorktreeSection + var source: IndexSet + var destination: Int + + var action: RepositoriesFeature.WorktreeOrderingAction { + switch section { + case .pinned: + return .pinnedWorktreesMoved(repositoryID: repositoryID, source, destination) + case .unpinned: + return .unpinnedWorktreesMoved(repositoryID: repositoryID, source, destination) + } + } +} + +extension RepositoriesFeature.State { + func sidebarPresentation( + expandedRepositoryIDs: Set, + includesArchivedWorktreesRow: Bool = false + ) -> SidebarPresentation { + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + let roots = sidebarPresentationRoots() + let repositoryCount = roots.count + var items: [SidebarItem] = [] + + if SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) { + items.append(.listHeader(SidebarListHeaderModel(repositoryCount: repositoryCount))) + } + + for rootURL in roots { + let standardizedRootURL = rootURL.standardizedFileURL + let repositoryID = standardizedRootURL.path(percentEncoded: false) + if let failureMessage = loadFailuresByID[repositoryID] { + let path = standardizedRootURL.path(percentEncoded: false) + items.append( + .failedRepository( + FailedRepositoryModel( + id: repositoryID, + name: Repository.name(for: standardizedRootURL), + path: path, + failureMessage: failureMessage, + isReorderable: true + ) + ) + ) + } else if let repository = repositoriesByID[repositoryID] { + let isExpanded = expandedRepositoryIDs.contains(repository.id) + items.append( + .repository( + SidebarRepositoryContainerModel( + repositoryID: repository.id, + title: repository.name, + rootURL: repository.rootURL, + kind: repository.kind, + isExpanded: isExpanded, + isRemoving: isRemovingRepository(repository), + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty + ) + ) + ) + } + } + + if includesArchivedWorktreesRow, !archivedWorktrees.isEmpty { + items.append(.archivedWorktrees(ArchivedWorktreesRowModel(count: archivedWorktrees.count))) + } + + return SidebarPresentation(items: items) + } + + private func sidebarPresentationRoots() -> [URL] { + let orderedRoots = orderedRepositoryRoots() + if !orderedRoots.isEmpty { + return orderedRoots + } + return repositories.map(\.rootURL) + } +} + +extension WorktreeRowSections { + static let empty = WorktreeRowSections( + main: nil, + pinned: [], + pending: [], + unpinned: [] + ) +} + +extension Array { + fileprivate mutating func moveElements(fromOffsets source: IndexSet, toOffset destination: Int) { + let sourceIndexes = source.sorted() + let movedElements = sourceIndexes.map { self[$0] } + for index in sourceIndexes.reversed() { + remove(at: index) + } + let removedBeforeDestination = sourceIndexes.filter { $0 < destination }.count + insert(contentsOf: movedElements, at: destination - removedBeforeDestination) + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift index 587fcaf6..0bd227d0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift @@ -142,32 +142,46 @@ extension RepositoriesFeature { return .merge(effects) case .worktreeNotificationReceived(let worktreeID): - guard let repositoryID = state.repositoryID(containing: worktreeID), - let repository = state.repositories[id: repositoryID], - let worktree = repository.worktrees[id: worktreeID] - else { + guard notificationReorderTarget(for: worktreeID, state: state) != nil else { return .none } - if state.isWorktreeArchived(worktree.id) { + if state.isSidebarDragActive { + state.pendingSidebarNotifyReorderIDs.removeAll { $0 == worktreeID } + state.pendingSidebarNotifyReorderIDs.append(worktreeID) return .none } - if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { - let reordered = reorderedUnpinnedWorktreeIDs( - for: worktreeID, - in: repository, - state: state - ) - if state.worktreeOrderByRepository[repositoryID] != reordered { - withAnimation(.snappy(duration: 0.2)) { - state.worktreeOrderByRepository[repositoryID] = reordered - } - let worktreeOrderByRepository = state.worktreeOrderByRepository - return .run { _ in - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) - } - } + guard applyNotificationReorder(for: worktreeID, state: &state, animated: true) else { + return .none + } + let worktreeOrderByRepository = state.worktreeOrderByRepository + return .run { _ in + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) + } + + case .setSidebarDragActive(let isActive): + guard state.isSidebarDragActive != isActive else { + return .none + } + state.isSidebarDragActive = isActive + guard !isActive else { + return .none + } + let pendingWorktreeIDs = state.pendingSidebarNotifyReorderIDs + state.pendingSidebarNotifyReorderIDs = [] + guard !pendingWorktreeIDs.isEmpty else { + return .none + } + var didReorder = false + for worktreeID in pendingWorktreeIDs { + didReorder = applyNotificationReorder(for: worktreeID, state: &state, animated: false) || didReorder + } + guard didReorder else { + return .none + } + let worktreeOrderByRepository = state.worktreeOrderByRepository + return .run { _ in + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) } - return .none case .setMoveNotifiedWorktreeToTop(let isEnabled): state.moveNotifiedWorktreeToTop = isEnabled @@ -184,3 +198,46 @@ extension RepositoriesFeature { } } } + +private func notificationReorderTarget( + for worktreeID: Worktree.ID, + state: RepositoriesFeature.State +) -> (repositoryID: Repository.ID, repository: Repository)? { + guard state.moveNotifiedWorktreeToTop, + let repositoryID = state.repositoryID(containing: worktreeID), + let repository = state.repositories[id: repositoryID], + let worktree = repository.worktrees[id: worktreeID], + !state.isWorktreeArchived(worktree.id), + !state.isMainWorktree(worktree), + !state.isWorktreePinned(worktree) + else { + return nil + } + return (repositoryID, repository) +} + +private func applyNotificationReorder( + for worktreeID: Worktree.ID, + state: inout RepositoriesFeature.State, + animated: Bool +) -> Bool { + guard let target = notificationReorderTarget(for: worktreeID, state: state) else { + return false + } + let reordered = reorderedUnpinnedWorktreeIDs( + for: worktreeID, + in: target.repository, + state: state + ) + guard state.worktreeOrderByRepository[target.repositoryID] != reordered else { + return false + } + if animated { + withAnimation(.snappy(duration: 0.2)) { + state.worktreeOrderByRepository[target.repositoryID] = reordered + } + } else { + state.worktreeOrderByRepository[target.repositoryID] = reordered + } + return true +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a3d85ed7..18be5ba1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -146,6 +146,7 @@ struct RepositoriesFeature { case pinWorktree(Worktree.ID) case unpinWorktree(Worktree.ID) case worktreeNotificationReceived(Worktree.ID) + case setSidebarDragActive(Bool) case setMoveNotifiedWorktreeToTop(Bool) } @@ -235,6 +236,8 @@ struct RepositoriesFeature { var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? + var isSidebarDragActive = false + var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var alert: AlertState? @@ -1977,7 +1980,7 @@ extension RepositoriesFeature.State { } } -struct WorktreeRowSections { +struct WorktreeRowSections: Equatable { let main: WorktreeRowModel? let pinned: [WorktreeRowModel] let pending: [WorktreeRowModel] diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 5d6300da..4e6d8078 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -39,14 +39,19 @@ struct SidebarListView: View { var body: some View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) - let orderedRoots = state.orderedRepositoryRoots() + let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) let repositoryListHeaderAction = Self.repositoryListHeaderAction( expandedRepoIDs: expandedRepoIDs, expandableRepositoryIDs: expandableRepositoryIDs ) - let visibleRepositoryCount = orderedRoots.isEmpty ? state.repositories.count : orderedRoots.count - let showsRepositoryListHeader = Self.showsRepositoryListHeader(repositoryCount: visibleRepositoryCount) + let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) + let showsRepositoryListHeader = presentation.items.contains { item in + if case .listHeader = item { + return true + } + return false + } let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) let selection = Binding>( get: { @@ -131,7 +136,6 @@ struct SidebarListView: View { } } ) - let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) let pendingSidebarReveal = state.pendingSidebarReveal ScrollViewReader { scrollProxy in @@ -144,75 +148,17 @@ struct SidebarListView: View { .listRowInsets(EdgeInsets()) } - if orderedRoots.isEmpty { - let repositories = store.repositories - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in - RepositorySectionView( - repository: repository, - hasTopSpacing: index > 0, - isDragActive: isDragActive, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - expandedRepoIDs: $expandedRepoIDs, - store: store, - terminalManager: terminalManager - ) - .listRowInsets(EdgeInsets()) - } - } else { - let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in - ( - index: index, - rootURL: rootURL, - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) - ) - } - ForEach(orderedRows, id: \.repositoryID) { row in - let index = row.index - let rootURL = row.rootURL - let repositoryID = row.repositoryID - if let failureMessage = state.loadFailuresByID[repositoryID] { - let name = Repository.name(for: rootURL.standardizedFileURL) - let path = rootURL.standardizedFileURL.path(percentEncoded: false) - FailedRepositoryRow( - name: name, - path: path, - showFailure: { - let message = "\(path)\n\n\(failureMessage)" - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) - }, - removeRepository: { - store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) - } - ) - .padding(.horizontal, 12) - .overlay(alignment: .top) { - if index > 0 { - Rectangle() - .fill(.secondary) - .frame(height: 1) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } - } - .listRowInsets(EdgeInsets()) - } else if let repository = repositoriesByID[repositoryID] { - RepositorySectionView( - repository: repository, - hasTopSpacing: index > 0, - isDragActive: isDragActive, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - expandedRepoIDs: $expandedRepoIDs, - store: store, - terminalManager: terminalManager - ) - .listRowInsets(EdgeInsets()) - } - } - .onMove { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) - } + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + .listRowInsets(EdgeInsets()) + } + .onMove { offsets, destination in + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) } } .listStyle(.sidebar) @@ -222,17 +168,20 @@ struct SidebarListView: View { if case .ended = session.phase { if isDragActive { isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) } return } if case .dataTransferCompleted = session.phase { if isDragActive { isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) } return } if !isDragActive { isDragActive = true + store.send(.worktreeOrdering(.setSidebarDragActive(true))) } } .safeAreaInset(edge: .top) { @@ -318,6 +267,56 @@ struct SidebarListView: View { .padding(.bottom, 4) } + @ViewBuilder + private func repositoryItemView( + _ item: SidebarItem, + index: Int, + hotkeyRows: [WorktreeRowModel], + selectedWorktreeIDs: Set + ) -> some View { + switch item { + case .repository(let model): + if let repository = store.state.repositories[id: model.repositoryID] { + RepositorySectionView( + repository: repository, + hasTopSpacing: index > 0, + isDragActive: isDragActive, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs, + expandedRepoIDs: $expandedRepoIDs, + store: store, + terminalManager: terminalManager + ) + } + + case .failedRepository(let model): + FailedRepositoryRow( + name: model.name, + path: model.path, + showFailure: { + let message = "\(model.path)\n\n\(model.failureMessage)" + store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) + }, + removeRepository: { + store.send(.repositoryManagement(.removeFailedRepository(model.id))) + } + ) + .padding(.horizontal, 12) + .overlay(alignment: .top) { + if index > 0 { + Rectangle() + .fill(.secondary) + .frame(height: 1) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + } + + case .listHeader, .archivedWorktrees: + EmptyView() + } + } + @MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, @@ -354,7 +353,13 @@ struct SidebarListView: View { } static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { - repositoryCount > 10 + SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) + } +} + +extension SidebarItem { + fileprivate var isRepositoryOrderItem: Bool { + repositoryOrderID != nil } } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index c33b6628..cb39234b 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -25,6 +25,7 @@ struct WorktreeRowsView: View { let state = store.state let sections = state.worktreeRowSections(in: repository) let isRepositoryRemoving = state.isRemovingRepository(repository) + let isSidebarDragActive = state.isSidebarDragActive let showShortcutHints = commandKeyObserver.isPressed let allRows = showShortcutHints ? hotkeyRows : [] let shortcutIndexByID = Dictionary( @@ -37,7 +38,7 @@ struct WorktreeRowsView: View { showShortcutHints: showShortcutHints, shortcutIndexByID: shortcutIndexByID ) - .animation(.easeOut(duration: 0.2), value: rowIDs) + .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) } @ViewBuilder @@ -94,6 +95,7 @@ struct WorktreeRowsView: View { moveDisabled: Bool, shortcutHint: String? ) -> some View { + let isSidebarDragActive = store.state.isSidebarDragActive let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) let displayName = if row.isDeleting { @@ -103,7 +105,7 @@ struct WorktreeRowsView: View { } else { row.name } - let canShowRowActions = row.isRemovable && !isRepositoryRemoving + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isSidebarDragActive let pinAction: (() -> Void)? = canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } @@ -134,9 +136,9 @@ struct WorktreeRowsView: View { let config = WorktreeRowViewConfig( displayName: displayName, worktreeName: worktreeName(for: row), - isHovered: hoveredWorktreeID == row.id, - showsNotificationIndicator: showsNotificationIndicator, - notifications: notifications, + isHovered: !isSidebarDragActive && hoveredWorktreeID == row.id, + showsNotificationIndicator: !isSidebarDragActive && showsNotificationIndicator, + notifications: isSidebarDragActive ? [] : notifications, onFocusNotification: onFocusNotification, shortcutHint: shortcutHint, pinAction: pinAction, @@ -205,11 +207,12 @@ struct WorktreeRowsView: View { let isSelected = selectedWorktreeIDs.contains(row.id) let taskStatus = terminalManager.taskStatus(for: row.id) let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) + let isSidebarDragActive = store.state.isSidebarDragActive return WorktreeRow( name: config.displayName, worktreeName: config.worktreeName, info: row.info, - showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), + showsPullRequestInfo: !isSidebarDragActive && !draggingWorktreeIDs.contains(row.id), isHovered: config.isHovered, isPinned: row.isPinned, isMainWorktree: row.isMainWorktree, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3a26bb48..2545f92e 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2577,6 +2577,115 @@ struct RepositoriesFeatureTests { #expect(store.state.statusToast == nil) } + @Test func worktreeNotificationDuringSidebarDragDefersReorder() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) + var state = makeState(repositories: [repository]) + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(true))) { + $0.isSidebarDragActive = true + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureB.id] + } + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) + } + + @Test func endingSidebarDragAppliesPendingNotificationReordersInOrder() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) + let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id, featureC.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureC.id, featureA.id] + } + } + + @Test func repeatedNotificationDuringSidebarDragKeepsLatestPosition() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureA.id] + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureA.id, featureB.id] + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureB.id, featureA.id] + } + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + $0.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + } + } + + @Test func stalePendingNotificationReordersAreIgnoredWhenDragEnds() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.pendingSidebarNotifyReorderIDs = ["/tmp/repo/stale", featureA.id] + state.worktreeOrderByRepository[repoRoot] = [featureA.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + } + } + + @Test func notificationDuringSidebarDragDoesNotRecordWhenMoveToTopDisabled() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.moveNotifiedWorktreeToTop = false + state.worktreeOrderByRepository[repoRoot] = [featureA.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) + #expect(store.state.pendingSidebarNotifyReorderIDs.isEmpty) + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id]) + } + @Test func setMoveNotifiedWorktreeToTopUpdatesState() async { var state = makeState(repositories: []) state.moveNotifiedWorktreeToTop = true diff --git a/supacodeTests/SidebarPresentationTests.swift b/supacodeTests/SidebarPresentationTests.swift new file mode 100644 index 00000000..bfc6df66 --- /dev/null +++ b/supacodeTests/SidebarPresentationTests.swift @@ -0,0 +1,197 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct SidebarPresentationTests { + @Test func expandedRepositoryIsOneOuterItemWithChildRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + #expect(presentation.items.count == 1) + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.id == repository.id) + #expect(model.isExpanded) + #expect(model.worktreeSections.allRows.map(\.id) == [main.id, feature.id]) + } + + @Test func collapsedRepositoryKeepsContainerButHidesChildRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: []) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(!model.isExpanded) + #expect(model.worktreeSections.allRows.isEmpty) + } + + @Test func failedRepositoriesParticipateInRootOrder() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + var state = makeState(repositories: [repoA]) + state.repositoryRoots = [ + URL(fileURLWithPath: "/tmp/missing"), + repoA.rootURL, + ] + state.repositoryOrderIDs = ["/tmp/missing", repoA.id] + state.loadFailuresByID["/tmp/missing"] = "missing" + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id]) + + #expect(presentation.repositoryOrderIDs == ["/tmp/missing", repoA.id]) + #expect( + presentation.repositoryOrderAfterMove(fromOffsets: IndexSet(integer: 0), toOffset: 2) == [ + repoA.id, "/tmp/missing", + ]) + guard case .failedRepository(let failed) = presentation.items.first else { + Issue.record("Expected failed repository first") + return + } + #expect(failed.id == "/tmp/missing") + #expect(failed.isReorderable) + } + + @Test func plainFolderProducesContainerWithoutWorktreeChildren() { + let repository = makeRepository(id: "/tmp/plain", kind: .plain, worktrees: []) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.kind == .plain) + #expect(model.worktreeSections.allRows.isEmpty) + } + + @Test func worktreeSectionsPreservePinnedMainPendingAndUnpinnedRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned", repoRoot: repoRoot) + let unpinned = makeWorktree(id: "/tmp/repo/unpinned", name: "unpinned", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, pinned, unpinned]) + var state = makeState(repositories: [repository]) + state.pinnedWorktreeIDs = [pinned.id] + state.pendingWorktrees = [ + PendingWorktree( + id: "/tmp/repo/pending", + repositoryID: repository.id, + progress: WorktreeCreationProgress(stage: .choosingWorktreeName, worktreeName: "pending") + ) + ] + state.worktreeOrderByRepository[repository.id] = [unpinned.id] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.worktreeSections.main?.id == main.id) + #expect(model.worktreeSections.pinned.map(\.id) == [pinned.id]) + #expect(model.worktreeSections.pending.map(\.id) == ["/tmp/repo/pending"]) + #expect(model.worktreeSections.unpinned.map(\.id) == [unpinned.id]) + } + + @Test func emptyOrderedRootsStillBuildsRepositoryPresentation() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + let repoB = makeRepository(id: "/tmp/b", worktrees: []) + var state = RepositoriesFeature.State() + state.repositories = [repoA, repoB] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) + + #expect(presentation.repositoryOrderIDs == [repoA.id, repoB.id]) + } + + @Test func customOrderedRootsUseSamePresentationRules() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + let repoB = makeRepository(id: "/tmp/b", worktrees: []) + var state = makeState(repositories: [repoA, repoB]) + state.repositoryOrderIDs = [repoB.id, repoA.id] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) + + #expect(presentation.repositoryOrderIDs == [repoB.id, repoA.id]) + } + + @Test func worktreeDropDestinationsMapToExistingOrderingActions() { + let pinned = SidebarWorktreeDropTarget( + repositoryID: "/tmp/repo", + section: .pinned, + source: IndexSet(integer: 1), + destination: 0 + ) + let unpinned = SidebarWorktreeDropTarget( + repositoryID: "/tmp/repo", + section: .unpinned, + source: IndexSet(integer: 0), + destination: 2 + ) + + #expect( + pinned.action + == RepositoriesFeature.WorktreeOrderingAction.pinnedWorktreesMoved( + repositoryID: "/tmp/repo", + IndexSet(integer: 1), + 0 + ) + ) + #expect( + unpinned.action + == RepositoriesFeature.WorktreeOrderingAction.unpinnedWorktreesMoved( + repositoryID: "/tmp/repo", + IndexSet(integer: 0), + 2 + ) + ) + } + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: id, + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository( + id: String, + kind: Repository.Kind = .git, + worktrees: [Worktree] + ) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: URL(fileURLWithPath: id).lastPathComponent, + kind: kind, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } + + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { + var state = RepositoriesFeature.State() + state.repositories = IdentifiedArray(uniqueElements: repositories) + state.repositoryRoots = repositories.map(\.rootURL) + return state + } +} -- 2.51.2 From 304a2f8ceb08fbba7bc28d30dd091edfe355791a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 21:22:54 +0900 Subject: [PATCH 028/528] Use sidebar container rows for repository drag --- .../Models/SidebarPresentation.swift | 6 + .../Views/RepositorySectionView.swift | 15 +- .../Views/SidebarDragSupport.swift | 172 ++++++++++++++ .../Repositories/Views/SidebarListView.swift | 219 ++++++++---------- .../Repositories/Views/WorktreeRowsView.swift | 187 ++++++++++++--- .../RepositorySectionViewTests.swift | 22 ++ 6 files changed, 468 insertions(+), 153 deletions(-) create mode 100644 supacode/Features/Repositories/Views/SidebarDragSupport.swift diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift index 8fbc6053..a211cbbc 100644 --- a/supacode/Features/Repositories/Models/SidebarPresentation.swift +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -59,6 +59,12 @@ enum SidebarPresentationItemID: Equatable, Hashable { case archivedWorktrees } +enum SidebarScrollID: Equatable, Hashable { + case repository(Repository.ID) + case worktree(Worktree.ID) + case archivedWorktrees +} + struct SidebarListHeaderModel: Equatable, Identifiable { let id = SidebarPresentationItemID.listHeader var repositoryCount: Int diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 51ff229b..998da820 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -12,6 +12,7 @@ struct RepositorySectionView: View { @Binding var expandedRepoIDs: Set @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager + let onRepositorySelected: () -> Void @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var isHovering = false @@ -21,6 +22,7 @@ struct RepositorySectionView: View { let state = store.state let isExpanded = expandedRepoIDs.contains(repository.id) let isRemovingRepository = state.isRemovingRepository(repository) + let isSelected = state.selection == .repository(repository.id) let openRepoSettings = { _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) } @@ -184,7 +186,11 @@ struct RepositorySectionView: View { .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) .contentShape(.interaction, .rect) .background { - if Self.debugHeaderLayers { + if isSelected { + RoundedRectangle(cornerRadius: 5) + .fill(Color.accentColor.opacity(0.18)) + .padding(.horizontal, 6) + } else if Self.debugHeaderLayers { Rectangle() .fill(.red.opacity(0.12)) .overlay { @@ -194,6 +200,10 @@ struct RepositorySectionView: View { } } .onHover { isHovering = $0 } + .onTapGesture { + onRepositorySelected() + } + .accessibilityAddTraits(.isButton) .contentShape(.rect) .contextMenu { Button("Repo Settings") { @@ -211,7 +221,7 @@ struct RepositorySectionView: View { .environment(\.colorScheme, colorScheme) .preferredColorScheme(colorScheme) - Group { + VStack(spacing: 0) { header .tag(SidebarSelection.repository(repository.id)) if isExpanded { @@ -225,6 +235,7 @@ struct RepositorySectionView: View { ) } } + .id(SidebarScrollID.repository(repository.id)) } private var headerCellHeight: CGFloat { diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift new file mode 100644 index 00000000..62076686 --- /dev/null +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -0,0 +1,172 @@ +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + static let prowlSidebarRepositoryID = UTType(exportedAs: "com.onevcat.prowl.sidebar.repository-id") + static let prowlSidebarWorktreeID = UTType(exportedAs: "com.onevcat.prowl.sidebar.worktree-id") +} + +enum SidebarDragProvider { + static func repository(id: Repository.ID) -> NSItemProvider { + itemProvider(id: id, type: .prowlSidebarRepositoryID) + } + + static func worktree(id: Worktree.ID) -> NSItemProvider { + itemProvider(id: id, type: .prowlSidebarWorktreeID) + } + + private static func itemProvider(id: String, type: UTType) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in + completion(Data(id.utf8), nil) + return nil + } + return provider + } +} + +struct SidebarRepositoryDropDelegate: DropDelegate { + let destination: Int + let repositoryOrderIDs: [Repository.ID] + @Binding var targetedDestination: Int? + let onDrop: (IndexSet, Int) -> Void + let onDragEnded: () -> Void + + func dropEntered(info: DropInfo) { + targetedDestination = destination + } + + func dropExited(info: DropInfo) { + if targetedDestination == destination { + targetedDestination = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + targetedDestination = nil + guard let provider = info.itemProviders(for: [.prowlSidebarRepositoryID]).first else { + onDragEnded() + return false + } + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarRepositoryID.identifier) { data, _ in + guard let data, + let repositoryID = String(data: data, encoding: .utf8), + let source = repositoryOrderIDs.firstIndex(of: repositoryID), + source != destination, + source + 1 != destination + else { + Task { @MainActor in onDragEnded() } + return + } + Task { @MainActor in + onDrop(IndexSet(integer: source), destination) + onDragEnded() + } + } + return true + } +} + +struct SidebarWorktreeDropDelegate: DropDelegate { + let destination: Int + let sectionIDs: [Worktree.ID] + @Binding var targetedDestination: Int? + let onDrop: (IndexSet, Int) -> Void + let onDragEnded: () -> Void + + func dropEntered(info: DropInfo) { + targetedDestination = destination + } + + func dropExited(info: DropInfo) { + if targetedDestination == destination { + targetedDestination = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + targetedDestination = nil + guard let provider = info.itemProviders(for: [.prowlSidebarWorktreeID]).first else { + onDragEnded() + return false + } + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarWorktreeID.identifier) { data, _ in + guard let data, + let worktreeID = String(data: data, encoding: .utf8), + let source = sectionIDs.firstIndex(of: worktreeID), + source != destination, + source + 1 != destination + else { + Task { @MainActor in onDragEnded() } + return + } + Task { @MainActor in + onDrop(IndexSet(integer: source), destination) + onDragEnded() + } + } + return true + } +} + +struct SidebarDropIndicator: View { + let isVisible: Bool + var horizontalPadding: CGFloat = 12 + + var body: some View { + ZStack { + if isVisible { + Capsule() + .fill(Color.accentColor) + .frame(height: 2) + .padding(.horizontal, horizontalPadding) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity) + .frame(height: 6) + .accessibilityHidden(true) + } +} + +extension View { + @ViewBuilder + func draggableRepository( + id: Repository.ID, + isEnabled: Bool, + beginDrag: @escaping () -> Void + ) -> some View { + if isEnabled { + self.onDrag { + beginDrag() + return SidebarDragProvider.repository(id: id) + } + } else { + self + } + } + + @ViewBuilder + func draggableWorktree( + id: Worktree.ID, + isEnabled: Bool, + beginDrag: @escaping () -> Void + ) -> some View { + if isEnabled { + self.onDrag { + beginDrag() + return SidebarDragProvider.worktree(id: id) + } + } else { + self + } + } +} diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 4e6d8078..0f43be57 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -35,6 +35,7 @@ struct SidebarListView: View { let terminalManager: WorktreeTerminalManager @FocusState private var isSidebarFocused: Bool @State private var isDragActive = false + @State private var targetedRepositoryDropDestination: Int? var body: some View { let state = store.state @@ -52,137 +53,51 @@ struct SidebarListView: View { } return false } - let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) - let selection = Binding>( - get: { - var nextSelections = sidebarSelections - if state.isShowingCanvas { - nextSelections = [.canvas] - } else if state.isShowingArchivedWorktrees { - nextSelections = [.archivedWorktrees] - } else { - nextSelections.remove(.archivedWorktrees) - nextSelections.remove(.canvas) - if let selectedRepository = state.selectedRepository, selectedRepository.kind == .plain { - nextSelections = [.repository(selectedRepository.id)] - } else if let selectedWorktreeID = state.selectedWorktreeID { - nextSelections.insert(.worktree(selectedWorktreeID)) - } - } - return nextSelections - }, - set: { newValue in - let nextSelections = newValue - let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in - guard case .repository(let repositoryID) = selection else { return nil } - return repositoryID - } - - if nextSelections.contains(.canvas) { - sidebarSelections = [.canvas] - store.send(.selectCanvas) - return - } - - if nextSelections.contains(.archivedWorktrees) { - sidebarSelections = [.archivedWorktrees] - store.send(.selectArchivedWorktrees) - return - } - - if let repositoryID = repositorySelections.first { - guard let repository = state.repositories[id: repositoryID] else { - return - } - if repository.capabilities.supportsWorktrees { - withAnimation(.easeOut(duration: 0.2)) { - if expandedRepoIDs.contains(repositoryID) { - expandedRepoIDs.remove(repositoryID) - } else { - expandedRepoIDs.insert(repositoryID) - } - } - sidebarSelections = [] - } else { - sidebarSelections = [.repository(repositoryID)] - store.send(.selectRepository(repositoryID)) - focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) - } - return - } - - let worktreeIDs = Set(nextSelections.compactMap(\.worktreeID)) - guard !worktreeIDs.isEmpty else { - sidebarSelections = [] - store.send(.selectWorktree(nil)) - return - } - let shouldFocusTerminal = worktreeIDs.count == 1 - sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) - if let selectedWorktreeID = state.selectedWorktreeID, - worktreeIDs.contains(selectedWorktreeID) - { - if shouldFocusTerminal { - focusTerminalAfterSidebarSelection(worktreeID: selectedWorktreeID) - } - return - } - let nextPrimarySelection = - hotkeyRows.map(\.id).first(where: worktreeIDs.contains) - ?? worktreeIDs.first - store.send(.selectWorktree(nextPrimarySelection, focusTerminal: shouldFocusTerminal)) - if shouldFocusTerminal { - focusTerminalAfterSidebarSelection(worktreeID: nextPrimarySelection) - } - } - ) + let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let pendingSidebarReveal = state.pendingSidebarReveal ScrollViewReader { scrollProxy in - List(selection: selection) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - .listRowInsets(EdgeInsets()) - } + ScrollView { + LazyVStack(spacing: 0) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryDropZone( + destination: index, + repositoryOrderIDs: presentation.repositoryOrderIDs + ) + repositoryItemView( + item, + index: index, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + } + repositoryDropZone( + destination: repositoryItems.count, + repositoryOrderIDs: presentation.repositoryOrderIDs ) - .listRowInsets(EdgeInsets()) - } - .onMove { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) } + .padding(.vertical, 2) } - .listStyle(.sidebar) .scrollIndicators(.never) .frame(minWidth: 220) + .background(.bar) .onDragSessionUpdated { session in if case .ended = session.phase { - if isDragActive { - isDragActive = false - store.send(.worktreeOrdering(.setSidebarDragActive(false))) - } + endSidebarDrag() return } if case .dataTransferCompleted = session.phase { - if isDragActive { - isDragActive = false - store.send(.worktreeOrdering(.setSidebarDragActive(false))) - } + endSidebarDrag() return } - if !isDragActive { - isDragActive = true - store.send(.worktreeOrdering(.setSidebarDragActive(true))) - } + beginSidebarDrag() } .safeAreaInset(edge: .top) { HStack(spacing: 4) { @@ -285,7 +200,15 @@ struct SidebarListView: View { selectedWorktreeIDs: selectedWorktreeIDs, expandedRepoIDs: $expandedRepoIDs, store: store, - terminalManager: terminalManager + terminalManager: terminalManager, + onRepositorySelected: { + selectRepository(repository) + } + ) + .draggableRepository( + id: model.repositoryID, + isEnabled: !model.isRemoving, + beginDrag: beginSidebarDrag ) } @@ -311,12 +234,66 @@ struct SidebarListView: View { .accessibilityHidden(true) } } + .draggableRepository( + id: model.id, + isEnabled: model.isReorderable, + beginDrag: beginSidebarDrag + ) case .listHeader, .archivedWorktrees: EmptyView() } } + private func repositoryDropZone( + destination: Int, + repositoryOrderIDs: [Repository.ID] + ) -> some View { + SidebarDropIndicator(isVisible: targetedRepositoryDropDestination == destination) + .onDrop( + of: [.prowlSidebarRepositoryID], + delegate: SidebarRepositoryDropDelegate( + destination: destination, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: $targetedRepositoryDropDestination, + onDrop: { offsets, destination in + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) + }, + onDragEnded: endSidebarDrag + ) + ) + } + + private func beginSidebarDrag() { + guard !isDragActive else { return } + isDragActive = true + store.send(.worktreeOrdering(.setSidebarDragActive(true))) + } + + private func endSidebarDrag() { + targetedRepositoryDropDestination = nil + guard isDragActive else { return } + isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) + } + + private func selectRepository(_ repository: Repository) { + if repository.capabilities.supportsWorktrees { + withAnimation(.easeOut(duration: 0.2)) { + if expandedRepoIDs.contains(repository.id) { + expandedRepoIDs.remove(repository.id) + } else { + expandedRepoIDs.insert(repository.id) + } + } + sidebarSelections = [] + } else { + sidebarSelections = [.repository(repository.id)] + store.send(.selectRepository(repository.id)) + focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) + } + } + @MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, @@ -328,7 +305,7 @@ struct SidebarListView: View { await Task.yield() isSidebarFocused = true withAnimation(.easeOut(duration: 0.2)) { - scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) + scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) } store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } @@ -355,6 +332,14 @@ struct SidebarListView: View { static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) } + + static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set { + var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs + if let selectedWorktreeID = state.selectedWorktreeID { + selectedWorktreeIDs.insert(selectedWorktreeID) + } + return selectedWorktreeIDs + } } extension SidebarItem { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index cb39234b..50829300 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -14,6 +14,8 @@ struct WorktreeRowsView: View { @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var draggingWorktreeIDs: Set = [] @State private var hoveredWorktreeID: Worktree.ID? + @State private var targetedPinnedDropDestination: Int? + @State private var targetedUnpinnedDropDestination: Int? var body: some View { if isExpanded { @@ -35,7 +37,6 @@ struct WorktreeRowsView: View { return rowsGroup( sections: sections, isRepositoryRemoving: isRepositoryRemoving, - showShortcutHints: showShortcutHints, shortcutIndexByID: shortcutIndexByID ) .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) @@ -45,7 +46,6 @@ struct WorktreeRowsView: View { private func rowsGroup( sections: WorktreeRowSections, isRepositoryRemoving: Bool, - showShortcutHints: Bool, shortcutIndexByID: [Worktree.ID: Int] ) -> some View { if let row = sections.main { @@ -53,39 +53,62 @@ struct WorktreeRowsView: View { row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: true, - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) ) } - ForEach(sections.pinned) { row in - rowView( - row, - isRepositoryRemoving: isRepositoryRemoving, - moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil - ) - } - .onMove { offsets, destination in - store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) - } + movableRowsGroup( + rows: sections.pinned, + section: .pinned, + targetedDestination: $targetedPinnedDropDestination, + isRepositoryRemoving: isRepositoryRemoving, + shortcutIndexByID: shortcutIndexByID + ) ForEach(sections.pending) { row in rowView( row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: true, - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) ) } - ForEach(sections.unpinned) { row in + movableRowsGroup( + rows: sections.unpinned, + section: .unpinned, + targetedDestination: $targetedUnpinnedDropDestination, + isRepositoryRemoving: isRepositoryRemoving, + shortcutIndexByID: shortcutIndexByID + ) + } + + @ViewBuilder + private func movableRowsGroup( + rows: [WorktreeRowModel], + section: SidebarWorktreeSection, + targetedDestination: Binding, + isRepositoryRemoving: Bool, + shortcutIndexByID: [Worktree.ID: Int] + ) -> some View { + let rowIDs = rows.map(\.id) + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + worktreeDropZone( + destination: index, + rowIDs: rowIDs, + targetedDestination: targetedDestination, + section: section + ) rowView( row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) ) } - .onMove { offsets, destination in - store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) - } + worktreeDropZone( + destination: rows.count, + rowIDs: rowIDs, + targetedDestination: targetedDestination, + section: section + ) } @ViewBuilder @@ -159,6 +182,18 @@ struct WorktreeRowsView: View { } .contentShape(.dragPreview, .rect) .contentShape(.interaction, .rect) + .onTapGesture { + selectWorktreeRow(row.id) + } + .accessibilityAddTraits(.isButton) + .draggableWorktree( + id: row.id, + isEnabled: !moveDisabled, + beginDrag: { + draggingWorktreeIDs = [row.id] + store.send(.worktreeOrdering(.setSidebarDragActive(true))) + } + ) .environment(\.colorScheme, colorScheme) .preferredColorScheme(colorScheme) .onHover { hovering in @@ -169,25 +204,103 @@ struct WorktreeRowsView: View { } } .onDragSessionUpdated { session in - let draggedIDs = Set(session.draggedItemIDs(for: Worktree.ID.self)) - if case .ended = session.phase { - if !draggingWorktreeIDs.isEmpty { - draggingWorktreeIDs = [] + let didEnd = + if case .ended = session.phase { + true + } else if case .dataTransferCompleted = session.phase { + true + } else { + false } - return + handleWorktreeDragSession( + draggedIDs: Set(session.draggedItemIDs(for: Worktree.ID.self)), + didEnd: didEnd + ) + } + } + + private func handleWorktreeDragSession( + draggedIDs: Set, + didEnd: Bool + ) { + if didEnd { + draggingWorktreeIDs = [] + return + } + if draggedIDs != draggingWorktreeIDs { + draggingWorktreeIDs = draggedIDs + } + } + + private func worktreeDropZone( + destination: Int, + rowIDs: [Worktree.ID], + targetedDestination: Binding, + section: SidebarWorktreeSection + ) -> some View { + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == destination, horizontalPadding: 28) + .onDrop( + of: [.prowlSidebarWorktreeID], + delegate: SidebarWorktreeDropDelegate( + destination: destination, + sectionIDs: rowIDs, + targetedDestination: targetedDestination, + onDrop: { offsets, destination in + switch section { + case .pinned: + store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) + case .unpinned: + store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) + } + }, + onDragEnded: endWorktreeDrag + ) + ) + } + + private func selectWorktreeRow(_ worktreeID: Worktree.ID) { + if commandKeyObserver.isPressed { + var nextSelection = selectedWorktreeIDs + if nextSelection.contains(worktreeID) { + nextSelection.remove(worktreeID) + } else { + nextSelection.insert(worktreeID) } - if case .dataTransferCompleted = session.phase { - if !draggingWorktreeIDs.isEmpty { - draggingWorktreeIDs = [] - } + guard !nextSelection.isEmpty else { + store.send(.selectWorktree(nil)) return } - if draggedIDs != draggingWorktreeIDs { - draggingWorktreeIDs = draggedIDs + let primarySelection = + hotkeyRows.map(\.id).first(where: nextSelection.contains) + ?? nextSelection.first + store.send(.selectWorktree(primarySelection, focusTerminal: false)) + store.send(.setSidebarSelectedWorktreeIDs(nextSelection)) + return + } + + store.send(.selectWorktree(worktreeID, focusTerminal: true)) + focusTerminalAfterSelection(worktreeID: worktreeID) + } + + private func focusTerminalAfterSelection(worktreeID: Worktree.ID) { + Task { @MainActor [terminalManager] in + for _ in 0..<4 { + await Task.yield() + if let terminalState = terminalManager.stateIfExists(for: worktreeID) { + terminalState.focusSelectedTab() + return + } } } } + private func endWorktreeDrag() { + draggingWorktreeIDs = [] + targetedPinnedDropDestination = nil + targetedUnpinnedDropDestination = nil + store.send(.worktreeOrdering(.setSidebarDragActive(false))) + } + private struct WorktreeRowViewConfig { let displayName: String let worktreeName: String @@ -230,10 +343,16 @@ struct WorktreeRowsView: View { onStopRunScript: config.onStopRunScript, ) .tag(SidebarSelection.worktree(row.id)) - .id(row.id) + .id(SidebarScrollID.worktree(row.id)) .typeSelectEquivalent("") - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) + .padding(.horizontal, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 5) + .fill(Color.accentColor.opacity(0.18)) + .padding(.horizontal, 6) + } + } .transition(.opacity) .moveDisabled(config.moveDisabled) } diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index 05c64f24..885472dd 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -69,6 +69,28 @@ struct RepositorySectionViewTests { #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) } + @Test func explicitSelectionIncludesPrimarySelectedWorktree() { + let worktree = Worktree( + id: "/tmp/repo/wt", + name: "wt", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + let repository = Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + kind: .git, + worktrees: [worktree] + ) + var state = RepositoriesFeature.State() + state.repositories = [repository] + state.selection = .worktree(worktree.id) + + #expect(SidebarListView.selectedWorktreeIDs(in: state) == [worktree.id]) + } + @Test func openTabCountForGitRepositorySumsAllWorktrees() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo") -- 2.51.2 From 7400ec0f0252145baca6df3040d416d0a4fe7c6d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 21:35:25 +0900 Subject: [PATCH 029/528] Fix sidebar drop overlays and hover state --- .../Views/RepositorySectionView.swift | 1 + .../Views/SidebarDragSupport.swift | 104 +++++++++++--- .../Repositories/Views/SidebarListView.swift | 134 ++++++++---------- .../Repositories/Views/WorktreeRowsView.swift | 62 +++----- 4 files changed, 169 insertions(+), 132 deletions(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 998da820..baa80323 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -182,6 +182,7 @@ struct RepositorySectionView: View { } } .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) + .padding(.horizontal, 12) .padding(.top, hasTopSpacing ? 4 : 0) .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) .contentShape(.interaction, .rect) diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index 62076686..d76f3f2e 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -26,27 +26,27 @@ enum SidebarDragProvider { } struct SidebarRepositoryDropDelegate: DropDelegate { - let destination: Int + let destination: (DropInfo) -> Int let repositoryOrderIDs: [Repository.ID] @Binding var targetedDestination: Int? let onDrop: (IndexSet, Int) -> Void let onDragEnded: () -> Void func dropEntered(info: DropInfo) { - targetedDestination = destination + targetedDestination = destination(info) } func dropExited(info: DropInfo) { - if targetedDestination == destination { - targetedDestination = nil - } + targetedDestination = nil } func dropUpdated(info: DropInfo) -> DropProposal? { - DropProposal(operation: .move) + targetedDestination = destination(info) + return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { + let dropDestination = destination(info) targetedDestination = nil guard let provider = info.itemProviders(for: [.prowlSidebarRepositoryID]).first else { onDragEnded() @@ -56,14 +56,14 @@ struct SidebarRepositoryDropDelegate: DropDelegate { guard let data, let repositoryID = String(data: data, encoding: .utf8), let source = repositoryOrderIDs.firstIndex(of: repositoryID), - source != destination, - source + 1 != destination + source != dropDestination, + source + 1 != dropDestination else { Task { @MainActor in onDragEnded() } return } Task { @MainActor in - onDrop(IndexSet(integer: source), destination) + onDrop(IndexSet(integer: source), dropDestination) onDragEnded() } } @@ -72,27 +72,27 @@ struct SidebarRepositoryDropDelegate: DropDelegate { } struct SidebarWorktreeDropDelegate: DropDelegate { - let destination: Int + let destination: (DropInfo) -> Int let sectionIDs: [Worktree.ID] @Binding var targetedDestination: Int? let onDrop: (IndexSet, Int) -> Void let onDragEnded: () -> Void func dropEntered(info: DropInfo) { - targetedDestination = destination + targetedDestination = destination(info) } func dropExited(info: DropInfo) { - if targetedDestination == destination { - targetedDestination = nil - } + targetedDestination = nil } func dropUpdated(info: DropInfo) -> DropProposal? { - DropProposal(operation: .move) + targetedDestination = destination(info) + return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { + let dropDestination = destination(info) targetedDestination = nil guard let provider = info.itemProviders(for: [.prowlSidebarWorktreeID]).first else { onDragEnded() @@ -102,14 +102,14 @@ struct SidebarWorktreeDropDelegate: DropDelegate { guard let data, let worktreeID = String(data: data, encoding: .utf8), let source = sectionIDs.firstIndex(of: worktreeID), - source != destination, - source + 1 != destination + source != dropDestination, + source + 1 != dropDestination else { Task { @MainActor in onDragEnded() } return } Task { @MainActor in - onDrop(IndexSet(integer: source), destination) + onDrop(IndexSet(integer: source), dropDestination) onDragEnded() } } @@ -138,6 +138,74 @@ struct SidebarDropIndicator: View { } extension View { + func repositoryDropTarget( + index: Int, + repositoryOrderIDs: [Repository.ID], + targetedDestination: Binding, + onDrop: @escaping (IndexSet, Int) -> Void, + onDragEnded: @escaping () -> Void + ) -> some View { + self + .overlay(alignment: .top) { + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index) + } + .overlay(alignment: .bottom) { + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1) + } + .background { + GeometryReader { proxy in + Color.clear + .onDrop( + of: [.prowlSidebarRepositoryID], + delegate: SidebarRepositoryDropDelegate( + destination: { info in + info.location.y < proxy.size.height / 2 ? index : index + 1 + }, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: targetedDestination, + onDrop: onDrop, + onDragEnded: onDragEnded + ) + ) + .accessibilityHidden(true) + } + } + } + + func worktreeDropTarget( + index: Int, + rowIDs: [Worktree.ID], + targetedDestination: Binding, + onDrop: @escaping (IndexSet, Int) -> Void, + onDragEnded: @escaping () -> Void + ) -> some View { + self + .overlay(alignment: .top) { + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index, horizontalPadding: 28) + } + .overlay(alignment: .bottom) { + SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1, horizontalPadding: 28) + } + .background { + GeometryReader { proxy in + Color.clear + .onDrop( + of: [.prowlSidebarWorktreeID], + delegate: SidebarWorktreeDropDelegate( + destination: { info in + info.location.y < proxy.size.height / 2 ? index : index + 1 + }, + sectionIDs: rowIDs, + targetedDestination: targetedDestination, + onDrop: onDrop, + onDragEnded: onDragEnded + ) + ) + .accessibilityHidden(true) + } + } + } + @ViewBuilder func draggableRepository( id: Repository.ID, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 0f43be57..3c3182ae 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -67,21 +67,14 @@ struct SidebarListView: View { } ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryDropZone( - destination: index, - repositoryOrderIDs: presentation.repositoryOrderIDs - ) repositoryItemView( item, index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, hotkeyRows: hotkeyRows, selectedWorktreeIDs: selectedWorktreeIDs ) } - repositoryDropZone( - destination: repositoryItems.count, - repositoryOrderIDs: presentation.repositoryOrderIDs - ) } .padding(.vertical, 2) } @@ -95,9 +88,7 @@ struct SidebarListView: View { } if case .dataTransferCompleted = session.phase { endSidebarDrag() - return } - beginSidebarDrag() } .safeAreaInset(edge: .top) { HStack(spacing: 4) { @@ -186,82 +177,75 @@ struct SidebarListView: View { private func repositoryItemView( _ item: SidebarItem, index: Int, + repositoryOrderIDs: [Repository.ID], hotkeyRows: [WorktreeRowModel], selectedWorktreeIDs: Set ) -> some View { - switch item { - case .repository(let model): - if let repository = store.state.repositories[id: model.repositoryID] { - RepositorySectionView( - repository: repository, - hasTopSpacing: index > 0, - isDragActive: isDragActive, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - expandedRepoIDs: $expandedRepoIDs, - store: store, - terminalManager: terminalManager, - onRepositorySelected: { - selectRepository(repository) + Group { + switch item { + case .repository(let model): + if let repository = store.state.repositories[id: model.repositoryID] { + RepositorySectionView( + repository: repository, + hasTopSpacing: index > 0, + isDragActive: isDragActive, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs, + expandedRepoIDs: $expandedRepoIDs, + store: store, + terminalManager: terminalManager, + onRepositorySelected: { + selectRepository(repository) + } + ) + .draggableRepository( + id: model.repositoryID, + isEnabled: !model.isRemoving, + beginDrag: beginSidebarDrag + ) + } + + case .failedRepository(let model): + FailedRepositoryRow( + name: model.name, + path: model.path, + showFailure: { + let message = "\(model.path)\n\n\(model.failureMessage)" + store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) + }, + removeRepository: { + store.send(.repositoryManagement(.removeFailedRepository(model.id))) } ) + .padding(.horizontal, 12) + .overlay(alignment: .top) { + if index > 0 { + Rectangle() + .fill(.secondary) + .frame(height: 1) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + } .draggableRepository( - id: model.repositoryID, - isEnabled: !model.isRemoving, + id: model.id, + isEnabled: model.isReorderable, beginDrag: beginSidebarDrag ) - } - case .failedRepository(let model): - FailedRepositoryRow( - name: model.name, - path: model.path, - showFailure: { - let message = "\(model.path)\n\n\(model.failureMessage)" - store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) - }, - removeRepository: { - store.send(.repositoryManagement(.removeFailedRepository(model.id))) - } - ) - .padding(.horizontal, 12) - .overlay(alignment: .top) { - if index > 0 { - Rectangle() - .fill(.secondary) - .frame(height: 1) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } + case .listHeader, .archivedWorktrees: + EmptyView() } - .draggableRepository( - id: model.id, - isEnabled: model.isReorderable, - beginDrag: beginSidebarDrag - ) - - case .listHeader, .archivedWorktrees: - EmptyView() } - } - - private func repositoryDropZone( - destination: Int, - repositoryOrderIDs: [Repository.ID] - ) -> some View { - SidebarDropIndicator(isVisible: targetedRepositoryDropDestination == destination) - .onDrop( - of: [.prowlSidebarRepositoryID], - delegate: SidebarRepositoryDropDelegate( - destination: destination, - repositoryOrderIDs: repositoryOrderIDs, - targetedDestination: $targetedRepositoryDropDestination, - onDrop: { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) - }, - onDragEnded: endSidebarDrag - ) - ) + .repositoryDropTarget( + index: index, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: $targetedRepositoryDropDestination, + onDrop: { offsets, destination in + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) + }, + onDragEnded: endSidebarDrag + ) } private func beginSidebarDrag() { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 50829300..d6fcaade 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -90,25 +90,22 @@ struct WorktreeRowsView: View { ) -> some View { let rowIDs = rows.map(\.id) ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in - worktreeDropZone( - destination: index, - rowIDs: rowIDs, - targetedDestination: targetedDestination, - section: section - ) rowView( row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) ) + .worktreeDropTarget( + index: index, + rowIDs: rowIDs, + targetedDestination: targetedDestination, + onDrop: { offsets, destination in + moveWorktrees(section: section, offsets: offsets, destination: destination) + }, + onDragEnded: endWorktreeDrag + ) } - worktreeDropZone( - destination: rows.count, - rowIDs: rowIDs, - targetedDestination: targetedDestination, - section: section - ) } @ViewBuilder @@ -224,7 +221,7 @@ struct WorktreeRowsView: View { didEnd: Bool ) { if didEnd { - draggingWorktreeIDs = [] + endWorktreeDrag() return } if draggedIDs != draggingWorktreeIDs { @@ -232,32 +229,6 @@ struct WorktreeRowsView: View { } } - private func worktreeDropZone( - destination: Int, - rowIDs: [Worktree.ID], - targetedDestination: Binding, - section: SidebarWorktreeSection - ) -> some View { - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == destination, horizontalPadding: 28) - .onDrop( - of: [.prowlSidebarWorktreeID], - delegate: SidebarWorktreeDropDelegate( - destination: destination, - sectionIDs: rowIDs, - targetedDestination: targetedDestination, - onDrop: { offsets, destination in - switch section { - case .pinned: - store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) - case .unpinned: - store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) - } - }, - onDragEnded: endWorktreeDrag - ) - ) - } - private func selectWorktreeRow(_ worktreeID: Worktree.ID) { if commandKeyObserver.isPressed { var nextSelection = selectedWorktreeIDs @@ -301,6 +272,19 @@ struct WorktreeRowsView: View { store.send(.worktreeOrdering(.setSidebarDragActive(false))) } + private func moveWorktrees( + section: SidebarWorktreeSection, + offsets: IndexSet, + destination: Int + ) { + switch section { + case .pinned: + store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) + case .unpinned: + store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) + } + } + private struct WorktreeRowViewConfig { let displayName: String let worktreeName: String -- 2.51.2 From bad7a46ff138bffa272e2f055921bc5cb8f9e903 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 22:05:32 +0900 Subject: [PATCH 030/528] Stabilize sidebar drag payload handling --- .../Views/RepositorySectionView.swift | 6 +- .../Views/SidebarDragSupport.swift | 114 ++++++++++-------- .../Repositories/Views/SidebarListView.swift | 10 +- .../Repositories/Views/WorktreeRowsView.swift | 14 +-- 4 files changed, 81 insertions(+), 63 deletions(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index baa80323..717ca93c 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -36,8 +36,6 @@ struct RepositorySectionView: View { } } } - let isDragging = isDragActive - let appearance = repositoryAppearances[repository.id] ?? .empty let header = HStack { // Inner HStack groups the name row and the tab-count badge so they @@ -73,7 +71,7 @@ struct RepositorySectionView: View { } } } - if isRemovingRepository && !isDragging { + if isRemovingRepository { ProgressView() .controlSize(.small) .background { @@ -94,7 +92,7 @@ struct RepositorySectionView: View { .help(color.displayName) .accessibilityLabel(Text("Repo color: \(color.displayName)")) } - if isHovering && !isDragging { + if isHovering { Menu { Button("Repo Settings") { openRepoSettings() diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index d76f3f2e..af0d5608 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -2,27 +2,51 @@ import SwiftUI import UniformTypeIdentifiers extension UTType { - static let prowlSidebarRepositoryID = UTType(exportedAs: "com.onevcat.prowl.sidebar.repository-id") - static let prowlSidebarWorktreeID = UTType(exportedAs: "com.onevcat.prowl.sidebar.worktree-id") + nonisolated static let prowlSidebarDragPayload = UTType.plainText } enum SidebarDragProvider { - static func repository(id: Repository.ID) -> NSItemProvider { - itemProvider(id: id, type: .prowlSidebarRepositoryID) + private nonisolated static let repositoryPrefix = "prowl-sidebar-repository:" + private nonisolated static let worktreePrefix = "prowl-sidebar-worktree:" + + nonisolated static func repository(id: Repository.ID) -> NSItemProvider { + itemProvider(payload: repositoryPrefix + id) + } + + nonisolated static func worktree(id: Worktree.ID) -> NSItemProvider { + itemProvider(payload: worktreePrefix + id) } - static func worktree(id: Worktree.ID) -> NSItemProvider { - itemProvider(id: id, type: .prowlSidebarWorktreeID) + nonisolated static func repositoryID(from data: Data) -> Repository.ID? { + payload(from: data, prefix: repositoryPrefix) } - private static func itemProvider(id: String, type: UTType) -> NSItemProvider { + nonisolated static func worktreeID(from data: Data) -> Worktree.ID? { + payload(from: data, prefix: worktreePrefix) + } + + private nonisolated static func itemProvider(payload: String) -> NSItemProvider { let provider = NSItemProvider() - provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in - completion(Data(id.utf8), nil) + let loadHandler: (@escaping (Data?, (any Error)?) -> Void) -> Progress? = { completion in + completion(Data(payload.utf8), nil) return nil } + provider.registerDataRepresentation( + forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier, + visibility: .all, + loadHandler: loadHandler + ) return provider } + + private nonisolated static func payload(from data: Data, prefix: String) -> String? { + guard let payload = String(data: data, encoding: .utf8), + payload.hasPrefix(prefix) + else { + return nil + } + return String(payload.dropFirst(prefix.count)) + } } struct SidebarRepositoryDropDelegate: DropDelegate { @@ -48,13 +72,13 @@ struct SidebarRepositoryDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { let dropDestination = destination(info) targetedDestination = nil - guard let provider = info.itemProviders(for: [.prowlSidebarRepositoryID]).first else { + guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { onDragEnded() return false } - provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarRepositoryID.identifier) { data, _ in + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in guard let data, - let repositoryID = String(data: data, encoding: .utf8), + let repositoryID = SidebarDragProvider.repositoryID(from: data), let source = repositoryOrderIDs.firstIndex(of: repositoryID), source != dropDestination, source + 1 != dropDestination @@ -94,13 +118,13 @@ struct SidebarWorktreeDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { let dropDestination = destination(info) targetedDestination = nil - guard let provider = info.itemProviders(for: [.prowlSidebarWorktreeID]).first else { + guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { onDragEnded() return false } - provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarWorktreeID.identifier) { data, _ in + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in guard let data, - let worktreeID = String(data: data, encoding: .utf8), + let worktreeID = SidebarDragProvider.worktreeID(from: data), let source = sectionIDs.firstIndex(of: worktreeID), source != dropDestination, source + 1 != dropDestination @@ -152,24 +176,18 @@ extension View { .overlay(alignment: .bottom) { SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1) } - .background { - GeometryReader { proxy in - Color.clear - .onDrop( - of: [.prowlSidebarRepositoryID], - delegate: SidebarRepositoryDropDelegate( - destination: { info in - info.location.y < proxy.size.height / 2 ? index : index + 1 - }, - repositoryOrderIDs: repositoryOrderIDs, - targetedDestination: targetedDestination, - onDrop: onDrop, - onDragEnded: onDragEnded - ) - ) - .accessibilityHidden(true) - } - } + .onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarRepositoryDropDelegate( + destination: { info in + info.location.y < 24 ? index : index + 1 + }, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: targetedDestination, + onDrop: onDrop, + onDragEnded: onDragEnded + ) + ) } func worktreeDropTarget( @@ -186,24 +204,18 @@ extension View { .overlay(alignment: .bottom) { SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1, horizontalPadding: 28) } - .background { - GeometryReader { proxy in - Color.clear - .onDrop( - of: [.prowlSidebarWorktreeID], - delegate: SidebarWorktreeDropDelegate( - destination: { info in - info.location.y < proxy.size.height / 2 ? index : index + 1 - }, - sectionIDs: rowIDs, - targetedDestination: targetedDestination, - onDrop: onDrop, - onDragEnded: onDragEnded - ) - ) - .accessibilityHidden(true) - } - } + .onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarWorktreeDropDelegate( + destination: { info in + info.location.y < 18 ? index : index + 1 + }, + sectionIDs: rowIDs, + targetedDestination: targetedDestination, + onDrop: onDrop, + onDragEnded: onDragEnded + ) + ) } @ViewBuilder diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 3c3182ae..64cb6b61 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -118,6 +118,9 @@ struct SidebarListView: View { return true } .focused($isSidebarFocused) + .onAppear { + resetSidebarDrag() + } .task(id: pendingSidebarReveal?.id) { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } @@ -256,7 +259,12 @@ struct SidebarListView: View { private func endSidebarDrag() { targetedRepositoryDropDestination = nil - guard isDragActive else { return } + isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) + } + + private func resetSidebarDrag() { + targetedRepositoryDropDestination = nil isDragActive = false store.send(.worktreeOrdering(.setSidebarDragActive(false))) } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index d6fcaade..cbea41d9 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -115,7 +115,7 @@ struct WorktreeRowsView: View { moveDisabled: Bool, shortcutHint: String? ) -> some View { - let isSidebarDragActive = store.state.isSidebarDragActive + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) let displayName = if row.isDeleting { @@ -125,7 +125,7 @@ struct WorktreeRowsView: View { } else { row.name } - let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isSidebarDragActive + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive let pinAction: (() -> Void)? = canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } @@ -156,9 +156,9 @@ struct WorktreeRowsView: View { let config = WorktreeRowViewConfig( displayName: displayName, worktreeName: worktreeName(for: row), - isHovered: !isSidebarDragActive && hoveredWorktreeID == row.id, - showsNotificationIndicator: !isSidebarDragActive && showsNotificationIndicator, - notifications: isSidebarDragActive ? [] : notifications, + isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, + showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, + notifications: isWorktreeDragActive ? [] : notifications, onFocusNotification: onFocusNotification, shortcutHint: shortcutHint, pinAction: pinAction, @@ -304,12 +304,12 @@ struct WorktreeRowsView: View { let isSelected = selectedWorktreeIDs.contains(row.id) let taskStatus = terminalManager.taskStatus(for: row.id) let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) - let isSidebarDragActive = store.state.isSidebarDragActive + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty return WorktreeRow( name: config.displayName, worktreeName: config.worktreeName, info: row.info, - showsPullRequestInfo: !isSidebarDragActive && !draggingWorktreeIDs.contains(row.id), + showsPullRequestInfo: !isWorktreeDragActive, isHovered: config.isHovered, isPinned: row.isPinned, isMainWorktree: row.isMainWorktree, -- 2.51.2 From 971a98ea5ad296a396e7e058d2711bb9f82f47c4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 22:24:54 +0900 Subject: [PATCH 031/528] Fix sidebar drag indicators and row hit areas --- .../Repositories/Views/RepoDisplayName.swift | 2 + .../Views/SidebarDragSupport.swift | 146 ++++++++++++++---- .../Repositories/Views/SidebarListView.swift | 11 +- .../Repositories/Views/WorktreeRow.swift | 1 + .../Repositories/Views/WorktreeRowsView.swift | 136 +++++++++------- supacodeTests/SidebarDragSupportTests.swift | 54 +++++++ 6 files changed, 261 insertions(+), 89 deletions(-) create mode 100644 supacodeTests/SidebarDragSupportTests.swift diff --git a/supacode/Features/Repositories/Views/RepoDisplayName.swift b/supacode/Features/Repositories/Views/RepoDisplayName.swift index 485e70ff..daa6ead0 100644 --- a/supacode/Features/Repositories/Views/RepoDisplayName.swift +++ b/supacode/Features/Repositories/Views/RepoDisplayName.swift @@ -16,6 +16,8 @@ struct RepoDisplayName: View { var body: some View { Text(customTitle ?? fallbackName) + .lineLimit(1) + .truncationMode(.tail) .help(tooltip ?? "") } } diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index af0d5608..b64abbee 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -27,7 +27,7 @@ enum SidebarDragProvider { private nonisolated static func itemProvider(payload: String) -> NSItemProvider { let provider = NSItemProvider() - let loadHandler: (@escaping (Data?, (any Error)?) -> Void) -> Progress? = { completion in + let loadHandler: @Sendable (@escaping @Sendable (Data?, (any Error)?) -> Void) -> Progress? = { completion in completion(Data(payload.utf8), nil) return nil } @@ -161,31 +161,62 @@ struct SidebarDropIndicator: View { } } +enum SidebarDropIndicatorEdge: Equatable { + case none + case top + case bottom + + static func edge( + targetedDestination: Int?, + rowIndex: Int, + rowCount: Int + ) -> Self { + guard let targetedDestination else { + return .none + } + if targetedDestination == rowIndex { + return .top + } + if rowIndex == rowCount - 1, targetedDestination == rowCount { + return .bottom + } + return .none + } +} + +struct SidebarDropTargetActions { + let onDrop: (IndexSet, Int) -> Void + let onDragEnded: () -> Void +} + extension View { func repositoryDropTarget( index: Int, repositoryOrderIDs: [Repository.ID], + isEnabled: Bool, targetedDestination: Binding, - onDrop: @escaping (IndexSet, Int) -> Void, - onDragEnded: @escaping () -> Void + actions: SidebarDropTargetActions ) -> some View { - self + let edge = SidebarDropIndicatorEdge.edge( + targetedDestination: targetedDestination.wrappedValue, + rowIndex: index, + rowCount: repositoryOrderIDs.count + ) + return + self .overlay(alignment: .top) { - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index) + SidebarDropIndicator(isVisible: edge == .top) } .overlay(alignment: .bottom) { - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1) + SidebarDropIndicator(isVisible: edge == .bottom) } - .onDrop( - of: [.prowlSidebarDragPayload], - delegate: SidebarRepositoryDropDelegate( - destination: { info in - info.location.y < 24 ? index : index + 1 - }, + .modifier( + SidebarRepositoryDropTargetModifier( + isEnabled: isEnabled, + index: index, repositoryOrderIDs: repositoryOrderIDs, targetedDestination: targetedDestination, - onDrop: onDrop, - onDragEnded: onDragEnded + actions: actions ) ) } @@ -193,27 +224,30 @@ extension View { func worktreeDropTarget( index: Int, rowIDs: [Worktree.ID], + isEnabled: Bool, targetedDestination: Binding, - onDrop: @escaping (IndexSet, Int) -> Void, - onDragEnded: @escaping () -> Void + actions: SidebarDropTargetActions ) -> some View { - self + let edge = SidebarDropIndicatorEdge.edge( + targetedDestination: targetedDestination.wrappedValue, + rowIndex: index, + rowCount: rowIDs.count + ) + return + self .overlay(alignment: .top) { - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index, horizontalPadding: 28) + SidebarDropIndicator(isVisible: edge == .top, horizontalPadding: 28) } .overlay(alignment: .bottom) { - SidebarDropIndicator(isVisible: targetedDestination.wrappedValue == index + 1, horizontalPadding: 28) + SidebarDropIndicator(isVisible: edge == .bottom, horizontalPadding: 28) } - .onDrop( - of: [.prowlSidebarDragPayload], - delegate: SidebarWorktreeDropDelegate( - destination: { info in - info.location.y < 18 ? index : index + 1 - }, - sectionIDs: rowIDs, + .modifier( + SidebarWorktreeDropTargetModifier( + isEnabled: isEnabled, + index: index, + rowIDs: rowIDs, targetedDestination: targetedDestination, - onDrop: onDrop, - onDragEnded: onDragEnded + actions: actions ) ) } @@ -250,3 +284,59 @@ extension View { } } } + +private struct SidebarRepositoryDropTargetModifier: ViewModifier { + let isEnabled: Bool + let index: Int + let repositoryOrderIDs: [Repository.ID] + @Binding var targetedDestination: Int? + let actions: SidebarDropTargetActions + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarRepositoryDropDelegate( + destination: { info in + info.location.y < 24 ? index : index + 1 + }, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: $targetedDestination, + onDrop: actions.onDrop, + onDragEnded: actions.onDragEnded + ) + ) + } else { + content + } + } +} + +private struct SidebarWorktreeDropTargetModifier: ViewModifier { + let isEnabled: Bool + let index: Int + let rowIDs: [Worktree.ID] + @Binding var targetedDestination: Int? + let actions: SidebarDropTargetActions + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarWorktreeDropDelegate( + destination: { info in + info.location.y < 18 ? index : index + 1 + }, + sectionIDs: rowIDs, + targetedDestination: $targetedDestination, + onDrop: actions.onDrop, + onDragEnded: actions.onDragEnded + ) + ) + } else { + content + } + } +} diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 64cb6b61..295be16e 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -243,11 +243,14 @@ struct SidebarListView: View { .repositoryDropTarget( index: index, repositoryOrderIDs: repositoryOrderIDs, + isEnabled: isDragActive, targetedDestination: $targetedRepositoryDropDestination, - onDrop: { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) - }, - onDragEnded: endSidebarDrag + actions: SidebarDropTargetActions( + onDrop: { offsets, destination in + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) + }, + onDragEnded: endSidebarDrag + ) ) } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 46f59a2d..229c2467 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -73,6 +73,7 @@ struct WorktreeRow: View { .font(.body) .foregroundStyle(nameColor) .lineLimit(1) + .truncationMode(.tail) Spacer(minLength: 4) if isHovered, pinAction != nil { Button { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index cbea41d9..2999883f 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -89,6 +89,7 @@ struct WorktreeRowsView: View { shortcutIndexByID: [Worktree.ID: Int] ) -> some View { let rowIDs = rows.map(\.id) + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in rowView( row, @@ -99,11 +100,14 @@ struct WorktreeRowsView: View { .worktreeDropTarget( index: index, rowIDs: rowIDs, + isEnabled: isWorktreeDragActive, targetedDestination: targetedDestination, - onDrop: { offsets, destination in - moveWorktrees(section: section, offsets: offsets, destination: destination) - }, - onDragEnded: endWorktreeDrag + actions: SidebarDropTargetActions( + onDrop: { offsets, destination in + moveWorktrees(section: section, offsets: offsets, destination: destination) + }, + onDragEnded: endWorktreeDrag + ) ) } } @@ -116,69 +120,27 @@ struct WorktreeRowsView: View { shortcutHint: String? ) -> some View { let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty - let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) - let displayName = - if row.isDeleting { - "\(row.name) (deleting...)" - } else if row.isArchiving { - "\(row.name) (archiving...)" - } else { - row.name - } - let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive - let pinAction: (() -> Void)? = - canShowRowActions && !row.isMainWorktree - ? { togglePin(for: row.id, isPinned: row.isPinned) } - : nil - let archiveAction: (() -> Void)? = - canShowRowActions && !row.isMainWorktree - ? { archiveWorktree(row.id) } - : nil - let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] - let onFocusNotification: (WorktreeTerminalNotification) -> Void = { notification in - guard let terminalState = terminalManager.stateIfExists(for: row.id) else { - return - } - _ = terminalState.focusSurface(id: notification.surfaceId) - } - let onDiffTap: (() -> Void)? = { - guard let worktree = store.state.worktree(for: row.id) else { return } - DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - resolvedKeybindings: resolvedKeybindings - ) - } - let onStopRunScript: (() -> Void)? = - terminalManager.isRunScriptRunning(for: row.id) - ? { _ = terminalManager.stateIfExists(for: row.id)?.stopRunScript() } - : nil - let config = WorktreeRowViewConfig( - displayName: displayName, - worktreeName: worktreeName(for: row), - isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, - showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, - notifications: isWorktreeDragActive ? [] : notifications, - onFocusNotification: onFocusNotification, - shortcutHint: shortcutHint, - pinAction: pinAction, - archiveAction: archiveAction, - onDiffTap: onDiffTap, - onStopRunScript: onStopRunScript, + let config = rowConfig( + for: row, + isRepositoryRemoving: isRepositoryRemoving, + isWorktreeDragActive: isWorktreeDragActive, moveDisabled: moveDisabled, + shortcutHint: shortcutHint ) let baseRow = worktreeRowView(row, config: config) + .disabled(isRepositoryRemoving) + .contentShape(.dragPreview, .rect) + .contentShape(.interaction, .rect) + .contentShape(Rectangle()) Group { if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { baseRow.contextMenu { rowContextMenu(worktree: worktree, row: row) } } else { - baseRow.disabled(isRepositoryRemoving) + baseRow } } - .contentShape(.dragPreview, .rect) - .contentShape(.interaction, .rect) .onTapGesture { selectWorktreeRow(row.id) } @@ -216,6 +178,66 @@ struct WorktreeRowsView: View { } } + private func rowConfig( + for row: WorktreeRowModel, + isRepositoryRemoving: Bool, + isWorktreeDragActive: Bool, + moveDisabled: Bool, + shortcutHint: String? + ) -> WorktreeRowViewConfig { + let displayName = + if row.isDeleting { + "\(row.name) (deleting...)" + } else if row.isArchiving { + "\(row.name) (archiving...)" + } else { + row.name + } + let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) + let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive + return WorktreeRowViewConfig( + displayName: displayName, + worktreeName: worktreeName(for: row), + isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, + showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, + notifications: isWorktreeDragActive ? [] : notifications, + onFocusNotification: focusNotificationHandler(for: row.id), + shortcutHint: shortcutHint, + pinAction: canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } : nil, + archiveAction: canShowRowActions && !row.isMainWorktree ? { archiveWorktree(row.id) } : nil, + onDiffTap: diffTapHandler(for: row.id), + onStopRunScript: stopRunScriptHandler(for: row.id), + moveDisabled: moveDisabled, + ) + } + + private func focusNotificationHandler(for worktreeID: Worktree.ID) -> (WorktreeTerminalNotification) -> Void { + { notification in + guard let terminalState = terminalManager.stateIfExists(for: worktreeID) else { + return + } + _ = terminalState.focusSurface(id: notification.surfaceId) + } + } + + private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { + { + guard let worktree = store.state.worktree(for: worktreeID) else { return } + DiffWindowManager.shared.show( + worktreeURL: worktree.workingDirectory, + branchName: worktree.name, + resolvedKeybindings: resolvedKeybindings + ) + } + } + + private func stopRunScriptHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { + terminalManager.isRunScriptRunning(for: worktreeID) + ? { _ = terminalManager.stateIfExists(for: worktreeID)?.stopRunScript() } + : nil + } + private func handleWorktreeDragSession( draggedIDs: Set, didEnd: Bool @@ -224,7 +246,7 @@ struct WorktreeRowsView: View { endWorktreeDrag() return } - if draggedIDs != draggingWorktreeIDs { + if !draggedIDs.isEmpty, draggedIDs != draggingWorktreeIDs { draggingWorktreeIDs = draggedIDs } } diff --git a/supacodeTests/SidebarDragSupportTests.swift b/supacodeTests/SidebarDragSupportTests.swift new file mode 100644 index 00000000..4d87d734 --- /dev/null +++ b/supacodeTests/SidebarDragSupportTests.swift @@ -0,0 +1,54 @@ +import Testing + +@testable import supacode + +struct SidebarDragSupportTests { + @Test func dropIndicatorOnlyDrawsOneEdgeForInteriorDestinations() { + #expect( + SidebarDropIndicatorEdge.edge( + targetedDestination: 1, + rowIndex: 0, + rowCount: 3 + ) + == .none + ) + #expect( + SidebarDropIndicatorEdge.edge( + targetedDestination: 1, + rowIndex: 1, + rowCount: 3 + ) + == .top + ) + } + + @Test func dropIndicatorDrawsTopAndFinalBottomBoundaries() { + #expect( + SidebarDropIndicatorEdge.edge( + targetedDestination: 0, + rowIndex: 0, + rowCount: 3 + ) + == .top + ) + #expect( + SidebarDropIndicatorEdge.edge( + targetedDestination: 3, + rowIndex: 2, + rowCount: 3 + ) + == .bottom + ) + } + + @Test func dropIndicatorHidesWithoutTarget() { + #expect( + SidebarDropIndicatorEdge.edge( + targetedDestination: nil, + rowIndex: 1, + rowCount: 3 + ) + == .none + ) + } +} -- 2.51.2 From ab86831c2c18c6a1db24ad719bbbd268c40e567f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 22:36:15 +0900 Subject: [PATCH 032/528] Restore sidebar drop targets during drag --- .../Views/SidebarDragSupport.swift | 80 ++++++++++++------- .../Repositories/Views/WorktreeRowsView.swift | 38 ++++++++- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index b64abbee..1357c692 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -50,6 +50,7 @@ enum SidebarDragProvider { } struct SidebarRepositoryDropDelegate: DropDelegate { + let isEnabled: Bool let destination: (DropInfo) -> Int let repositoryOrderIDs: [Repository.ID] @Binding var targetedDestination: Int? @@ -57,6 +58,10 @@ struct SidebarRepositoryDropDelegate: DropDelegate { let onDragEnded: () -> Void func dropEntered(info: DropInfo) { + guard isEnabled else { + targetedDestination = nil + return + } targetedDestination = destination(info) } @@ -65,11 +70,19 @@ struct SidebarRepositoryDropDelegate: DropDelegate { } func dropUpdated(info: DropInfo) -> DropProposal? { + guard isEnabled else { + targetedDestination = nil + return nil + } targetedDestination = destination(info) return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { + guard isEnabled else { + targetedDestination = nil + return false + } let dropDestination = destination(info) targetedDestination = nil guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { @@ -96,6 +109,7 @@ struct SidebarRepositoryDropDelegate: DropDelegate { } struct SidebarWorktreeDropDelegate: DropDelegate { + let isEnabled: Bool let destination: (DropInfo) -> Int let sectionIDs: [Worktree.ID] @Binding var targetedDestination: Int? @@ -103,6 +117,10 @@ struct SidebarWorktreeDropDelegate: DropDelegate { let onDragEnded: () -> Void func dropEntered(info: DropInfo) { + guard isEnabled else { + targetedDestination = nil + return + } targetedDestination = destination(info) } @@ -111,11 +129,19 @@ struct SidebarWorktreeDropDelegate: DropDelegate { } func dropUpdated(info: DropInfo) -> DropProposal? { + guard isEnabled else { + targetedDestination = nil + return nil + } targetedDestination = destination(info) return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { + guard isEnabled else { + targetedDestination = nil + return false + } let dropDestination = destination(info) targetedDestination = nil guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { @@ -294,22 +320,19 @@ private struct SidebarRepositoryDropTargetModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { - if isEnabled { - content.onDrop( - of: [.prowlSidebarDragPayload], - delegate: SidebarRepositoryDropDelegate( - destination: { info in - info.location.y < 24 ? index : index + 1 - }, - repositoryOrderIDs: repositoryOrderIDs, - targetedDestination: $targetedDestination, - onDrop: actions.onDrop, - onDragEnded: actions.onDragEnded - ) + content.onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarRepositoryDropDelegate( + isEnabled: isEnabled, + destination: { info in + info.location.y < 24 ? index : index + 1 + }, + repositoryOrderIDs: repositoryOrderIDs, + targetedDestination: $targetedDestination, + onDrop: actions.onDrop, + onDragEnded: actions.onDragEnded ) - } else { - content - } + ) } } @@ -322,21 +345,18 @@ private struct SidebarWorktreeDropTargetModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { - if isEnabled { - content.onDrop( - of: [.prowlSidebarDragPayload], - delegate: SidebarWorktreeDropDelegate( - destination: { info in - info.location.y < 18 ? index : index + 1 - }, - sectionIDs: rowIDs, - targetedDestination: $targetedDestination, - onDrop: actions.onDrop, - onDragEnded: actions.onDragEnded - ) + content.onDrop( + of: [.prowlSidebarDragPayload], + delegate: SidebarWorktreeDropDelegate( + isEnabled: isEnabled, + destination: { info in + info.location.y < 18 ? index : index + 1 + }, + sectionIDs: rowIDs, + targetedDestination: $targetedDestination, + onDrop: actions.onDrop, + onDragEnded: actions.onDragEnded ) - } else { - content - } + ) } } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 2999883f..c0c9197c 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -14,6 +14,7 @@ struct WorktreeRowsView: View { @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var draggingWorktreeIDs: Set = [] @State private var hoveredWorktreeID: Worktree.ID? + @State private var contextMenuHighlightedWorktreeID: Worktree.ID? @State private var targetedPinnedDropDestination: Int? @State private var targetedUnpinnedDropDestination: Int? @@ -40,6 +41,9 @@ struct WorktreeRowsView: View { shortcutIndexByID: shortcutIndexByID ) .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) + .onReceive(NotificationCenter.default.publisher(for: NSMenu.didEndTrackingNotification)) { _ in + contextMenuHighlightedWorktreeID = nil + } } @ViewBuilder @@ -134,9 +138,13 @@ struct WorktreeRowsView: View { .contentShape(Rectangle()) Group { if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { - baseRow.contextMenu { - rowContextMenu(worktree: worktree, row: row) - } + baseRow + .contextMenu { + ContextMenuHighlightActivator { + scheduleContextMenuHighlight(for: row.id) + } + rowContextMenu(worktree: worktree, row: row) + } } else { baseRow } @@ -324,6 +332,7 @@ struct WorktreeRowsView: View { private func worktreeRowView(_ row: WorktreeRowModel, config: WorktreeRowViewConfig) -> some View { let isSelected = selectedWorktreeIDs.contains(row.id) + let showsContextMenuHighlight = contextMenuHighlightedWorktreeID == row.id && !isSelected let taskStatus = terminalManager.taskStatus(for: row.id) let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty @@ -359,10 +368,24 @@ struct WorktreeRowsView: View { .padding(.horizontal, 6) } } + .overlay { + if showsContextMenuHighlight { + RoundedRectangle(cornerRadius: 5) + .stroke(Color.accentColor, lineWidth: 2) + .padding(.horizontal, 6) + } + } .transition(.opacity) .moveDisabled(config.moveDisabled) } + private func scheduleContextMenuHighlight(for worktreeID: Worktree.ID) { + guard !selectedWorktreeIDs.contains(worktreeID) else { return } + Task { @MainActor in + contextMenuHighlightedWorktreeID = worktreeID + } + } + @ViewBuilder private func rowContextMenu(worktree: Worktree, row: WorktreeRowModel) -> some View { let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display @@ -495,3 +518,12 @@ struct WorktreeRowsView: View { return row.name } } + +private struct ContextMenuHighlightActivator: View { + let activate: () -> Void + + var body: some View { + EmptyView() + .onAppear(perform: activate) + } +} -- 2.51.2 From 261e54adc8d59de43f069a34e479070f3adfafc3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 23:04:20 +0900 Subject: [PATCH 033/528] Clear sidebar drag state synchronously --- .../Views/SidebarDragSupport.swift | 71 ++++++++++++------- .../Repositories/Views/SidebarListView.swift | 19 +++-- .../Repositories/Views/WorktreeRowsView.swift | 42 +++++++++-- 3 files changed, 98 insertions(+), 34 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index 1357c692..6a333743 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -54,8 +54,7 @@ struct SidebarRepositoryDropDelegate: DropDelegate { let destination: (DropInfo) -> Int let repositoryOrderIDs: [Repository.ID] @Binding var targetedDestination: Int? - let onDrop: (IndexSet, Int) -> Void - let onDragEnded: () -> Void + let actions: SidebarDropTargetActions func dropEntered(info: DropInfo) { guard isEnabled else { @@ -85,27 +84,40 @@ struct SidebarRepositoryDropDelegate: DropDelegate { } let dropDestination = destination(info) targetedDestination = nil + if let repositoryID = actions.draggedItemID { + return performDrop(repositoryID: repositoryID, dropDestination: dropDestination) + } guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { - onDragEnded() + actions.onDragEnded() return false } provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in guard let data, - let repositoryID = SidebarDragProvider.repositoryID(from: data), - let source = repositoryOrderIDs.firstIndex(of: repositoryID), - source != dropDestination, - source + 1 != dropDestination + let repositoryID = SidebarDragProvider.repositoryID(from: data) else { - Task { @MainActor in onDragEnded() } + Task { @MainActor in actions.onDragEnded() } return } Task { @MainActor in - onDrop(IndexSet(integer: source), dropDestination) - onDragEnded() + _ = performDrop(repositoryID: repositoryID, dropDestination: dropDestination) } } return true } + + @MainActor + private func performDrop(repositoryID: Repository.ID, dropDestination: Int) -> Bool { + guard let source = repositoryOrderIDs.firstIndex(of: repositoryID), + source != dropDestination, + source + 1 != dropDestination + else { + actions.onDragEnded() + return false + } + actions.onDrop(IndexSet(integer: source), dropDestination) + actions.onDragEnded() + return true + } } struct SidebarWorktreeDropDelegate: DropDelegate { @@ -113,8 +125,7 @@ struct SidebarWorktreeDropDelegate: DropDelegate { let destination: (DropInfo) -> Int let sectionIDs: [Worktree.ID] @Binding var targetedDestination: Int? - let onDrop: (IndexSet, Int) -> Void - let onDragEnded: () -> Void + let actions: SidebarDropTargetActions func dropEntered(info: DropInfo) { guard isEnabled else { @@ -144,27 +155,40 @@ struct SidebarWorktreeDropDelegate: DropDelegate { } let dropDestination = destination(info) targetedDestination = nil + if let worktreeID = actions.draggedItemID { + return performDrop(worktreeID: worktreeID, dropDestination: dropDestination) + } guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { - onDragEnded() + actions.onDragEnded() return false } provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in guard let data, - let worktreeID = SidebarDragProvider.worktreeID(from: data), - let source = sectionIDs.firstIndex(of: worktreeID), - source != dropDestination, - source + 1 != dropDestination + let worktreeID = SidebarDragProvider.worktreeID(from: data) else { - Task { @MainActor in onDragEnded() } + Task { @MainActor in actions.onDragEnded() } return } Task { @MainActor in - onDrop(IndexSet(integer: source), dropDestination) - onDragEnded() + _ = performDrop(worktreeID: worktreeID, dropDestination: dropDestination) } } return true } + + @MainActor + private func performDrop(worktreeID: Worktree.ID, dropDestination: Int) -> Bool { + guard let source = sectionIDs.firstIndex(of: worktreeID), + source != dropDestination, + source + 1 != dropDestination + else { + actions.onDragEnded() + return false + } + actions.onDrop(IndexSet(integer: source), dropDestination) + actions.onDragEnded() + return true + } } struct SidebarDropIndicator: View { @@ -211,6 +235,7 @@ enum SidebarDropIndicatorEdge: Equatable { } struct SidebarDropTargetActions { + var draggedItemID: String? let onDrop: (IndexSet, Int) -> Void let onDragEnded: () -> Void } @@ -329,8 +354,7 @@ private struct SidebarRepositoryDropTargetModifier: ViewModifier { }, repositoryOrderIDs: repositoryOrderIDs, targetedDestination: $targetedDestination, - onDrop: actions.onDrop, - onDragEnded: actions.onDragEnded + actions: actions ) ) } @@ -354,8 +378,7 @@ private struct SidebarWorktreeDropTargetModifier: ViewModifier { }, sectionIDs: rowIDs, targetedDestination: $targetedDestination, - onDrop: actions.onDrop, - onDragEnded: actions.onDragEnded + actions: actions ) ) } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 295be16e..10f70901 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -35,6 +35,7 @@ struct SidebarListView: View { let terminalManager: WorktreeTerminalManager @FocusState private var isSidebarFocused: Bool @State private var isDragActive = false + @State private var draggingRepositoryID: Repository.ID? @State private var targetedRepositoryDropDestination: Int? var body: some View { @@ -204,7 +205,9 @@ struct SidebarListView: View { .draggableRepository( id: model.repositoryID, isEnabled: !model.isRemoving, - beginDrag: beginSidebarDrag + beginDrag: { + beginSidebarDrag(repositoryID: model.repositoryID) + } ) } @@ -233,7 +236,9 @@ struct SidebarListView: View { .draggableRepository( id: model.id, isEnabled: model.isReorderable, - beginDrag: beginSidebarDrag + beginDrag: { + beginSidebarDrag(repositoryID: model.id) + } ) case .listHeader, .archivedWorktrees: @@ -246,28 +251,34 @@ struct SidebarListView: View { isEnabled: isDragActive, targetedDestination: $targetedRepositoryDropDestination, actions: SidebarDropTargetActions( + draggedItemID: draggingRepositoryID, onDrop: { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) + withAnimation(.easeOut(duration: 0.2)) { + _ = store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) + } }, onDragEnded: endSidebarDrag ) ) } - private func beginSidebarDrag() { + private func beginSidebarDrag(repositoryID: Repository.ID) { guard !isDragActive else { return } + draggingRepositoryID = repositoryID isDragActive = true store.send(.worktreeOrdering(.setSidebarDragActive(true))) } private func endSidebarDrag() { targetedRepositoryDropDestination = nil + draggingRepositoryID = nil isDragActive = false store.send(.worktreeOrdering(.setSidebarDragActive(false))) } private func resetSidebarDrag() { targetedRepositoryDropDestination = nil + draggingRepositoryID = nil isDragActive = false store.send(.worktreeOrdering(.setSidebarDragActive(false))) } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index c0c9197c..76b756a0 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -107,6 +107,7 @@ struct WorktreeRowsView: View { isEnabled: isWorktreeDragActive, targetedDestination: targetedDestination, actions: SidebarDropTargetActions( + draggedItemID: draggingWorktreeIDs.first, onDrop: { offsets, destination in moveWorktrees(section: section, offsets: offsets, destination: destination) }, @@ -139,10 +140,12 @@ struct WorktreeRowsView: View { Group { if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { baseRow - .contextMenu { - ContextMenuHighlightActivator { + .overlay { + ContextMenuActivationOverlay { scheduleContextMenuHighlight(for: row.id) } + } + .contextMenu { rowContextMenu(worktree: worktree, row: row) } } else { @@ -519,11 +522,38 @@ struct WorktreeRowsView: View { } } -private struct ContextMenuHighlightActivator: View { +private struct ContextMenuActivationOverlay: NSViewRepresentable { let activate: () -> Void - var body: some View { - EmptyView() - .onAppear(perform: activate) + func makeNSView(context: Context) -> RightClickForwardingView { + let view = RightClickForwardingView() + view.activate = activate + return view + } + + func updateNSView(_ nsView: RightClickForwardingView, context: Context) { + nsView.activate = activate + } + + final class RightClickForwardingView: NSView { + var activate: (() -> Void)? + private var isForwardingRightClick = false + + override func hitTest(_ point: NSPoint) -> NSView? { + guard !isForwardingRightClick, + NSApp.currentEvent?.type == .rightMouseDown + else { + return nil + } + return bounds.contains(point) ? self : nil + } + + override func rightMouseDown(with event: NSEvent) { + activate?() + guard let window else { return } + isForwardingRightClick = true + window.sendEvent(event) + isForwardingRightClick = false + } } } -- 2.51.2 From a391f42f9ce789d29e72057fefcda7db8d76c4bb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 3 May 2026 23:09:53 +0900 Subject: [PATCH 034/528] End sidebar drag before applying drop --- supacode/Features/Repositories/Views/SidebarDragSupport.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarDragSupport.swift b/supacode/Features/Repositories/Views/SidebarDragSupport.swift index 6a333743..05ad6c01 100644 --- a/supacode/Features/Repositories/Views/SidebarDragSupport.swift +++ b/supacode/Features/Repositories/Views/SidebarDragSupport.swift @@ -114,8 +114,8 @@ struct SidebarRepositoryDropDelegate: DropDelegate { actions.onDragEnded() return false } - actions.onDrop(IndexSet(integer: source), dropDestination) actions.onDragEnded() + actions.onDrop(IndexSet(integer: source), dropDestination) return true } } @@ -185,8 +185,8 @@ struct SidebarWorktreeDropDelegate: DropDelegate { actions.onDragEnded() return false } - actions.onDrop(IndexSet(integer: source), dropDestination) actions.onDragEnded() + actions.onDrop(IndexSet(integer: source), dropDestination) return true } } -- 2.51.2 From d7ccc545f477d5dd6adb6ce44d4061e7b8a0ed99 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:06:37 +0900 Subject: [PATCH 035/528] Align repository list header inset --- supacode/Features/Repositories/Views/SidebarListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 10f70901..db4a6672 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -173,6 +173,7 @@ struct SidebarListView: View { } } .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) + .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 4) } -- 2.51.2 From 54bf5e15e846b276f8a5acdc58e3d302a6fc8e31 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:08:06 +0900 Subject: [PATCH 036/528] Indent worktree rows in repository sections --- supacode/Features/Repositories/Views/WorktreeRowsView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 76b756a0..8c88bd74 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -363,7 +363,8 @@ struct WorktreeRowsView: View { .tag(SidebarSelection.worktree(row.id)) .id(SidebarScrollID.worktree(row.id)) .typeSelectEquivalent("") - .padding(.horizontal, 8) + .padding(.leading, 20) + .padding(.trailing, 8) .background { if isSelected { RoundedRectangle(cornerRadius: 5) -- 2.51.2 From a07ad9f4800320b8764d9348905ef839a3265c62 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:10:11 +0900 Subject: [PATCH 037/528] Tune worktree row indentation --- supacode/Features/Repositories/Views/WorktreeRowsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 8c88bd74..1889c27b 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -363,7 +363,7 @@ struct WorktreeRowsView: View { .tag(SidebarSelection.worktree(row.id)) .id(SidebarScrollID.worktree(row.id)) .typeSelectEquivalent("") - .padding(.leading, 20) + .padding(.leading, 14) .padding(.trailing, 8) .background { if isSelected { -- 2.51.2 From 7dfb6a339fcf236f061210697e4f4e64a2e75bac Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:12:21 +0900 Subject: [PATCH 038/528] Tune repository list header spacing --- supacode/Features/Repositories/Views/SidebarListView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index db4a6672..b9241290 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -173,8 +173,9 @@ struct SidebarListView: View { } } .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) - .padding(.horizontal, 12) - .padding(.top, 8) + .padding(.leading, 12) + .padding(.trailing, 4) + .padding(.top, 2) .padding(.bottom, 4) } -- 2.51.2 From 9e7b1eefe20d58b932a4a98b95a12a0f33b5008c Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:14:29 +0900 Subject: [PATCH 039/528] Adjust repository header trailing inset --- supacode/Features/Repositories/Views/SidebarListView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index b9241290..40ec52c1 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -174,7 +174,7 @@ struct SidebarListView: View { } .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) .padding(.leading, 12) - .padding(.trailing, 4) + .padding(.trailing, 8) .padding(.top, 2) .padding(.bottom, 4) } -- 2.51.2 From 8c38e2753907edc661c70969307dc0bdbe15e278 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 00:22:38 +0900 Subject: [PATCH 040/528] Tune sidebar header and icon tint --- .../Features/Repositories/Views/RepositorySectionView.swift | 2 +- supacode/Features/Repositories/Views/SidebarListView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 717ca93c..4b13eb6c 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -49,7 +49,7 @@ struct RepositorySectionView: View { customTitle: store.repositoryCustomTitles[repository.id], isRemoving: isRemovingRepository, icon: appearance.icon, - iconTint: appearance.color?.color, + iconTint: appearance.color?.color ?? .accentColor, repositoryRootURL: repository.rootURL, nameTooltip: repository.capabilities.supportsWorktrees ? (isExpanded ? "Collapse" : "Expand") diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 40ec52c1..cbc21a24 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -174,7 +174,7 @@ struct SidebarListView: View { } .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) .padding(.leading, 12) - .padding(.trailing, 8) + .padding(.trailing, 7) .padding(.top, 2) .padding(.bottom, 4) } -- 2.51.2 From b92ead51a42b5655b2ad2e265771247bda55416b Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 20:02:16 +0900 Subject: [PATCH 041/528] Dim unfocused split panes Adds a Ghostty-style dim overlay on inactive split panes so the focused one stands out, with a Settings toggle (Appearance > Splits) defaulting to on. Light mode uses a lighter tint than dark to avoid washing the pane in grey, and the split divider switches to NSColor.separatorColor for a more neutral look. --- .../Canvas/Views/CanvasCardView.swift | 12 +++- .../Features/Canvas/Views/CanvasView.swift | 1 + .../Settings/Models/GlobalSettings.swift | 13 +++- .../Settings/Reducer/SettingsFeature.swift | 6 +- .../Views/AppearanceSettingsView.swift | 7 ++ .../Shelf/Views/ShelfOpenBookView.swift | 5 +- .../Views/TerminalSplitTreeView.swift | 68 +++++++++++++++++-- .../Views/WorktreeTerminalTabsView.swift | 5 +- 8 files changed, 102 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 14dc47bc..2e57b798 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -16,6 +16,7 @@ struct CanvasCardView: View { /// PNG/SVG filenames against the per-repo icons directory. var repositoryRootURL: URL? let tree: SplitTree + let focusedSurfaceID: UUID? let isFocused: Bool let isSelected: Bool let hasUnseenNotification: Bool @@ -225,9 +226,14 @@ struct CanvasCardView: View { } private var terminalContent: some View { - TerminalSplitTreeView(tree: tree, pinnedSize: cardSize, action: onSplitOperation) - .frame(width: cardSize.width, height: cardSize.height) - .allowsHitTesting(isFocused && !showsSelectionShield) + TerminalSplitTreeView( + tree: tree, + pinnedSize: cardSize, + focusedSurfaceID: focusedSurfaceID, + action: onSplitOperation + ) + .frame(width: cardSize.width, height: cardSize.height) + .allowsHitTesting(isFocused && !showsSelectionShield) } private var selectionShield: some View { diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index f9895643..a2e95992 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -98,6 +98,7 @@ struct CanvasView: View { repositoryColor: repositoryAppearance.color?.color, repositoryRootURL: state.repositoryRootURL, tree: tree, + focusedSurfaceID: state.focusedSurfaceId(in: tab.id), isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 29d703d1..9a43b675 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -27,6 +27,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var archivedAutoDeletePeriod: AutoDeletePeriod? var keybindingUserOverrides: KeybindingUserOverrideStore var defaultViewMode: DefaultViewMode + var dimUnfocusedSplits: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -56,7 +57,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { archivedAutoDeletePeriod: nil, terminalFontSize: nil, keybindingUserOverrides: .empty, - defaultViewMode: .normal + defaultViewMode: .normal, + dimUnfocusedSplits: true ) init( @@ -87,7 +89,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { archivedAutoDeletePeriod: AutoDeletePeriod? = nil, terminalFontSize: Float32? = nil, keybindingUserOverrides: KeybindingUserOverrideStore = .empty, - defaultViewMode: DefaultViewMode = .normal + defaultViewMode: DefaultViewMode = .normal, + dimUnfocusedSplits: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -117,6 +120,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.terminalFontSize = terminalFontSize self.keybindingUserOverrides = keybindingUserOverrides self.defaultViewMode = defaultViewMode + self.dimUnfocusedSplits = dimUnfocusedSplits } func encode(to encoder: any Encoder) throws { @@ -149,6 +153,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encodeIfPresent(terminalFontSize, forKey: .terminalFontSize) try container.encode(keybindingUserOverrides, forKey: .keybindingUserOverrides) try container.encode(defaultViewMode, forKey: .defaultViewMode) + try container.encode(dimUnfocusedSplits, forKey: .dimUnfocusedSplits) } private enum CodingKeys: String, CodingKey { @@ -180,6 +185,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case terminalFontSize case keybindingUserOverrides case defaultViewMode + case dimUnfocusedSplits // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -272,5 +278,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { defaultViewMode = try container.decodeIfPresent(DefaultViewMode.self, forKey: .defaultViewMode) ?? Self.default.defaultViewMode + dimUnfocusedSplits = + try container.decodeIfPresent(Bool.self, forKey: .dimUnfocusedSplits) + ?? Self.default.dimUnfocusedSplits } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 2a99533e..f12d22e1 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -33,6 +33,7 @@ struct SettingsFeature { var terminalFontSize: Float32? var keybindingUserOverrides: KeybindingUserOverrideStore var defaultViewMode: DefaultViewMode + var dimUnfocusedSplits: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true var selection: SettingsSection? = .general @@ -70,6 +71,7 @@ struct SettingsFeature { terminalFontSize = settings.terminalFontSize keybindingUserOverrides = settings.keybindingUserOverrides defaultViewMode = settings.defaultViewMode + dimUnfocusedSplits = settings.dimUnfocusedSplits } var globalSettings: GlobalSettings { @@ -103,7 +105,8 @@ struct SettingsFeature { archivedAutoDeletePeriod: archivedAutoDeletePeriod, terminalFontSize: terminalFontSize, keybindingUserOverrides: keybindingUserOverrides, - defaultViewMode: defaultViewMode + defaultViewMode: defaultViewMode, + dimUnfocusedSplits: dimUnfocusedSplits ) } } @@ -204,6 +207,7 @@ struct SettingsFeature { state.terminalFontSize = normalizedSettings.terminalFontSize state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides state.defaultViewMode = normalizedSettings.defaultViewMode + state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 5d833a31..fadb84b5 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -46,6 +46,13 @@ struct AppearanceSettingsView: View { .font(.footnote) .foregroundStyle(.secondary) } + Section("Splits") { + Toggle( + "Dim unfocused split panes", + isOn: $store.dimUnfocusedSplits + ) + .help("Fade split panes that aren't focused so the active one stands out.") + } Section("Default View") { Picker("Launch in", selection: $store.defaultViewMode) { ForEach(DefaultViewMode.allCases) { mode in diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index 46cf261f..2bd0692d 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -25,7 +25,10 @@ struct ShelfOpenBookView: View { Group { if let selectedId = state.tabManager.selectedTabId { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer(tree: state.splitTree(for: tabId)) { operation in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + focusedSurfaceID: state.focusedSurfaceId(in: tabId) + ) { operation in state.performSplitOperation(operation, in: tabId) } } diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 27adfb98..4724dbff 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -1,10 +1,12 @@ import AppKit +import Sharing import SwiftUI import UniformTypeIdentifiers struct TerminalSplitTreeView: View { let tree: SplitTree var pinnedSize: CGSize? + var focusedSurfaceID: UUID? let action: (Operation) -> Void private static let dragType = UTType(exportedAs: "com.onevcat.prowl.ghosttySurfaceId") @@ -23,8 +25,14 @@ struct TerminalSplitTreeView: View { var body: some View { if let node = tree.visibleNode { - SubtreeView(node: node, isRoot: node == tree.root, pinnedSize: pinnedSize, action: action) - .id(node.structuralIdentity) + SubtreeView( + node: node, + isRoot: node == tree.root, + pinnedSize: pinnedSize, + focusedSurfaceID: focusedSurfaceID, + action: action + ) + .id(node.structuralIdentity) } } @@ -38,12 +46,19 @@ struct TerminalSplitTreeView: View { let node: SplitTree.Node var isRoot: Bool = false var pinnedSize: CGSize? + var focusedSurfaceID: UUID? let action: (Operation) -> Void var body: some View { switch node { case .leaf(let leafView): - LeafView(surfaceView: leafView, isSplit: !isRoot, pinnedSize: pinnedSize, action: action) + LeafView( + surfaceView: leafView, + isSplit: !isRoot, + isFocused: leafView.id == focusedSurfaceID, + pinnedSize: pinnedSize, + action: action + ) case .split(let split): let splitViewDirection: SplitView.Direction = switch split.direction { @@ -63,13 +78,23 @@ struct TerminalSplitTreeView: View { set: { action(.resize(node: node, ratio: Double($0))) }), - dividerColor: .secondary, + dividerColor: Color(nsColor: .separatorColor), resizeIncrements: .init(width: 1, height: 1), left: { - SubtreeView(node: split.left, pinnedSize: leftPinned, action: action) + SubtreeView( + node: split.left, + pinnedSize: leftPinned, + focusedSurfaceID: focusedSurfaceID, + action: action + ) }, right: { - SubtreeView(node: split.right, pinnedSize: rightPinned, action: action) + SubtreeView( + node: split.right, + pinnedSize: rightPinned, + focusedSurfaceID: focusedSurfaceID, + action: action + ) }, onEqualize: { action(.equalize) @@ -93,15 +118,37 @@ struct TerminalSplitTreeView: View { struct LeafView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool + var isFocused: Bool = true var pinnedSize: CGSize? let action: (Operation) -> Void @State private var dropState: DropState = .idle + @Shared(.settingsFile) private var settingsFile: SettingsFile + @Environment(\.colorScheme) private var colorScheme + + private var shouldDim: Bool { + isSplit && !isFocused && settingsFile.global.dimUnfocusedSplits + } + + /// Lighter tint in light mode so the inactive pane reads as faded + /// rather than washed in grey; dark mode's deeper tint matches the + /// effect users see in Ghostty.app. + private var dimOpacity: Double { + colorScheme == .dark ? 0.3 : 0.12 + } var body: some View { GeometryReader { geometry in GhosttyTerminalView(surfaceView: surfaceView, pinnedSize: pinnedSize) .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + // Mirrors Ghostty's `unfocused-split-fill`/`unfocused-split-opacity`: + // a translucent black tint fades the inactive pane. + Color.black + .opacity(shouldDim ? dimOpacity : 0) + .allowsHitTesting(false) + .animation(.easeOut(duration: 0.12), value: shouldDim) + } .overlay(alignment: .top) { GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state) } @@ -299,6 +346,7 @@ struct TerminalSplitTreeView: View { /// list of terminal panes to assistive technologies. struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree + var focusedSurfaceID: UUID? let action: (TerminalSplitTreeView.Operation) -> Void func makeNSView(context: Context) -> TerminalSplitAXContainerView { @@ -307,7 +355,13 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { nsView.update( - rootView: AnyView(TerminalSplitTreeView(tree: tree, action: action)), + rootView: AnyView( + TerminalSplitTreeView( + tree: tree, + focusedSurfaceID: focusedSurfaceID, + action: action + ) + ), panes: tree.visibleLeaves() ) } diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 2de65709..0fe5ef13 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -43,7 +43,10 @@ struct WorktreeTerminalTabsView: View { ) if let selectedId = state.tabManager.selectedTabId { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer(tree: state.splitTree(for: tabId)) { operation in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + focusedSurfaceID: state.focusedSurfaceId(in: tabId) + ) { operation in state.performSplitOperation(operation, in: tabId) } } -- 2.51.2 From af84ac3b371d53e29dfe7fe1f8926d2e4a70db72 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 20:45:37 +0900 Subject: [PATCH 042/528] bump v2026.5.4 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 6297b677..9c06a93d 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260430; + CURRENT_PROJECT_VERSION = 20260504; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.30; + MARKETING_VERSION = 2026.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260430; + CURRENT_PROJECT_VERSION = 20260504; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.4.30; + MARKETING_VERSION = 2026.5.4; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260430; + CURRENT_PROJECT_VERSION = 20260504; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.30; + MARKETING_VERSION = 2026.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260430; + CURRENT_PROJECT_VERSION = 20260504; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.4.30; + MARKETING_VERSION = 2026.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index ac629973..cfca3ff3 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.4.30" + public static let current = "2026.5.4" } -- 2.51.2 From 3df443493ca81929fb13e8b3a5b92073f199972e Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 20:45:38 +0900 Subject: [PATCH 043/528] Update CHANGELOG for 2026.5.4 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4204a609..2d1bda30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2026.5.4](https://github.com/onevcat/Prowl/releases/tag/v2026.5.4) + +This release brings visual polish to split panes and the sidebar, along with a fix for keyboard-driven tab closing. + +### New + +- Inactive split panes now dim slightly so the focused pane stands out at a glance. The effect adapts to light and dark appearance — stronger in dark mode, subtler in light mode. To disable it, go to Settings → Appearance → Splits and turn off "Dim unfocused split panes". + +### Improved + +- The sidebar now groups each repository and its worktrees as a cohesive visual unit. Worktree rows are indented under their parent repository, and drag-and-drop indicators are drawn at repository boundaries for clearer reordering feedback. +- The split divider between panes uses a softer separator color, reducing visual noise alongside the new dim treatment. + +### Fixed + +- Cmd-W now correctly closes a terminal tab when the default Ghostty keybinding for close-tab is not set. + ## [2026.4.30](https://github.com/onevcat/Prowl/releases/tag/v2026.4.30) ### New -- 2.51.2 From 43be278d0106e392a6222eb438a94023d343e8da Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 4 May 2026 23:52:36 +0900 Subject: [PATCH 044/528] Add inline + button to sidebar Repositories header Always show the "Repositories" header so a single + button can live next to the existing expand/collapse chevron, replacing the bottom "Add Repository" button. Render an empty-state hint with a pulsing arrow.turn.up.right that points at the + when no repositories are loaded, and update the main empty state copy to match. --- .../Models/SidebarPresentation.swift | 2 +- .../Repositories/Views/EmptyStateView.swift | 4 +-- .../Views/SidebarFooterView.swift | 24 ------------- .../Repositories/Views/SidebarListView.swift | 35 ++++++++++++++++++- .../RepositorySectionViewTests.swift | 6 ++-- supacodeTests/SidebarPresentationTests.swift | 19 ++++++---- 6 files changed, 53 insertions(+), 37 deletions(-) diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift index a211cbbc..44617867 100644 --- a/supacode/Features/Repositories/Models/SidebarPresentation.swift +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -4,7 +4,7 @@ struct SidebarPresentation: Equatable { var items: [SidebarItem] static func showsListHeader(repositoryCount: Int) -> Bool { - repositoryCount > 10 + true } var repositoryOrderIDs: [Repository.ID] { diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index ef7053cc..35f62e54 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -38,8 +38,8 @@ struct EmptyStateView: View { private func promptText(shortcutDisplay: String?) -> String { if let shortcutDisplay { - return "Press \(shortcutDisplay) or click Open Repository to choose a folder." + return "Press \(shortcutDisplay) or click Open Repository to add a repository." } - return "Click Open Repository to choose a folder." + return "Click Open Repository to add a repository." } } diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 86cbf140..e8eccdd8 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -5,30 +5,10 @@ struct SidebarFooterView: View { let store: StoreOf @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity @Environment(\.openURL) private var openURL - @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { HStack { - Button { - store.send(.setOpenPanelPresented(true)) - } label: { - HStack(spacing: 6) { - Label("Add Repository", systemImage: "folder.badge.plus") - .font(.callout) - if commandKeyObserver.isPressed, - let shortcut = shortcutDisplay(for: AppShortcuts.CommandID.openRepository) - { - ShortcutHintView(text: shortcut, color: .secondary) - } - } - } - .help( - AppShortcuts.helpText( - title: "Add Repository", - commandID: AppShortcuts.CommandID.openRepository, - in: resolvedKeybindings - )) Spacer() Menu { Button("Homepage", systemImage: "house") { @@ -110,8 +90,4 @@ struct SidebarFooterView: View { Divider() } } - - private func shortcutDisplay(for commandID: String) -> String? { - AppShortcuts.display(for: commandID, in: resolvedKeybindings) - } } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index cbc21a24..690cf6e4 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -67,6 +67,10 @@ struct SidebarListView: View { ) } + if repositoryItems.isEmpty { + emptyRepositoryHint() + } + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in repositoryItemView( item, @@ -145,11 +149,22 @@ struct SidebarListView: View { action: RepositoryListHeaderAction, expandableRepositoryIDs: Set ) -> some View { - HStack(spacing: 8) { + HStack(spacing: 4) { Text("Repositories") .font(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) + Button { + store.send(.setOpenPanelPresented(true)) + } label: { + Label("Add Repository", systemImage: "plus") + .labelStyle(.iconOnly) + .frame(width: 20, height: 20) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Add Repository") if !expandableRepositoryIDs.isEmpty { Button { withAnimation(.easeOut(duration: 0.2)) { @@ -179,6 +194,24 @@ struct SidebarListView: View { .padding(.bottom, 4) } + private func emptyRepositoryHint() -> some View { + HStack(spacing: 6) { + Spacer(minLength: 0) + Text("Add your first repository") + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: "arrow.turn.up.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .symbolEffect(.pulse, options: .repeating) + .accessibilityHidden(true) + } + .padding(.leading, 12) + .padding(.trailing, 14) + .padding(.top, 2) + .padding(.bottom, 6) + } + @ViewBuilder private func repositoryItemView( _ item: SidebarItem, diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index 885472dd..cbdd7032 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -63,9 +63,9 @@ struct RepositorySectionViewTests { ) } - @Test func sidebarHeaderOnlyShowsForLongRepositoryLists() { - #expect(!SidebarListView.showsRepositoryListHeader(repositoryCount: 0)) - #expect(!SidebarListView.showsRepositoryListHeader(repositoryCount: 10)) + @Test func sidebarHeaderAlwaysShows() { + #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 0)) + #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 1)) #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) } diff --git a/supacodeTests/SidebarPresentationTests.swift b/supacodeTests/SidebarPresentationTests.swift index bfc6df66..d61b60f4 100644 --- a/supacodeTests/SidebarPresentationTests.swift +++ b/supacodeTests/SidebarPresentationTests.swift @@ -15,8 +15,9 @@ struct SidebarPresentationTests { let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) - #expect(presentation.items.count == 1) - guard case .repository(let model) = presentation.items.first else { + let repositoryItems = presentation.repositoryRowItems + #expect(repositoryItems.count == 1) + guard case .repository(let model) = repositoryItems.first else { Issue.record("Expected repository container") return } @@ -34,7 +35,7 @@ struct SidebarPresentationTests { let presentation = state.sidebarPresentation(expandedRepositoryIDs: []) - guard case .repository(let model) = presentation.items.first else { + guard case .repository(let model) = presentation.repositoryRowItems.first else { Issue.record("Expected repository container") return } @@ -59,7 +60,7 @@ struct SidebarPresentationTests { presentation.repositoryOrderAfterMove(fromOffsets: IndexSet(integer: 0), toOffset: 2) == [ repoA.id, "/tmp/missing", ]) - guard case .failedRepository(let failed) = presentation.items.first else { + guard case .failedRepository(let failed) = presentation.repositoryRowItems.first else { Issue.record("Expected failed repository first") return } @@ -73,7 +74,7 @@ struct SidebarPresentationTests { let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) - guard case .repository(let model) = presentation.items.first else { + guard case .repository(let model) = presentation.repositoryRowItems.first else { Issue.record("Expected repository container") return } @@ -100,7 +101,7 @@ struct SidebarPresentationTests { let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) - guard case .repository(let model) = presentation.items.first else { + guard case .repository(let model) = presentation.repositoryRowItems.first else { Issue.record("Expected repository container") return } @@ -195,3 +196,9 @@ struct SidebarPresentationTests { return state } } + +extension SidebarPresentation { + fileprivate var repositoryRowItems: [SidebarItem] { + items.filter { $0.repositoryOrderID != nil } + } +} -- 2.51.2 From bf1674d0fb475a14d0296c234663d7f8a035c488 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 5 May 2026 14:31:15 +0900 Subject: [PATCH 045/528] Polish empty-state copy and onboarding arrow direction Tighten EmptyStateView padding, rename "Open Repository" to "Add Repository" so the wording matches the sidebar action, and flip the onboarding hint arrow to arrow.turn.right.up so it points toward the + button in the header. --- .../Repositories/Views/EmptyStateView.swift | 13 ++++++++----- .../Repositories/Views/SidebarListView.swift | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 35f62e54..fc6800e8 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -7,18 +7,20 @@ struct EmptyStateView: View { var body: some View { let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) - VStack { + VStack(spacing: 16) { Image(systemName: "tray") .font(.title2) .accessibilityHidden(true) + .padding(.bottom, 4) Text("Open a repository or folder") .font(.headline) Text(promptText(shortcutDisplay: shortcutDisplay)) .font(.subheadline) .foregroundStyle(.secondary) - Button("Open Repository...") { + Button("Add Repository...") { store.send(.setOpenPanelPresented(true)) } + .padding(.top, 4) .modifier( KeyboardShortcutModifier( shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.openRepository) @@ -26,11 +28,12 @@ struct EmptyStateView: View { ) .help( AppShortcuts.helpText( - title: "Open Repository", + title: "Add Repository", commandID: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings )) } + .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .multilineTextAlignment(.center) @@ -38,8 +41,8 @@ struct EmptyStateView: View { private func promptText(shortcutDisplay: String?) -> String { if let shortcutDisplay { - return "Press \(shortcutDisplay) or click Open Repository to add a repository." + return "Press \(shortcutDisplay) or click Add Repository to add one." } - return "Click Open Repository to add a repository." + return "Click Add Repository to add one." } } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 690cf6e4..d9beccf0 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -200,7 +200,7 @@ struct SidebarListView: View { Text("Add your first repository") .font(.caption) .foregroundStyle(.secondary) - Image(systemName: "arrow.turn.up.right") + Image(systemName: "arrow.turn.right.up") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .symbolEffect(.pulse, options: .repeating) -- 2.51.2 From cb3e25d4cdf9335eaff2fc4eef37ccbcda644174 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 5 May 2026 15:24:10 +0900 Subject: [PATCH 046/528] Unify empty-state views on ContentUnavailableView Migrate the Repositories and Shelf empty states to ContentUnavailableView so they match the Archived Worktrees and Diff View placeholders. Removes two ad-hoc VStack layouts with mismatched icon sizes and spacing in favor of the platform component, which keeps typography and Dynamic Type consistent across all empty states. --- .../Repositories/Views/EmptyStateView.swift | 16 ++++------------ supacode/Features/Shelf/Views/ShelfView.swift | 16 +++++----------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index fc6800e8..8d800bbf 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -7,20 +7,14 @@ struct EmptyStateView: View { var body: some View { let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) - VStack(spacing: 16) { - Image(systemName: "tray") - .font(.title2) - .accessibilityHidden(true) - .padding(.bottom, 4) - Text("Open a repository or folder") - .font(.headline) + ContentUnavailableView { + Label("Open a repository or folder", systemImage: "tray") + } description: { Text(promptText(shortcutDisplay: shortcutDisplay)) - .font(.subheadline) - .foregroundStyle(.secondary) + } actions: { Button("Add Repository...") { store.send(.setOpenPanelPresented(true)) } - .padding(.top, 4) .modifier( KeyboardShortcutModifier( shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.openRepository) @@ -33,10 +27,8 @@ struct EmptyStateView: View { in: resolvedKeybindings )) } - .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) - .multilineTextAlignment(.center) } private func promptText(shortcutDisplay: String?) -> String { diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 1aa07110..00d15fe4 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -166,17 +166,11 @@ struct ShelfView: View { @ViewBuilder private func emptyOpenArea() -> some View { - VStack(spacing: 10) { - Image(systemName: "books.vertical") - .font(.system(size: 40)) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text("No worktree selected") - .font(.headline) - Text("Click a worktree to open it.") - .font(.callout) - .foregroundStyle(.secondary) - } + ContentUnavailableView( + "No worktree selected", + systemImage: "books.vertical", + description: Text("Click a worktree to open it.") + ) .frame(maxWidth: .infinity, maxHeight: .infinity) } -- 2.51.2 From 86a2070f7bc89d6d098788ecb675e3aa4511a4b2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 5 May 2026 15:29:46 +0900 Subject: [PATCH 047/528] Move sidebar Help menu to the left edge Place the Help (?) menu at the leading side of the sidebar footer and push refresh / archive / settings to the trailing side via a Spacer, giving the secondary informational menu its own resting spot away from the action cluster. --- supacode/Features/Repositories/Views/SidebarFooterView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index e8eccdd8..38713342 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -9,7 +9,6 @@ struct SidebarFooterView: View { var body: some View { HStack { - Spacer() Menu { Button("Homepage", systemImage: "house") { if let url = URL(string: "https://prowl.onev.cat/") { @@ -36,6 +35,7 @@ struct SidebarFooterView: View { } .menuIndicator(.hidden) .help("Help") + Spacer() Button { store.send(.refreshWorktrees) } label: { -- 2.51.2 From 2ae18071735c0ab433c05d968d0792ac16715189 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 7 May 2026 22:40:14 +0900 Subject: [PATCH 048/528] Fix Ghostty key equivalent ownership --- .../Ghostty/GhosttySurfaceView.swift | 42 +++++++++- supacodeTests/GhosttySurfaceViewTests.swift | 78 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 5746c038..8faf80ae 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1196,10 +1196,16 @@ final class GhosttySurfaceView: NSView, Identifiable { guard event.type == .keyDown else { return false } let isFontSizeShortcut = matchesFontSizeShortcut(event: event) guard let surface else { return false } - guard focused else { return false } + guard + Self.hasKeyEquivalentFocusOwnership( + cachedFocused: focused, + isActualFirstResponder: window?.firstResponder === self + ) + else { return false } if UserCustomShortcutRegistry.shared.matches(event: event), let menu = NSApp.mainMenu, + Self.mainMenuHasMatchingItem(for: event, in: menu), menu.performKeyEquivalent(with: event) { return true @@ -1208,6 +1214,7 @@ final class GhosttySurfaceView: NSView, Identifiable { if let bindingFlags = bindingFlags(for: event, surface: surface) { if shouldAttemptMenu(for: bindingFlags), let menu = NSApp.mainMenu, + Self.mainMenuHasMatchingItem(for: event, in: menu), menu.performKeyEquivalent(with: event) { return true @@ -1439,6 +1446,39 @@ final class GhosttySurfaceView: NSView, Identifiable { performBindingAction("prompt_surface_title") } + static func hasKeyEquivalentFocusOwnership( + cachedFocused: Bool, + isActualFirstResponder: Bool + ) -> Bool { + cachedFocused && isActualFirstResponder + } + + static func mainMenuHasMatchingItem(for event: NSEvent, in menu: NSMenu) -> Bool { + guard let characters = event.charactersIgnoringModifiers?.lowercased(), !characters.isEmpty else { return false } + let shortcutMask: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + let eventModifiers = event.modifierFlags.intersection(shortcutMask) + + for item in menu.items { + if let submenu = item.submenu, mainMenuHasMatchingItem(for: event, in: submenu) { + return true + } + + guard !item.keyEquivalent.isEmpty else { continue } + let itemKey = item.keyEquivalent + guard itemKey.lowercased() == characters else { continue } + + var itemModifiers = item.keyEquivalentModifierMask.intersection(shortcutMask) + if itemKey != itemKey.lowercased() { + itemModifiers.insert(.shift) + } + if itemModifiers == eventModifiers { + return true + } + } + + return false + } + private func shouldAttemptMenu(for flags: ghostty_binding_flags_e) -> Bool { if bridge.state.keySequenceActive == true { return false } if bridge.state.keyTableDepth > 0 { return false } diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index b18881b4..62f08942 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -7,6 +7,64 @@ import Testing @MainActor struct GhosttySurfaceViewTests { + @Test func mainMenuExactMatchRejectsShiftVariantOfCommandComma() { + let menu = NSMenu() + let item = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",") + item.keyEquivalentModifierMask = [.command] + menu.addItem(item) + + let event = makeKeyEvent( + characters: "<", + charactersIgnoringModifiers: ",", + modifiers: [.command, .shift], + keyCode: 43 + ) + + #expect(!GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) + } + + @Test func mainMenuExactMatchAcceptsExactCommandComma() { + let menu = NSMenu() + let item = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",") + item.keyEquivalentModifierMask = [.command] + menu.addItem(item) + + let event = makeKeyEvent( + characters: ",", + charactersIgnoringModifiers: ",", + modifiers: [.command], + keyCode: 43 + ) + + #expect(GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) + } + + @Test func mainMenuExactMatchFindsSubmenuItems() { + let menu = NSMenu() + let parent = NSMenuItem(title: "View", action: nil, keyEquivalent: "") + let submenu = NSMenu() + let item = NSMenuItem(title: "Show Diff", action: nil, keyEquivalent: "y") + item.keyEquivalentModifierMask = [.command, .shift] + submenu.addItem(item) + parent.submenu = submenu + menu.addItem(parent) + + let event = makeKeyEvent( + characters: "Y", + charactersIgnoringModifiers: "y", + modifiers: [.command, .shift], + keyCode: 16 + ) + + #expect(GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) + } + + @Test func keyEquivalentFocusOwnershipRequiresActualFirstResponder() { + #expect(GhosttySurfaceView.hasKeyEquivalentFocusOwnership(cachedFocused: true, isActualFirstResponder: true)) + #expect(!GhosttySurfaceView.hasKeyEquivalentFocusOwnership(cachedFocused: true, isActualFirstResponder: false)) + #expect(!GhosttySurfaceView.hasKeyEquivalentFocusOwnership(cachedFocused: false, isActualFirstResponder: true)) + } + @Test func occlusionStateResendsDesiredValueAfterAttachmentChange() { var state = GhosttySurfaceView.OcclusionState() @@ -371,4 +429,24 @@ struct GhosttySurfaceViewTests { #expect(!suppression.suppresses(keyCode: 49, timestamp: 11.1)) #expect(suppression.isExpired(at: 11.1)) } + + private func makeKeyEvent( + characters: String, + charactersIgnoringModifiers: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16 + ) -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 1, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, + isARepeat: false, + keyCode: keyCode + )! + } } -- 2.51.2 From 0fa828aaba7ca2889e2efe68dc2721c24f502197 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 7 May 2026 23:03:15 +0900 Subject: [PATCH 049/528] Fix GitHub PR repo resolution --- supacode/Clients/Github/GithubCLIClient.swift | 78 ++++++++++--- .../GithubGraphQLPullRequestResponse.swift | 27 ++++- ...epositoriesFeature+GithubIntegration.swift | 37 +++++- .../Reducer/RepositoriesFeature.swift | 8 +- .../GithubBatchPullRequestsTests.swift | 59 ++++++++-- supacodeTests/GithubCLIClientTests.swift | 89 ++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 109 +++++++++++++++++- 7 files changed, 374 insertions(+), 33 deletions(-) diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 5d9e7d84..e740e6ba 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -42,11 +42,12 @@ extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String + var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] - var mergePullRequest: @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void - var closePullRequest: @Sendable (URL, Int) async throws -> Void - var markPullRequestReady: @Sendable (URL, Int) async throws -> Void + var mergePullRequest: @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void + var closePullRequest: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void + var markPullRequestReady: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void var failedRunLogs: @Sendable (URL, Int) async throws -> String var runLogs: @Sendable (URL, Int) async throws -> String @@ -61,6 +62,7 @@ extension GithubCLIClient: DependencyKey { let resolver = GithubCLIExecutableResolver() return GithubCLIClient( defaultBranch: defaultBranchFetcher(shell: shell, resolver: resolver), + resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver), latestRun: latestRunFetcher(shell: shell, resolver: resolver), batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver), mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver), @@ -76,11 +78,12 @@ extension GithubCLIClient: DependencyKey { static let testValue = GithubCLIClient( defaultBranch: { _ in "main" }, + resolveRemoteInfo: { _ in nil }, latestRun: { _, _ in nil }, batchPullRequests: { _, _, _, _ in [:] }, - mergePullRequest: { _, _, _ in }, - closePullRequest: { _, _ in }, - markPullRequestReady: { _, _ in }, + mergePullRequest: { _, _, _, _ in }, + closePullRequest: { _, _, _ in }, + markPullRequestReady: { _, _, _ in }, rerunFailedJobs: { _, _ in }, failedRunLogs: { _, _ in "" }, runLogs: { _, _ in "" }, @@ -102,6 +105,23 @@ private struct GithubPullRequestsRequest: Sendable { let repo: String } +nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { + let owner: Owner + let name: String + let url: String + + nonisolated var remoteInfo: GithubRemoteInfo? { + guard let host = URL(string: url)?.host else { + return nil + } + return GithubRemoteInfo(host: host, owner: owner.login, repo: name) + } + + nonisolated struct Owner: Decodable, Sendable { + let login: String + } +} + private actor GithubCLIExecutableResolver { private var cachedExecutableURL: URL? private var inFlightResolution: Task? @@ -197,6 +217,27 @@ nonisolated private func defaultBranchFetcher( } } +nonisolated private func resolveRemoteInfoFetcher( + shell: ShellClient, + resolver: GithubCLIExecutableResolver +) -> @Sendable (URL) async -> GithubRemoteInfo? { + { repoRoot in + do { + let output = try await runGh( + shell: shell, + resolver: resolver, + arguments: ["repo", "view", "--json", "owner,name,url"], + repoRoot: repoRoot + ) + let data = Data(output.utf8) + let response = try JSONDecoder().decode(GithubRepoViewRemoteInfoResponse.self, from: data) + return response.remoteInfo + } catch { + return nil + } + } +} + nonisolated private func latestRunFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver @@ -258,8 +299,8 @@ nonisolated private func batchPullRequestsFetcher( nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int, PullRequestMergeStrategy) async throws -> Void { - { repoRoot, pullRequestNumber, strategy in +) -> @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber, strategy in _ = try await runGh( shell: shell, resolver: resolver, @@ -268,7 +309,7 @@ nonisolated private func mergePullRequestFetcher( "merge", "\(pullRequestNumber)", "--\(strategy.ghArgument)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } @@ -277,8 +318,8 @@ nonisolated private func mergePullRequestFetcher( nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> Void { - { repoRoot, pullRequestNumber in +) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, resolver: resolver, @@ -286,7 +327,7 @@ nonisolated private func closePullRequestFetcher( "pr", "close", "\(pullRequestNumber)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } @@ -295,8 +336,8 @@ nonisolated private func closePullRequestFetcher( nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> Void { - { repoRoot, pullRequestNumber in +) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, resolver: resolver, @@ -304,12 +345,19 @@ nonisolated private func markPullRequestReadyFetcher( "pr", "ready", "\(pullRequestNumber)", - ], + ] + repoArgument(remoteInfo), repoRoot: repoRoot ) } } +nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo?) -> [String] { + guard let remoteInfo else { + return [] + } + return ["--repo", "\(remoteInfo.host)/\(remoteInfo.owner)/\(remoteInfo.repo)"] +} + nonisolated private func rerunFailedJobsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver diff --git a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift index 3f87e833..af4ad74e 100644 --- a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift +++ b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift @@ -16,10 +16,22 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { continue } let upstreamCandidates = connection.nodes.filter { $0.matches(owner: normalizedOwner, repo: normalizedRepo) } - let candidates = - upstreamCandidates.isEmpty - ? connection.nodes.filter { $0.headRepository != nil } - : upstreamCandidates + let candidates: [PullRequestNode] + if !upstreamCandidates.isEmpty { + candidates = upstreamCandidates + } else { + let forkCandidates = connection.nodes.filter { + $0.headRepository != nil && $0.doesNotTargetSameBranch(branch) + } + candidates = + if !forkCandidates.isEmpty { + forkCandidates + } else { + connection.nodes.filter { + $0.headRepository == nil && $0.doesNotTargetSameBranch(branch) + } + } + } if let node = candidates.max(by: { left, right in let leftRank = left.stateRank let rightRank = right.stateRank @@ -133,6 +145,13 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { return headRepository.owner.login.lowercased() == owner && headRepository.name.lowercased() == repo } + + func doesNotTargetSameBranch(_ branch: String) -> Bool { + guard let baseRefName else { + return true + } + return baseRefName != branch + } } nonisolated struct CommitConnection: Decodable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 0c9d88f8..a1f13f0b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -2,6 +2,19 @@ import AppKit import ComposableArchitecture import Foundation +extension RepositoriesFeature { + func resolveGithubRemoteInfo( + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> GithubRemoteInfo? { + if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { + return remoteInfo + } + return await gitClient.remoteInfo(repositoryRootURL) + } +} + extension RepositoriesFeature { // swiftlint:disable:next cyclomatic_complexity function_body_length func reduceGithubIntegration( @@ -312,6 +325,7 @@ extension RepositoriesFeature { case .markReadyForReview: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -324,7 +338,12 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Marking PR ready…"))) do { - try await githubCLI.markPullRequestReady(worktreeRoot, pullRequest.number) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.markPullRequestReady(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request marked ready"))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { @@ -341,6 +360,7 @@ extension RepositoriesFeature { case .merge: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -356,7 +376,12 @@ extension RepositoriesFeature { let strategy = repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy await send(.showToast(.inProgress("Merging pull request…"))) do { - try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.mergePullRequest(worktreeRoot, remoteInfo, pullRequest.number, strategy) await send(.showToast(.success("Pull request merged"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) @@ -374,6 +399,7 @@ extension RepositoriesFeature { case .close: let githubCLI = githubCLI let githubIntegration = githubIntegration + let gitClient = gitClient return .run { send in guard await githubIntegration.isAvailable() else { await send( @@ -386,7 +412,12 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Closing pull request…"))) do { - try await githubCLI.closePullRequest(worktreeRoot, pullRequest.number) + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + try await githubCLI.closePullRequest(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request closed"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 18be5ba1..d2381eae 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1152,7 +1152,13 @@ struct RepositoriesFeature { let gitClient = gitClient let githubCLI = githubCLI return .run { send in - guard let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) else { + guard + let remoteInfo = await resolveGithubRemoteInfo( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + else { await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } diff --git a/supacodeTests/GithubBatchPullRequestsTests.swift b/supacodeTests/GithubBatchPullRequestsTests.swift index 2545e0a8..5b450ada 100644 --- a/supacodeTests/GithubBatchPullRequestsTests.swift +++ b/supacodeTests/GithubBatchPullRequestsTests.swift @@ -108,7 +108,7 @@ struct GithubBatchPullRequestsTests { #expect(prs["feature-a"]?.title == "Fork PR") } - @Test func ignoresNilHeadRepositoryInFallback() throws { + @Test func fallsBackToMergedPullRequestWithDeletedFork() throws { let json = """ { "data": { @@ -117,8 +117,8 @@ struct GithubBatchPullRequestsTests { "nodes": [ { "number": 7, - "title": "Head Missing", - "state": "OPEN", + "title": "Deleted Fork", + "state": "MERGED", "additions": 1, "deletions": 0, "isDraft": false, @@ -127,18 +127,62 @@ struct GithubBatchPullRequestsTests { "url": "https://github.com/octo/repo/pull/7", "headRefName": "feature-a", "headRepository": null + } + ] + } + } + } + } + """ + let data = Data(json.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) + let prs = response.pullRequestsByBranch( + aliasMap: ["branch0": "feature-a"], + owner: "octo", + repo: "repo" + ) + #expect(prs["feature-a"]?.number == 7) + #expect(prs["feature-a"]?.title == "Deleted Fork") + } + + @Test func forkFallbackIgnoresSameBaseBranchMatches() throws { + let json = """ + { + "data": { + "repository": { + "branch0": { + "nodes": [ + { + "number": 12, + "title": "Same Branch Candidate", + "state": "OPEN", + "additions": 1, + "deletions": 0, + "isDraft": false, + "reviewDecision": null, + "updatedAt": "2025-01-03T00:00:00Z", + "url": "https://github.com/fork/repo/pull/12", + "headRefName": "feature-a", + "baseRefName": "feature-a", + "headRepository": { + "name": "repo", + "owner": { "login": "fork" } + } }, { - "number": 8, - "title": "Fork PR", + "number": 13, + "title": "Fork PR From Feature", "state": "OPEN", "additions": 1, "deletions": 0, "isDraft": false, "reviewDecision": null, "updatedAt": "2025-01-01T00:00:00Z", - "url": "https://github.com/fork/repo/pull/8", + "url": "https://github.com/fork/repo/pull/13", "headRefName": "feature-a", + "baseRefName": "main", "headRepository": { "name": "repo", "owner": { "login": "fork" } @@ -159,7 +203,8 @@ struct GithubBatchPullRequestsTests { owner: "octo", repo: "repo" ) - #expect(prs["feature-a"]?.number == 8) + #expect(prs["feature-a"]?.number == 13) + #expect(prs["feature-a"]?.title == "Fork PR From Feature") } @Test func prefersOpenOverMergedEvenIfOlder() throws { diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index 8eec680e..083ca5b0 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -48,7 +48,96 @@ actor GithubBatchShellProbe { } } +actor GithubCommandProbe { + struct Call: Equatable { + let arguments: [String] + let currentDirectoryURL: URL? + } + + private var calls: [Call] = [] + + func record(arguments: [String], currentDirectoryURL: URL?) { + calls.append(Call(arguments: arguments, currentDirectoryURL: currentDirectoryURL)) + } + + func snapshot() -> [Call] { + calls + } +} + struct GithubCLIClientTests { + @Test func resolveRemoteInfoUsesGhRepoView() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/fork") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + return ShellOutput( + stdout: #"{"owner":{"login":"supabitapp"},"name":"supacode","url":"https://github.com/supabitapp/supacode"}"#, + stderr: "", + exitCode: 0 + ) + } + ) + let client = GithubCLIClient.live(shell: shell) + + let remoteInfo = try await client.resolveRemoteInfo(repoRoot) + + #expect(remoteInfo == GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode")) + let calls = await probe.snapshot() + #expect( + calls == [ + GithubCommandProbe.Call( + arguments: ["repo", "view", "--json", "owner,name,url"], + currentDirectoryURL: repoRoot + ) + ]) + } + + @Test func pullRequestMutationsUseResolvedRemoteInfo() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/fork") + let remoteInfo = GithubRemoteInfo(host: "github.enterprise.test", owner: "octo", repo: "repo") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + let client = GithubCLIClient.live(shell: shell) + + try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash) + try await client.closePullRequest(repoRoot, remoteInfo, 13) + try await client.markPullRequestReady(repoRoot, remoteInfo, 14) + + let calls = await probe.snapshot() + #expect( + calls.map(\.arguments) == [ + ["pr", "merge", "12", "--squash", "--repo", "github.enterprise.test/octo/repo"], + ["pr", "close", "13", "--repo", "github.enterprise.test/octo/repo"], + ["pr", "ready", "14", "--repo", "github.enterprise.test/octo/repo"], + ]) + #expect(calls.allSatisfy { $0.currentDirectoryURL == repoRoot }) + } + @Test func batchPullRequestsCapsConcurrencyAtThree() async throws { let probe = GithubBatchShellProbe() let shell = ShellClient( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2545f92e..7501d737 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3306,7 +3306,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.mergePullRequest = { _, number, _ in + $0.githubCLI.mergePullRequest = { _, _, number, _ in mergedNumbers.withValue { $0.append(number) } } } @@ -3356,7 +3356,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.mergePullRequest = { _, _, strategy in + $0.githubCLI.mergePullRequest = { _, _, _, strategy in mergedStrategies.withValue { $0.append(strategy) } } } @@ -3374,6 +3374,57 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func pullRequestActionMergeUsesResolvedRemoteInfo() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .disabled + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: openPullRequest + ) + let mutationRemoteInfos = LockIsolated<[GithubRemoteInfo?]>([]) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { root in + #expect(root == URL(fileURLWithPath: repoRoot)) + return upstreamRemoteInfo + } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not be used when gh repo view succeeds") + return nil + } + $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _ in + #expect(root == featureWorktree.workingDirectory) + #expect(number == 12) + mutationRemoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Merging pull request…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request merged") + } + await store.receive(\.worktreeInfoEvent) + #expect(mutationRemoteInfos.value == [upstreamRemoteInfo]) + await store.finish() + } + @Test func pullRequestActionCloseRefreshesImmediately() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -3396,7 +3447,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.closePullRequest = { _, number in + $0.githubCLI.closePullRequest = { _, _, number in closedNumbers.withValue { $0.append(number) } } } @@ -3589,6 +3640,58 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func worktreeInfoEventRepositoryPullRequestRefreshPrefersResolvedRemoteInfo() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let requestedRemoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { root in + #expect(root == URL(fileURLWithPath: repoRoot)) + return upstreamRemoteInfo + } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not be used when gh repo view succeeds") + return nil + } + $0.githubCLI.batchPullRequests = { host, owner, repo, branches in + #expect(branches == ["main", "feature"]) + requestedRemoteInfos.withValue { + $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) + } + return [:] + } + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + #expect(requestedRemoteInfos.value == [upstreamRemoteInfo]) + await store.finish() + } + @Test func worktreeInfoEventRepositoryPullRequestRefreshQueuesWhileAvailabilityUnknown() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) -- 2.51.2 From 4449489d1aeb194525316cf5ed0327d12ec19b88 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 7 May 2026 23:33:06 +0900 Subject: [PATCH 050/528] Add notification jump and surface indicators --- supacode/App/AppShortcuts.swift | 10 ++ supacode/App/supacodeApp.swift | 44 +++++-- .../SystemNotificationClient.swift | 40 ++++++- .../Clients/Terminal/TerminalClient.swift | 15 ++- supacode/Commands/WorktreeCommands.swift | 6 + .../Features/App/Reducer/AppFeature.swift | 37 +++++- .../Canvas/Views/CanvasCardView.swift | 1 + .../Shelf/Views/ShelfOpenBookView.swift | 12 +- .../WorktreeTerminalManager.swift | 37 +++++- .../Models/NotificationLocation.swift | 8 ++ .../Models/WorktreeTerminalNotification.swift | 11 +- .../Models/WorktreeTerminalState.swift | 33 +++++- .../TabBar/Views/TerminalTabBarView.swift | 4 +- .../TabBar/Views/TerminalTabView.swift | 38 ++++-- .../TabBar/Views/TerminalTabsRowView.swift | 2 + .../TabBar/Views/TerminalTabsView.swift | 2 + .../Views/TerminalSplitTreeView.swift | 28 +++++ .../Views/WorktreeTerminalTabsView.swift | 15 ++- .../AppFeatureJumpToLatestUnreadTests.swift | 100 ++++++++++++++++ .../AppFeatureSystemNotificationTests.swift | 34 +++++- .../WorktreeTerminalManagerTests.swift | 108 +++++++++++++++++- 21 files changed, 532 insertions(+), 53 deletions(-) create mode 100644 supacode/Features/Terminal/Models/NotificationLocation.swift create mode 100644 supacodeTests/AppFeatureJumpToLatestUnreadTests.swift diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index ed92d901..0aab1d22 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -94,6 +94,7 @@ enum AppShortcuts { static let openPullRequest = "open_pull_request" static let toggleLeftSidebar = "toggle_left_sidebar" static let refreshWorktrees = "refresh_worktrees" + static let jumpToLatestUnread = "jump_to_latest_unread" static let runScript = "run_script" static let stopScript = "stop_script" static let checkForUpdates = "check_for_updates" @@ -180,6 +181,7 @@ enum AppShortcuts { static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) static let toggleLeftSidebar = AppShortcut(key: "s", modifiers: [.command, .control]) static let refreshWorktrees = AppShortcut(key: "r", modifiers: [.command, .shift]) + static let jumpToLatestUnread = AppShortcut(key: "u", modifiers: [.command, .option]) static let runScript = AppShortcut(key: "r", modifiers: .command) static let stopRunScript = AppShortcut(key: ".", modifiers: .command) static let checkForUpdates = AppShortcut(key: "u", modifiers: [.command, .shift]) @@ -323,6 +325,7 @@ enum AppShortcuts { private static let reservedCustomCommandBindings: [ReservedCustomCommandBinding] = [ .init(actionTitle: "Open Settings", shortcut: openSettings), .init(actionTitle: "Toggle Left Sidebar", shortcut: toggleLeftSidebar), + .init(actionTitle: "Jump to Latest Unread", shortcut: jumpToLatestUnread), .init(actionTitle: "Run Script", shortcut: runScript), .init(actionTitle: "Stop Script", shortcut: stopRunScript), .init(actionTitle: "Check for Updates", shortcut: checkForUpdates), @@ -397,6 +400,12 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: refreshWorktrees ), + .init( + id: CommandID.jumpToLatestUnread, + title: "Jump to Latest Unread", + scope: .configurableAppAction, + shortcut: jumpToLatestUnread + ), .init( id: CommandID.runScript, title: "Run Script", @@ -840,6 +849,7 @@ enum AppShortcuts { toggleLeftSidebar, revealInSidebar, refreshWorktrees, + jumpToLatestUnread, runScript, stopRunScript, checkForUpdates, diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 7487cb83..044b0380 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -29,7 +29,14 @@ private enum GhosttyCLI { @MainActor final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { - var appStore: StoreOf? + var appStore: StoreOf? { + didSet { + guard let appStore else { return } + setSystemNotificationTapHandler { [weak appStore] worktreeID, surfaceID in + appStore?.send(.systemNotificationTapped(worktreeID: worktreeID, surfaceID: surfaceID)) + } + } + } var terminalManager: WorktreeTerminalManager? var cliSocketServer: CLISocketServer? @@ -191,17 +198,7 @@ struct SupacodeApp: App { AppFeature() .logActions() } withDependencies: { values in - values.terminalClient = TerminalClient( - send: { command in - terminalManager.handleCommand(command) - }, - events: { - terminalManager.eventStream() - }, - canvasFocusedWorktreeID: { - terminalManager.canvasFocusedWorktreeID - } - ) + values.terminalClient = Self.makeTerminalClient(terminalManager: terminalManager) values.worktreeInfoWatcher = WorktreeInfoWatcherClient( send: { command in worktreeInfoWatcher.handleCommand(command) @@ -248,6 +245,29 @@ struct SupacodeApp: App { #endif } + private static func makeTerminalClient(terminalManager: WorktreeTerminalManager) -> TerminalClient { + TerminalClient( + send: { command in + terminalManager.handleCommand(command) + }, + events: { + terminalManager.eventStream() + }, + canvasFocusedWorktreeID: { + terminalManager.canvasFocusedWorktreeID + }, + latestUnreadNotification: { + terminalManager.latestUnreadNotificationLocation() + }, + focusSurface: { worktreeID, surfaceID in + terminalManager.focusSurface(worktreeID: worktreeID, surfaceID: surfaceID) + }, + markNotificationRead: { worktreeID, notificationID in + terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + } + ) + } + private static func makeTargetResolver( appStore: StoreOf, terminalManager: WorktreeTerminalManager diff --git a/supacode/Clients/Notifications/SystemNotificationClient.swift b/supacode/Clients/Notifications/SystemNotificationClient.swift index 965a4a12..fa32e174 100644 --- a/supacode/Clients/Notifications/SystemNotificationClient.swift +++ b/supacode/Clients/Notifications/SystemNotificationClient.swift @@ -3,7 +3,13 @@ import ComposableArchitecture import Foundation import UserNotifications +private nonisolated let notificationWorktreeIDKey = "prowl.worktreeID" +private nonisolated let notificationSurfaceIDKey = "prowl.surfaceID" + +@MainActor private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + var onNotificationTap: ((Worktree.ID, UUID) -> Void)? + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification @@ -11,6 +17,21 @@ private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotifi await Task.yield() return [.badge, .sound, .banner] } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + await Task.yield() + let userInfo = response.notification.request.content.userInfo + guard let worktreeID = userInfo[notificationWorktreeIDKey] as? String, + let rawSurfaceID = userInfo[notificationSurfaceIDKey] as? String, + let surfaceID = UUID(uuidString: rawSurfaceID) + else { + return + } + onNotificationTap?(worktreeID, surfaceID) + } } @MainActor @@ -25,6 +46,12 @@ private func configuredNotificationCenter() -> UNUserNotificationCenter { return center } +@MainActor +func setSystemNotificationTapHandler(_ handler: @escaping @MainActor (Worktree.ID, UUID) -> Void) { + _ = configuredNotificationCenter() + foregroundSystemNotificationDelegate.onNotificationTap = handler +} + struct SystemNotificationClient { struct AuthorizationRequestResult: Equatable { let granted: Bool @@ -39,7 +66,8 @@ struct SystemNotificationClient { var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult - var send: @MainActor @Sendable (_ title: String, _ body: String) async -> Void + var send: + @MainActor @Sendable (_ title: String, _ body: String, _ worktreeID: Worktree.ID?, _ surfaceID: UUID?) async -> Void var openSettings: @MainActor @Sendable () async -> Void } @@ -73,12 +101,18 @@ extension SystemNotificationClient: DependencyKey { ) } }, - send: { title, body in + send: { title, body, worktreeID, surfaceID in let center = configuredNotificationCenter() let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default + if let worktreeID, let surfaceID { + content.userInfo = [ + notificationWorktreeIDKey: worktreeID, + notificationSurfaceIDKey: surfaceID.uuidString, + ] + } let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, @@ -97,7 +131,7 @@ extension SystemNotificationClient: DependencyKey { static let testValue = SystemNotificationClient( authorizationStatus: { .notDetermined }, requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, - send: { _, _ in }, + send: { _, _, _, _ in }, openSettings: {} ) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index b228e298..da35439f 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -5,6 +5,9 @@ struct TerminalClient { var send: @MainActor @Sendable (Command) -> Void var events: @MainActor @Sendable () -> AsyncStream var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? + var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? + var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool + var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void enum Command: Equatable { case createTab(Worktree, runSetupScriptIfNew: Bool) @@ -49,7 +52,7 @@ struct TerminalClient { enum Event: Equatable { case customCommandSucceeded(worktreeID: Worktree.ID, name: String, durationMs: Int) - case notificationReceived(worktreeID: Worktree.ID, title: String, body: String) + case notificationReceived(worktreeID: Worktree.ID, surfaceID: UUID, title: String, body: String) case notificationIndicatorChanged(count: Int) case tabCreated(worktreeID: Worktree.ID) case tabClosed(worktreeID: Worktree.ID, remainingTabs: Int) @@ -68,13 +71,19 @@ extension TerminalClient: DependencyKey { static let liveValue = TerminalClient( send: { _ in fatalError("TerminalClient.send not configured") }, events: { fatalError("TerminalClient.events not configured") }, - canvasFocusedWorktreeID: { nil } + canvasFocusedWorktreeID: { nil }, + latestUnreadNotification: { nil }, + focusSurface: { _, _ in false }, + markNotificationRead: { _, _ in } ) static let testValue = TerminalClient( send: { _ in }, events: { AsyncStream { $0.finish() } }, - canvasFocusedWorktreeID: { nil } + canvasFocusedWorktreeID: { nil }, + latestUnreadNotification: { nil }, + focusSurface: { _, _ in false }, + markNotificationRead: { _, _ in } ) } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 08670cd3..af44dd93 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -110,6 +110,12 @@ struct WorktreeCommands: Commands { } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.refreshWorktrees))) .help(helpText(title: "Refresh Worktrees", commandID: AppShortcuts.CommandID.refreshWorktrees)) + Button("Jump to Latest Unread") { + store.send(.jumpToLatestUnread) + } + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.jumpToLatestUnread))) + .help(helpText(title: "Jump to Latest Unread", commandID: AppShortcuts.CommandID.jumpToLatestUnread)) + .disabled(store.notificationIndicatorCount == 0) Divider() Button("Run Script") { runScriptAction?() diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 4dd2bb92..9f049646 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -5,6 +5,7 @@ import PostHog import SwiftUI private let appLogger = SupaLogger("App") +private let notificationJumpLogger = SupaLogger("NotificationJump") private enum CancelID { static let periodicRefresh = "app.periodicRefresh" @@ -81,6 +82,7 @@ struct AppFeature { case openWorktreeFailed(OpenActionError) case requestQuit case newTerminal + case jumpToLatestUnread case runScript case runCustomCommand(Int) case runScriptDraftChanged(String) @@ -95,6 +97,7 @@ struct AppFeature { case navigateSearchPrevious case endSearch case systemNotificationsPermissionFailed(errorMessage: String?) + case systemNotificationTapped(worktreeID: Worktree.ID, surfaceID: UUID) case alert(PresentationAction) case terminalEvent(TerminalClient.Event) } @@ -614,6 +617,24 @@ struct AppFeature { await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) } + case .jumpToLatestUnread: + guard let location = terminalClient.latestUnreadNotification() else { + notificationJumpLogger.debug("jumpToLatestUnread invoked with no unread notification.") + return .none + } + guard state.repositories.worktree(for: location.worktreeID) != nil else { + notificationJumpLogger.warning("Unread notification worktree vanished: \(location.worktreeID)") + return .none + } + analyticsClient.capture("notifications_jump_to_latest_unread", nil) + return .merge( + .send(.repositories(.selectWorktree(location.worktreeID, focusTerminal: true))), + .run { _ in + _ = await terminalClient.focusSurface(location.worktreeID, location.surfaceID) + await terminalClient.markNotificationRead(location.worktreeID, location.notificationID) + } + ) + case .runScript: guard let worktree = state.repositories.selectedTerminalWorktree else { return .none @@ -855,6 +876,18 @@ struct AppFeature { .send(.settings(.showNotificationPermissionAlert(errorMessage: errorMessage))) ) + case .systemNotificationTapped(let worktreeID, let surfaceID): + guard state.repositories.worktree(for: worktreeID) != nil else { + notificationJumpLogger.warning("Tapped notification worktree vanished: \(worktreeID)") + return .none + } + return .merge( + .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), + .run { _ in + _ = await terminalClient.focusSurface(worktreeID, surfaceID) + } + ) + case .alert(.dismiss): state.alert = nil return .none @@ -970,14 +1003,14 @@ struct AppFeature { let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" return .send(.repositories(.showToast(.success(message)))) - case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): + case .terminalEvent(.notificationReceived(let worktreeID, let surfaceID, let title, let body)): var effects: [Effect] = [ .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) ] if state.settings.systemNotificationsEnabled { effects.append( .run { _ in - await systemNotificationClient.send(title, body) + await systemNotificationClient.send(title, body, worktreeID, surfaceID) } ) } diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 2e57b798..0efc23fc 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -230,6 +230,7 @@ struct CanvasCardView: View { tree: tree, pinnedSize: cardSize, focusedSurfaceID: focusedSurfaceID, + hasNotification: { _ in false }, action: onSplitOperation ) .frame(width: cardSize.width, height: cardSize.height) diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index 2bd0692d..5f1f3a56 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -27,10 +27,14 @@ struct ShelfOpenBookView: View { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in TerminalSplitTreeAXContainer( tree: state.splitTree(for: tabId), - focusedSurfaceID: state.focusedSurfaceId(in: tabId) - ) { operation in - state.performSplitOperation(operation, in: tabId) - } + focusedSurfaceID: state.focusedSurfaceId(in: tabId), + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) } } else { EmptyTerminalPaneView(message: "No terminals open") diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 89b66305..109334b3 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -226,8 +226,8 @@ final class WorktreeTerminalManager { state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } - state.onNotificationReceived = { [weak self] title, body in - self?.emit(.notificationReceived(worktreeID: worktree.id, title: title, body: body)) + state.onNotificationReceived = { [weak self] surfaceID, title, body in + self?.emit(.notificationReceived(worktreeID: worktree.id, surfaceID: surfaceID, title: title, body: body)) } state.onNotificationIndicatorChanged = { [weak self] in self?.emitNotificationIndicatorCountIfNeeded() @@ -433,6 +433,39 @@ final class WorktreeTerminalManager { states[worktreeID]?.hasUnseenNotification == true } + func latestUnreadNotificationLocation() -> NotificationLocation? { + var bestLocation: NotificationLocation? + var bestCreatedAt: Date? + for (worktreeID, state) in states { + for notification in state.unreadNotifications() { + if let bestCreatedAt, bestCreatedAt >= notification.createdAt { + break + } + guard let tabID = state.tabID(containing: notification.surfaceId) else { + continue + } + bestLocation = NotificationLocation( + worktreeID: worktreeID, + tabID: tabID, + surfaceID: notification.surfaceId, + notificationID: notification.id + ) + bestCreatedAt = notification.createdAt + break + } + } + return bestLocation + } + + @discardableResult + func focusSurface(worktreeID: Worktree.ID, surfaceID: UUID) -> Bool { + states[worktreeID]?.focusSurface(id: surfaceID) == true + } + + func markNotificationRead(worktreeID: Worktree.ID, notificationID: UUID) { + states[worktreeID]?.markNotificationRead(id: notificationID) + } + func surfaceBackgroundOpacity() -> Double { runtime?.backgroundOpacity() ?? 1.0 } diff --git a/supacode/Features/Terminal/Models/NotificationLocation.swift b/supacode/Features/Terminal/Models/NotificationLocation.swift new file mode 100644 index 00000000..5a886997 --- /dev/null +++ b/supacode/Features/Terminal/Models/NotificationLocation.swift @@ -0,0 +1,8 @@ +import Foundation + +struct NotificationLocation: Equatable, Sendable { + let worktreeID: Worktree.ID + let tabID: TerminalTabID + let surfaceID: UUID + let notificationID: UUID +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift b/supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift index 0a4f22e8..93326f4b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift @@ -5,13 +5,22 @@ struct WorktreeTerminalNotification: Identifiable, Equatable, Sendable { let surfaceId: UUID let title: String let body: String + let createdAt: Date var isRead: Bool - init(id: UUID = UUID(), surfaceId: UUID, title: String, body: String, isRead: Bool = false) { + init( + id: UUID = UUID(), + surfaceId: UUID, + title: String, + body: String, + createdAt: Date = .distantPast, + isRead: Bool = false + ) { self.id = id self.surfaceId = surfaceId self.title = title self.body = body + self.createdAt = createdAt self.isRead = isRead } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8c52fcc1..b4e7326b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -65,11 +65,24 @@ final class WorktreeTerminalState { notifications.contains { !$0.isRead } } + func hasUnseenNotification(forSurfaceID surfaceID: UUID) -> Bool { + notifications.contains { !$0.isRead && $0.surfaceId == surfaceID } + } + func hasUnseenNotification(for tabId: TerminalTabID) -> Bool { let surfaceIds = trees[tabId]?.leaves().map(\.id) ?? [] return notifications.contains { !$0.isRead && surfaceIds.contains($0.surfaceId) } } + func unreadNotifications() -> [WorktreeTerminalNotification] { + notifications.filter { !$0.isRead }.sorted { left, right in + if left.createdAt != right.createdAt { + return left.createdAt > right.createdAt + } + return left.id.uuidString > right.id.uuidString + } + } + var canCloseFocusedTab: Bool { tabManager.selectedTabId != nil } @@ -84,7 +97,7 @@ final class WorktreeTerminalState { } var isSelected: () -> Bool = { false } - var onNotificationReceived: ((String, String) -> Void)? + var onNotificationReceived: ((UUID, String, String) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? var onTabCreated: (() -> Void)? var onTabClosed: (() -> Void)? @@ -943,6 +956,15 @@ final class WorktreeTerminalState { emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) } + func markNotificationRead(id notificationID: WorktreeTerminalNotification.ID) { + let previousHasUnseen = hasUnseenNotification + guard let index = notifications.firstIndex(where: { $0.id == notificationID }) else { + return + } + notifications[index].isRead = true + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { let previousHasUnseen = hasUnseenNotification notifications.removeAll { $0.id == notificationID } @@ -1300,13 +1322,14 @@ final class WorktreeTerminalState { surfaceId: surfaceId, title: trimmedTitle, body: trimmedBody, + createdAt: Date(), isRead: isRead ), at: 0 ) emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) } - onNotificationReceived?(trimmedTitle, trimmedBody) + onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody) } /// How recently the user must have typed for us to consider the exit user-initiated. @@ -1585,13 +1608,17 @@ final class WorktreeTerminalState { tabIsRunningById.removeValue(forKey: tabId) } - private func tabId(containing surfaceId: UUID) -> TerminalTabID? { + func tabID(containing surfaceId: UUID) -> TerminalTabID? { for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { return tabId } return nil } + private func tabId(containing surfaceId: UUID) -> TerminalTabID? { + tabID(containing: surfaceId) + } + private func isFocusedSurface(_ surfaceId: UUID) -> Bool { guard let selectedTabId = tabManager.selectedTabId else { return false diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index f880165e..d9dfad5b 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -12,6 +12,7 @@ struct TerminalTabBarView: View { let closeOthers: (TerminalTabID) -> Void let closeToRight: (TerminalTabID) -> Void let closeAll: () -> Void + let hasNotification: (TerminalTabID) -> Bool @Environment(\.controlActiveState) private var activeState @@ -24,7 +25,8 @@ struct TerminalTabBarView: View { closeTab: closeTab, closeOthers: closeOthers, closeToRight: closeToRight, - closeAll: closeAll + closeAll: closeAll, + hasNotification: hasNotification ) Spacer(minLength: 0) TerminalTabBarTrailingAccessories( diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 7ba90640..b7d9429a 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -7,6 +7,7 @@ struct TerminalTabView: View { let isDragging: Bool let tabIndex: Int let fixedWidth: CGFloat? + let hasNotification: Bool let onSelect: () -> Void let onClose: () -> Void @Binding var closeButtonGestureActive: Bool @@ -41,14 +42,21 @@ struct TerminalTabView: View { .help("Open tab \(tab.title)") .accessibilityLabel(tab.title) - TerminalTabCloseButton( - isHoveringTab: isHovering, - isDragging: isDragging, - isShowingShortcutHint: showsShortcutHint, - closeAction: onClose, - closeButtonGestureActive: $closeButtonGestureActive, - isHoveringClose: $isHoveringClose - ) + ZStack { + TabNotificationDot() + .opacity(isShowingNotificationDot ? 1 : 0) + .allowsHitTesting(false) + TerminalTabCloseButton( + isHoveringTab: isHovering, + isDragging: isDragging, + isShowingShortcutHint: showsShortcutHint, + closeAction: onClose, + closeButtonGestureActive: $closeButtonGestureActive, + isHoveringClose: $isHoveringClose + ) + } + .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) + .animation(.easeInOut(duration: 0.2), value: hasNotification) .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) } .background { @@ -80,6 +88,20 @@ struct TerminalTabView: View { private var showsShortcutHint: Bool { commandKeyObserver.isPressed && shortcutHint != nil } + + private var isShowingNotificationDot: Bool { + hasNotification && !isHovering && !isHoveringClose && !isDragging && !showsShortcutHint + } +} + +private struct TabNotificationDot: View { + var body: some View { + Circle() + .fill(.orange) + .frame(width: 6, height: 6) + .frame(width: TerminalTabBarMetrics.closeButtonSize, height: TerminalTabBarMetrics.closeButtonSize) + .accessibilityLabel("Unread notifications") + } } private struct MiddleClickView: NSViewRepresentable { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index 771a2b5d..01f7e259 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -8,6 +8,7 @@ struct TerminalTabsRowView: View { @Binding var draggingStartLocation: CGFloat? @Binding var closeButtonGestureActive: Bool let fixedTabWidth: CGFloat? + let hasNotification: (TerminalTabID) -> Bool let changeTitle: (TerminalTabID) -> Void let changeIcon: (TerminalTabID) -> Void let closeTab: (TerminalTabID) -> Void @@ -30,6 +31,7 @@ struct TerminalTabsRowView: View { isDragging: draggingTabId == id, tabIndex: index, fixedWidth: fixedTabWidth, + hasNotification: hasNotification(id), onSelect: { manager.selectTab(id) }, diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift index 04b60589..144e8543 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift @@ -8,6 +8,7 @@ struct TerminalTabsView: View { let closeOthers: (TerminalTabID) -> Void let closeToRight: (TerminalTabID) -> Void let closeAll: () -> Void + let hasNotification: (TerminalTabID) -> Bool @State private var draggingTabId: TerminalTabID? @State private var draggingStartLocation: CGFloat? @@ -30,6 +31,7 @@ struct TerminalTabsView: View { draggingStartLocation: $draggingStartLocation, closeButtonGestureActive: $closeButtonGestureActive, fixedTabWidth: effectiveTabWidth, + hasNotification: hasNotification, changeTitle: changeTitle, changeIcon: changeIcon, closeTab: closeTab, diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 4724dbff..06a5bf36 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -7,6 +7,7 @@ struct TerminalSplitTreeView: View { let tree: SplitTree var pinnedSize: CGSize? var focusedSurfaceID: UUID? + let hasNotification: (UUID) -> Bool let action: (Operation) -> Void private static let dragType = UTType(exportedAs: "com.onevcat.prowl.ghosttySurfaceId") @@ -30,6 +31,7 @@ struct TerminalSplitTreeView: View { isRoot: node == tree.root, pinnedSize: pinnedSize, focusedSurfaceID: focusedSurfaceID, + hasNotification: hasNotification, action: action ) .id(node.structuralIdentity) @@ -47,6 +49,7 @@ struct TerminalSplitTreeView: View { var isRoot: Bool = false var pinnedSize: CGSize? var focusedSurfaceID: UUID? + let hasNotification: (UUID) -> Bool let action: (Operation) -> Void var body: some View { @@ -56,6 +59,7 @@ struct TerminalSplitTreeView: View { surfaceView: leafView, isSplit: !isRoot, isFocused: leafView.id == focusedSurfaceID, + hasNotification: hasNotification(leafView.id), pinnedSize: pinnedSize, action: action ) @@ -85,6 +89,7 @@ struct TerminalSplitTreeView: View { node: split.left, pinnedSize: leftPinned, focusedSurfaceID: focusedSurfaceID, + hasNotification: hasNotification, action: action ) }, @@ -93,6 +98,7 @@ struct TerminalSplitTreeView: View { node: split.right, pinnedSize: rightPinned, focusedSurfaceID: focusedSurfaceID, + hasNotification: hasNotification, action: action ) }, @@ -119,6 +125,7 @@ struct TerminalSplitTreeView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool var isFocused: Bool = true + let hasNotification: Bool var pinnedSize: CGSize? let action: (Operation) -> Void @@ -157,6 +164,13 @@ struct TerminalSplitTreeView: View { GhosttySurfaceSearchOverlay(surfaceView: surfaceView) } } + .overlay(alignment: .topTrailing) { + SurfaceNotificationDot() + .padding(6) + .opacity(hasNotification ? 1 : 0) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.2), value: hasNotification) + } .overlay(alignment: .top) { if isSplit { DragHandle(surfaceView: surfaceView) @@ -340,6 +354,18 @@ struct TerminalSplitTreeView: View { } } +private struct SurfaceNotificationDot: View { + var body: some View { + Circle() + .fill(.orange) + .frame(width: 8, height: 8) + .overlay { + Circle().stroke(.background, lineWidth: 1) + } + .accessibilityLabel("Unread notifications") + } +} + // MARK: - Accessibility Container /// Wraps the SwiftUI split tree in an AppKit view so we can expose an ordered @@ -347,6 +373,7 @@ struct TerminalSplitTreeView: View { struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree var focusedSurfaceID: UUID? + let hasNotification: (UUID) -> Bool let action: (TerminalSplitTreeView.Operation) -> Void func makeNSView(context: Context) -> TerminalSplitAXContainerView { @@ -359,6 +386,7 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { TerminalSplitTreeView( tree: tree, focusedSurfaceID: focusedSurfaceID, + hasNotification: hasNotification, action: action ) ), diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 0fe5ef13..0a2e2547 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -39,16 +39,23 @@ struct WorktreeTerminalTabsView: View { }, closeAll: { state.closeAllTabs() + }, + hasNotification: { tabId in + state.hasUnseenNotification(for: tabId) } ) if let selectedId = state.tabManager.selectedTabId { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in TerminalSplitTreeAXContainer( tree: state.splitTree(for: tabId), - focusedSurfaceID: state.focusedSurfaceId(in: tabId) - ) { operation in - state.performSplitOperation(operation, in: tabId) - } + focusedSurfaceID: state.focusedSurfaceId(in: tabId), + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) } } else { EmptyTerminalPaneView(message: "No terminals open") diff --git a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift new file mode 100644 index 00000000..d445ae64 --- /dev/null +++ b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift @@ -0,0 +1,100 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureJumpToLatestUnreadTests { + @Test func jumpToLatestUnreadSelectsWorktreeAndFocusesSurface() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + let tabID = TerminalTabID() + let surfaceID = UUID() + let notificationID = UUID() + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.snapshotPersistencePhase = .active + let focusedSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) + let readNotifications = LockIsolated<[(Worktree.ID, UUID)]>([]) + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.latestUnreadNotification = { + NotificationLocation( + worktreeID: worktree.id, + tabID: tabID, + surfaceID: surfaceID, + notificationID: notificationID + ) + } + $0.terminalClient.focusSurface = { worktreeID, targetSurfaceID in + focusedSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } + return true + } + $0.terminalClient.markNotificationRead = { worktreeID, targetNotificationID in + readNotifications.withValue { $0.append((worktreeID, targetNotificationID)) } + } + } + store.exhaustivity = .off + + await store.send(.jumpToLatestUnread) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + await store.finish() + + #expect(focusedSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) + #expect( + readNotifications.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(notificationID.uuidString)"]) + } + + @Test func systemNotificationTappedSelectsWorktreeAndFocusesSurface() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + let surfaceID = UUID() + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.snapshotPersistencePhase = .active + let focusedSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.focusSurface = { worktreeID, targetSurfaceID in + focusedSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } + return true + } + } + store.exhaustivity = .off + + await store.send(.systemNotificationTapped(worktreeID: worktree.id, surfaceID: surfaceID)) + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + } + await store.finish() + + #expect(focusedSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) + } + + private func makeWorktree() -> Worktree { + Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeRepository(worktrees: [Worktree]) -> Repository { + Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +} diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index a4dc29e0..a7a9482e 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -5,6 +5,13 @@ import Testing @testable import supacode +private struct SystemNotificationSend: Equatable { + let title: String + let body: String + let worktreeID: Worktree.ID? + let surfaceID: UUID? +} + @MainActor struct AppFeatureSystemNotificationTests { @Test(.dependencies) func firstTimeDeniedTurnsSystemNotificationsBackOffWithAlert() async { @@ -118,7 +125,8 @@ struct AppFeatureSystemNotificationTests { @Test(.dependencies) func notificationReceivedSendsSystemNotificationWhenEnabled() async { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = true - let sends = LockIsolated<[(String, String)]>([]) + let surfaceID = UUID() + let sends = LockIsolated<[SystemNotificationSend]>([]) let store = TestStore( initialState: AppFeature.State( settings: SettingsFeature.State(settings: globalSettings) @@ -126,8 +134,17 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.systemNotificationClient.send = { title, body in - sends.withValue { $0.append((title, body)) } + $0.systemNotificationClient.send = { title, body, worktreeID, targetSurfaceID in + sends.withValue { + $0.append( + SystemNotificationSend( + title: title, + body: body, + worktreeID: worktreeID, + surfaceID: targetSurfaceID + ) + ) + } } } store.exhaustivity = .off @@ -136,6 +153,7 @@ struct AppFeatureSystemNotificationTests { .terminalEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", + surfaceID: surfaceID, title: "Done", body: "Build succeeded" ) @@ -144,8 +162,10 @@ struct AppFeatureSystemNotificationTests { await store.finish() #expect(sends.value.count == 1) - #expect(sends.value.first?.0 == "Done") - #expect(sends.value.first?.1 == "Build succeeded") + #expect(sends.value.first?.title == "Done") + #expect(sends.value.first?.body == "Build succeeded") + #expect(sends.value.first?.worktreeID == "/tmp/repo/wt-1") + #expect(sends.value.first?.surfaceID == surfaceID) } @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenSystemNotificationsEnabled() async { @@ -170,6 +190,7 @@ struct AppFeatureSystemNotificationTests { .terminalEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), title: "Done", body: "Build succeeded" ) @@ -196,7 +217,7 @@ struct AppFeatureSystemNotificationTests { $0.notificationSoundClient.play = { plays.withValue { $0 += 1 } } - $0.systemNotificationClient.send = { _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } } @@ -206,6 +227,7 @@ struct AppFeatureSystemNotificationTests { .terminalEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), title: "Done", body: "Build succeeded" ) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4b8c3a1d..1d76bb47 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -214,6 +214,99 @@ struct WorktreeTerminalManagerTests { #expect(manager.hasUnseenNotifications(for: worktree.id) == false) } + @Test func markNotificationReadOnlyAffectsMatchingID() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let notificationA = UUID() + let notificationB = UUID() + let surfaceID = UUID() + + state.notifications = [ + makeNotification(id: notificationA, surfaceId: surfaceID, isRead: false), + makeNotification(id: notificationB, surfaceId: surfaceID, isRead: false), + ] + + state.markNotificationRead(id: notificationB) + + #expect(state.notifications.map(\.isRead) == [false, true]) + #expect(manager.hasUnseenNotifications(for: worktree.id) == true) + } + + @Test func latestUnreadNotificationLocationChoosesNewestFocusableAcrossWorktrees() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktreeA = makeWorktree(id: "/tmp/repo/wt-a", name: "wt-a") + let worktreeB = makeWorktree(id: "/tmp/repo/wt-b", name: "wt-b") + let stateA = manager.state(for: worktreeA) + let stateB = manager.state(for: worktreeB) + let tabA = stateA.createTab()! + let tabB = stateB.createTab()! + let surfaceA = stateA.focusedSurfaceId(in: tabA)! + let surfaceB = stateB.focusedSurfaceId(in: tabB)! + let notificationA = UUID() + let notificationB = UUID() + + stateA.notifications = [ + makeNotification( + id: notificationA, + surfaceId: surfaceA, + createdAt: Date(timeIntervalSince1970: 10), + isRead: false + ) + ] + stateB.notifications = [ + makeNotification( + id: notificationB, + surfaceId: surfaceB, + createdAt: Date(timeIntervalSince1970: 20), + isRead: false + ) + ] + + #expect( + manager.latestUnreadNotificationLocation() + == NotificationLocation( + worktreeID: worktreeB.id, + tabID: tabB, + surfaceID: surfaceB, + notificationID: notificationB + ) + ) + } + + @Test func latestUnreadNotificationLocationSkipsClosedSurfaces() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let tabID = state.createTab()! + let surfaceID = state.focusedSurfaceId(in: tabID)! + let focusableNotification = UUID() + + state.notifications = [ + makeNotification( + surfaceId: UUID(), + createdAt: Date(timeIntervalSince1970: 20), + isRead: false + ), + makeNotification( + id: focusableNotification, + surfaceId: surfaceID, + createdAt: Date(timeIntervalSince1970: 10), + isRead: false + ), + ] + + #expect( + manager.latestUnreadNotificationLocation() + == NotificationLocation( + worktreeID: worktree.id, + tabID: tabID, + surfaceID: surfaceID, + notificationID: focusableNotification + ) + ) + } + @Test func setNotificationsDisabledMarksAllRead() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() @@ -314,12 +407,15 @@ struct WorktreeTerminalManagerTests { #expect(clearCount.value == 1) } - private func makeWorktree() -> Worktree { + private func makeWorktree( + id: Worktree.ID = "/tmp/repo/wt-1", + name: String = "wt-1" + ) -> Worktree { Worktree( - id: "/tmp/repo/wt-1", - name: "wt-1", + id: id, + name: name, detail: "detail", - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + workingDirectory: URL(fileURLWithPath: id), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ) } @@ -335,13 +431,17 @@ struct WorktreeTerminalManagerTests { } private func makeNotification( + id: UUID = UUID(), surfaceId: UUID = UUID(), + createdAt: Date = .distantPast, isRead: Bool ) -> WorktreeTerminalNotification { WorktreeTerminalNotification( + id: id, surfaceId: surfaceId, title: "Title", body: "Body", + createdAt: createdAt, isRead: isRead ) } -- 2.51.2 From 3facc552e928a040949a9b411378480c7cb92a4a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 7 May 2026 23:44:18 +0900 Subject: [PATCH 051/528] Align inactive split dimming with Ghostty config --- .../Canvas/Views/CanvasCardView.swift | 6 ++- .../Features/Canvas/Views/CanvasView.swift | 9 +++- .../Shelf/Views/ShelfOpenBookView.swift | 16 +++++-- .../WorktreeTerminalManager.swift | 6 +++ .../Models/WorktreeTerminalState.swift | 19 +++++--- .../Views/TerminalSplitTreeView.swift | 45 +++++++++--------- .../Views/WorktreeTerminalTabsView.swift | 16 +++++-- .../Ghostty/GhosttyRuntime.swift | 25 ++++++++++ supacodeTests/SplitTreeTests.swift | 46 +++++++++++++++++++ 9 files changed, 149 insertions(+), 39 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 2e57b798..21fa173c 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -16,7 +16,8 @@ struct CanvasCardView: View { /// PNG/SVG filenames against the per-repo icons directory. var repositoryRootURL: URL? let tree: SplitTree - let focusedSurfaceID: UUID? + let activeSurfaceID: UUID? + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let isFocused: Bool let isSelected: Bool let hasUnseenNotification: Bool @@ -229,7 +230,8 @@ struct CanvasCardView: View { TerminalSplitTreeView( tree: tree, pinnedSize: cardSize, - focusedSurfaceID: focusedSurfaceID, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, action: onSplitOperation ) .frame(width: cardSize.width, height: cardSize.height) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a2e95992..4b5e8ba7 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -26,6 +26,7 @@ struct CanvasView: View { @State private var hasPerformedInitialFit = false @State private var viewportSize: CGSize = .zero @State private var showsCanvasHelp = false + @State private var configReloadCounter = 0 private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -43,6 +44,7 @@ struct CanvasView: View { for: AppShortcuts.CommandID.selectAllCanvasCards, in: resolvedKeybindings ) + let _ = configReloadCounter CanvasScrollContainer( offset: $canvasOffset, lastOffset: $lastCanvasOffset, @@ -88,6 +90,7 @@ struct CanvasView: View { let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) let screenCenter = screenPosition(for: resized.center) let cardTotalHeight = resized.size.height + titleBarHeight + let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() let repositoryAppearance = appearance(for: state.repositoryRootURL) let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) @@ -98,7 +101,8 @@ struct CanvasView: View { repositoryColor: repositoryAppearance.color?.color, repositoryRootURL: state.repositoryRootURL, tree: tree, - focusedSurfaceID: state.focusedSurfaceId(in: tab.id), + activeSurfaceID: state.activeSurfaceID(for: tab.id), + unfocusedSplitOverlay: unfocusedSplitOverlay, isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), @@ -201,6 +205,9 @@ struct CanvasView: View { return .handled } .task { activateCanvas() } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in + configReloadCounter &+= 1 + } .onDisappear { deactivateCanvas() } } diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index 2bd0692d..33a7c540 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -19,18 +19,23 @@ struct ShelfOpenBookView: View { let forceAutoFocus: Bool @State private var windowActivity = WindowActivityState.inactive + @State private var configReloadCounter = 0 var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } + let _ = configReloadCounter + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() Group { if let selectedId = state.tabManager.selectedTabId { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in TerminalSplitTreeAXContainer( tree: state.splitTree(for: tabId), - focusedSurfaceID: state.focusedSurfaceId(in: tabId) - ) { operation in - state.performSplitOperation(operation, in: tabId) - } + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) } } else { EmptyTerminalPaneView(message: "No terminals open") @@ -87,6 +92,9 @@ struct ShelfOpenBookView: View { state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in + configReloadCounter &+= 1 + } } private var shouldAutoFocusTerminal: Bool { diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 89b66305..add7019d 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1,6 +1,7 @@ import Foundation import Observation import Sharing +import SwiftUI private let terminalLogger = SupaLogger("Terminal") private let layoutRestoreFailureMessage = "Saved terminal layout was invalid and has been reset" @@ -437,6 +438,11 @@ final class WorktreeTerminalManager { runtime?.backgroundOpacity() ?? 1.0 } + func unfocusedSplitOverlay() -> (fill: Color?, opacity: Double) { + guard let runtime else { return (nil, 0) } + return (runtime.unfocusedSplitFill(), runtime.unfocusedSplitOverlayOpacity()) + } + func syncPreferredFontSize(from worktreeID: Worktree.ID) { guard let state = states[worktreeID] else { return } let fontSize = state.focusedFontSize() diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8c52fcc1..dc215e6b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -576,6 +576,10 @@ final class WorktreeTerminalState { focusedSurfaceIdByTab[tabId] } + func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { + focusedSurfaceIdByTab[tabId] + } + /// Marks a surface so that its next successful `command_finished` event (exit 0) /// will trigger a one-shot close of that surface. func markSurfaceForAutoClose(_ surfaceId: UUID) { @@ -1090,10 +1094,7 @@ final class WorktreeTerminalState { private func configureSurfaceCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { view.onFocusChange = { [weak self, weak view] focused in guard let self, let view, focused else { return } - self.focusedSurfaceIdByTab[tabId] = view.id - self.markNotificationsRead(forSurfaceID: view.id) - self.updateTabTitle(for: tabId) - self.emitFocusChangedIfNeeded(view.id) + self.recordActiveSurface(view, in: tabId) self.emitTaskStatusIfChanged() } view.onKeyInput = { [weak self, weak view] in @@ -1279,12 +1280,16 @@ final class WorktreeTerminalState { private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } - focusedSurfaceIdByTab[tabId] = surface.id - markNotificationsRead(forSurfaceID: surface.id) - updateTabTitle(for: tabId) + recordActiveSurface(surface, in: tabId) guard tabId == tabManager.selectedTabId else { return } let fromSurface = (previousSurface === surface) ? nil : previousSurface GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) + } + + private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { + focusedSurfaceIdByTab[tabId] = surface.id + markNotificationsRead(forSurfaceID: surface.id) + updateTabTitle(for: tabId) emitFocusChangedIfNeeded(surface.id) } diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 4724dbff..29e014ad 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -6,7 +6,8 @@ import UniformTypeIdentifiers struct TerminalSplitTreeView: View { let tree: SplitTree var pinnedSize: CGSize? - var focusedSurfaceID: UUID? + var activeSurfaceID: UUID? + var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (Operation) -> Void private static let dragType = UTType(exportedAs: "com.onevcat.prowl.ghosttySurfaceId") @@ -29,7 +30,8 @@ struct TerminalSplitTreeView: View { node: node, isRoot: node == tree.root, pinnedSize: pinnedSize, - focusedSurfaceID: focusedSurfaceID, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, action: action ) .id(node.structuralIdentity) @@ -46,7 +48,8 @@ struct TerminalSplitTreeView: View { let node: SplitTree.Node var isRoot: Bool = false var pinnedSize: CGSize? - var focusedSurfaceID: UUID? + var activeSurfaceID: UUID? + var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (Operation) -> Void var body: some View { @@ -55,7 +58,8 @@ struct TerminalSplitTreeView: View { LeafView( surfaceView: leafView, isSplit: !isRoot, - isFocused: leafView.id == focusedSurfaceID, + isFocused: leafView.id == activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, pinnedSize: pinnedSize, action: action ) @@ -84,7 +88,8 @@ struct TerminalSplitTreeView: View { SubtreeView( node: split.left, pinnedSize: leftPinned, - focusedSurfaceID: focusedSurfaceID, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, action: action ) }, @@ -92,7 +97,8 @@ struct TerminalSplitTreeView: View { SubtreeView( node: split.right, pinnedSize: rightPinned, - focusedSurfaceID: focusedSurfaceID, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, action: action ) }, @@ -119,22 +125,19 @@ struct TerminalSplitTreeView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool var isFocused: Bool = true + var unfocusedSplitOverlay: (fill: Color?, opacity: Double) var pinnedSize: CGSize? let action: (Operation) -> Void @State private var dropState: DropState = .idle @Shared(.settingsFile) private var settingsFile: SettingsFile - @Environment(\.colorScheme) private var colorScheme private var shouldDim: Bool { - isSplit && !isFocused && settingsFile.global.dimUnfocusedSplits - } - - /// Lighter tint in light mode so the inactive pane reads as faded - /// rather than washed in grey; dark mode's deeper tint matches the - /// effect users see in Ghostty.app. - private var dimOpacity: Double { - colorScheme == .dark ? 0.3 : 0.12 + isSplit + && !isFocused + && settingsFile.global.dimUnfocusedSplits + && unfocusedSplitOverlay.fill != nil + && unfocusedSplitOverlay.opacity > 0 } var body: some View { @@ -142,10 +145,8 @@ struct TerminalSplitTreeView: View { GhosttyTerminalView(surfaceView: surfaceView, pinnedSize: pinnedSize) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { - // Mirrors Ghostty's `unfocused-split-fill`/`unfocused-split-opacity`: - // a translucent black tint fades the inactive pane. - Color.black - .opacity(shouldDim ? dimOpacity : 0) + unfocusedSplitOverlay.fill + .opacity(shouldDim ? unfocusedSplitOverlay.opacity : 0) .allowsHitTesting(false) .animation(.easeOut(duration: 0.12), value: shouldDim) } @@ -346,7 +347,8 @@ struct TerminalSplitTreeView: View { /// list of terminal panes to assistive technologies. struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree - var focusedSurfaceID: UUID? + var activeSurfaceID: UUID? + var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (TerminalSplitTreeView.Operation) -> Void func makeNSView(context: Context) -> TerminalSplitAXContainerView { @@ -358,7 +360,8 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { rootView: AnyView( TerminalSplitTreeView( tree: tree, - focusedSurfaceID: focusedSurfaceID, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, action: action ) ), diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 0fe5ef13..f230246e 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -8,9 +8,12 @@ struct WorktreeTerminalTabsView: View { let forceAutoFocus: Bool let createTab: () -> Void @State private var windowActivity = WindowActivityState.inactive + @State private var configReloadCounter = 0 var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } + let _ = configReloadCounter + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() VStack(spacing: 0) { TerminalTabBarView( manager: state.tabManager, @@ -45,10 +48,12 @@ struct WorktreeTerminalTabsView: View { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in TerminalSplitTreeAXContainer( tree: state.splitTree(for: tabId), - focusedSurfaceID: state.focusedSurfaceId(in: tabId) - ) { operation in - state.performSplitOperation(operation, in: tabId) - } + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) } } else { EmptyTerminalPaneView(message: "No terminals open") @@ -94,6 +99,9 @@ struct WorktreeTerminalTabsView: View { let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in + configReloadCounter &+= 1 + } } private var shouldAutoFocusTerminal: Bool { diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index 9b289aca..ce89df2c 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -943,6 +943,31 @@ final class GhosttyRuntime { return min(max(value, 0.001), 1) } + func unfocusedSplitOverlayOpacity() -> Double { + guard let config else { return 0 } + var value: Double = 0.85 + let key = "unfocused-split-opacity" + _ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) + return min(max(1 - value, 0), 1) + } + + func unfocusedSplitFill() -> Color? { + guard let config else { return nil } + var color = ghostty_config_color_s() + let fillKey = "unfocused-split-fill" + if ghostty_config_get(config, &color, fillKey, UInt(fillKey.lengthOfBytes(using: .utf8))) { + return Color(nsColor: NSColor(ghostty: color)) + } + let backgroundKey = "background" + if ghostty_config_get(config, &color, backgroundKey, UInt(backgroundKey.lengthOfBytes(using: .utf8))) { + return Color(nsColor: NSColor(ghostty: color)) + } + ghosttyLogger.warning( + "Ghostty config missing both 'unfocused-split-fill' and 'background'; skipping unfocused split overlay." + ) + return nil + } + func backgroundColor() -> NSColor { backgroundColorFromConfig() ?? NSColor.windowBackgroundColor } diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index 19bb74a2..50fa512a 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -5,6 +5,39 @@ import Testing @MainActor struct SplitTreeTests { + @Test func activeSurfaceTracksClickAndGotoSplitPaths() throws { + let state = makeWorktreeTerminalState() + let tabId = try #require(state.createTab()) + let firstID = try #require(state.focusedSurfaceId(in: tabId)) + let first = try #require(state.surfaceView(for: firstID)) + + #expect(state.performSplitAction(.newSplit(direction: .right), for: firstID)) + let secondID = try #require(state.focusedSurfaceId(in: tabId)) + let second = try #require(state.surfaceView(for: secondID)) + + var emissions: [UUID] = [] + state.onFocusChanged = { emissions.append($0) } + + #expect(state.activeSurfaceID(for: tabId) == second.id) + + first.onFocusChange?(true) + #expect(state.activeSurfaceID(for: tabId) == first.id) + + first.onFocusChange?(true) + #expect(state.activeSurfaceID(for: tabId) == first.id) + + #expect(state.performSplitAction(.gotoSplit(direction: .next), for: first.id)) + #expect(state.activeSurfaceID(for: tabId) == second.id) + + #expect(state.focusSurface(id: second.id)) + #expect(state.activeSurfaceID(for: tabId) == second.id) + + second.onFocusChange?(false) + #expect(state.activeSurfaceID(for: tabId) == second.id) + + #expect(emissions == [first.id, second.id]) + } + @Test func focusTargetAfterClosingUsesNextForLeftmostLeaf() throws { let first = SplitTreeTestView() let second = SplitTreeTestView() @@ -44,6 +77,19 @@ struct SplitTreeTests { #expect(visibleLeaves.count == 1) #expect(visibleLeaves.first === second) } + + private func makeWorktreeTerminalState() -> WorktreeTerminalState { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + ) + } } private final class SplitTreeTestView: NSView, Identifiable { -- 2.51.2 From f72ed4cdf921cd8f6b7fa94c27b23d63b437acc6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 7 May 2026 23:54:55 +0900 Subject: [PATCH 052/528] Add persistent custom tab titles --- supacode/CLIService/TargetResolver.swift | 2 +- .../Features/Canvas/Views/CanvasView.swift | 2 +- .../Features/Shelf/Views/ShelfSpineView.swift | 4 +- .../TerminalLayoutSnapshotPayload.swift | 18 +++++ .../Terminal/Models/TerminalTabItem.swift | 5 ++ .../Terminal/Models/TerminalTabManager.swift | 30 +++++++-- .../Models/WorktreeTerminalState.swift | 17 ++--- .../TabBar/Views/TerminalTabBarView.swift | 4 +- .../TabBar/Views/TerminalTabContextMenu.swift | 10 ++- .../Views/TerminalTabContextMenuActions.swift | 2 +- .../TabBar/Views/TerminalTabLabelView.swift | 2 +- .../TabBar/Views/TerminalTabView.swift | 65 ++++++++++++++++++- .../TabBar/Views/TerminalTabsRowView.swift | 17 ++++- .../TabBar/Views/TerminalTabsView.swift | 4 +- .../Views/WorktreeTerminalTabsView.swift | 4 +- .../TerminalLayoutSnapshotPayloadTests.swift | 16 ++++- supacodeTests/TerminalTabManagerTests.swift | 44 +++++++++++++ .../WorktreeTerminalManagerTests.swift | 43 ++++++++++++ 18 files changed, 254 insertions(+), 35 deletions(-) diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index 6a539c88..12084940 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -260,7 +260,7 @@ enum TargetResolutionSnapshotBuilder { guard let snapshot else { return nil } return TargetResolutionSnapshot.Tab( id: tab.id.rawValue, - title: tab.title, + title: tab.displayTitle, selected: tab.id == selectedTabID, panes: snapshot.panes, focusedPaneID: snapshot.focusedPaneID diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a2e95992..ef935fb5 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -93,7 +93,7 @@ struct CanvasView: View { let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) CanvasCardView( repositoryName: resolvedRepositoryName, - worktreeName: tab.title, + worktreeName: tab.displayTitle, repositoryIcon: repositoryAppearance.icon, repositoryColor: repositoryAppearance.color?.color, repositoryRootURL: state.repositoryRootURL, diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 90bcc0d2..4a3c19b0 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -270,7 +270,7 @@ struct ShelfSpineView: View { tabId: tab.id, tabs: terminalState.tabManager.tabs, actions: TerminalTabContextMenuActions( - changeTitle: { terminalState.promptChangeTabTitle($0) }, + renameTab: { terminalState.promptChangeTabTitle($0) }, changeIcon: { terminalState.presentIconPicker(for: $0) }, closeTab: { terminalState.closeTab($0) }, closeOthers: { terminalState.closeOtherTabs(keeping: $0) }, @@ -449,7 +449,7 @@ private struct ShelfSpineTabSlot: View { .onHover { hovering in isHovering = hovering } - .help(tab.title) + .help(tab.displayTitle) } /// When ⌘ is held AND this tab has a `Cmd+N` hotkey, swap the icon diff --git a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift index a1494751..0d0050d7 100644 --- a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift +++ b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift @@ -100,13 +100,31 @@ extension TerminalLayoutSnapshotPayload { nonisolated struct SnapshotTab: Codable, Equatable, Sendable { let tabID: String let title: String? + let customTitle: String? let icon: String? let splitRoot: SnapshotSplitNode + init( + tabID: String, + title: String?, + customTitle: String? = nil, + icon: String?, + splitRoot: SnapshotSplitNode + ) { + self.tabID = tabID + self.title = title + self.customTitle = customTitle + self.icon = icon + self.splitRoot = splitRoot + } + func isValid(maxSplitNodesPerTab: Int, maxSplitDepth: Int) -> Bool { guard hasContent(tabID) else { return false } + if let customTitle, !hasContent(customTitle) { + return false + } var nodeCount = 0 return splitRoot.isValid( depth: 1, diff --git a/supacode/Features/Terminal/Models/TerminalTabItem.swift b/supacode/Features/Terminal/Models/TerminalTabItem.swift index f6105128..d59748a8 100644 --- a/supacode/Features/Terminal/Models/TerminalTabItem.swift +++ b/supacode/Features/Terminal/Models/TerminalTabItem.swift @@ -21,14 +21,18 @@ enum TerminalTabIconLock: Equatable, Sendable { struct TerminalTabItem: Identifiable, Equatable, Sendable { let id: TerminalTabID var title: String + var customTitle: String? var icon: String? var isDirty: Bool var isTitleLocked: Bool var iconLock: TerminalTabIconLock + var displayTitle: String { customTitle ?? title } + init( id: TerminalTabID = TerminalTabID(), title: String, + customTitle: String? = nil, icon: String?, isDirty: Bool = false, isTitleLocked: Bool = false, @@ -36,6 +40,7 @@ struct TerminalTabItem: Identifiable, Equatable, Sendable { ) { self.id = id self.title = title + self.customTitle = customTitle self.icon = icon self.isDirty = isDirty self.isTitleLocked = isTitleLocked diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index e077f2a7..c6bb8759 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -1,10 +1,17 @@ +import Foundation import Observation @MainActor @Observable final class TerminalTabManager { - var tabs: [TerminalTabItem] = [] + var tabs: [TerminalTabItem] = [] { + didSet { + guard let editingTabID, !tabs.contains(where: { $0.id == editingTabID }) else { return } + self.editingTabID = nil + } + } var selectedTabId: TerminalTabID? + private(set) var editingTabID: TerminalTabID? func createTab(title: String, icon: String?, isTitleLocked: Bool = false) -> TerminalTabID { let tab = TerminalTabItem(title: title, icon: icon, isTitleLocked: isTitleLocked) @@ -31,14 +38,27 @@ final class TerminalTabManager { } func overrideTitle(_ id: TerminalTabID, title: String) { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - tabs[index].title = title - tabs[index].isTitleLocked = true + setCustomTitle(id, title: title) } func clearTitleOverride(_ id: TerminalTabID) { + setCustomTitle(id, title: "") + } + + func setCustomTitle(_ id: TerminalTabID, title: String) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - tabs[index].isTitleLocked = false + guard !tabs[index].isTitleLocked else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + tabs[index].customTitle = trimmed.isEmpty ? nil : trimmed + } + + func beginTabRename(_ id: TerminalTabID) { + guard tabs.contains(where: { $0.id == id && !$0.isTitleLocked }) else { return } + editingTabID = id + } + + func endTabRename() { + editingTabID = nil } /// Auto-detection write path (e.g. `CommandIconMap`). Only applies diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8c52fcc1..64efbcfb 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -783,6 +783,7 @@ final class WorktreeTerminalState { TerminalLayoutSnapshotPayload.SnapshotTab( tabID: tab.id.rawValue.uuidString, title: isBlockingScriptTab ? nil : tab.title, + customTitle: isBlockingScriptTab ? nil : tab.customTitle, icon: snapshotIcon, splitRoot: splitRoot ) @@ -870,8 +871,9 @@ final class WorktreeTerminalState { TerminalTabItem( id: entry.tabID, title: entry.snapshotTab.title ?? "\(worktree.name) \(index + 1)", + customTitle: entry.snapshotTab.customTitle, icon: entry.snapshotTab.icon ?? "terminal", - isTitleLocked: entry.snapshotTab.title != nil, + isTitleLocked: false, iconLock: entry.snapshotTab.icon != nil ? .user : .auto ) ) @@ -1236,7 +1238,7 @@ final class WorktreeTerminalState { alert.alertStyle = .informational let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) - textField.stringValue = tabManager.tabs[tabIndex].title + textField.stringValue = tabManager.tabs[tabIndex].displayTitle alert.accessoryView = textField alert.addButton(withTitle: "OK") @@ -1248,12 +1250,7 @@ final class WorktreeTerminalState { guard response == .alertFirstButtonReturn else { return } guard let self else { return } let newTitle = textField.stringValue - if newTitle.isEmpty { - self.tabManager.clearTitleOverride(tabId) - self.updateTabTitle(for: tabId) - } else { - self.tabManager.overrideTitle(tabId, title: newTitle) - } + self.tabManager.setCustomTitle(tabId, title: newTitle) } } } @@ -1857,13 +1854,13 @@ extension WorktreeTerminalState { context: GHOSTTY_SURFACE_CONTEXT_TAB ).workingDirectory?.path(percentEncoded: false) - let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.title) + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.displayTitle) return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) } return CLITerminalTabSnapshot( id: tab.id.rawValue, - title: tab.title, + title: tab.displayTitle, selected: tab.id == selectedTabID, focusedPaneID: focusedSurfaceIdByTab[tab.id], panes: panes diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index f880165e..ce9e463c 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -6,7 +6,7 @@ struct TerminalTabBarView: View { let splitHorizontally: () -> Void let splitVertically: () -> Void let canSplit: Bool - let changeTitle: (TerminalTabID) -> Void + let renameTab: (TerminalTabID) -> Void let changeIcon: (TerminalTabID) -> Void let closeTab: (TerminalTabID) -> Void let closeOthers: (TerminalTabID) -> Void @@ -19,7 +19,7 @@ struct TerminalTabBarView: View { HStack(spacing: 0) { TerminalTabsView( manager: manager, - changeTitle: changeTitle, + renameTab: renameTab, changeIcon: changeIcon, closeTab: closeTab, closeOthers: closeOthers, diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift index 156448dc..250eab80 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift @@ -23,8 +23,10 @@ struct TerminalTabContextMenu: ViewModifier { func body(content: Content) -> some View { content.contextMenu { - Button("Change Tab Title...") { - actions.changeTitle(tabId) + if let currentTab, !currentTab.isTitleLocked { + Button("Rename Tab") { + actions.renameTab(tabId) + } } Button("Change Tab Icon...") { @@ -57,4 +59,8 @@ struct TerminalTabContextMenu: ViewModifier { guard let last = tabs.last else { return true } return last.id == tabId } + + private var currentTab: TerminalTabItem? { + tabs.first { $0.id == tabId } + } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift index 8da1247b..ac57372a 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift @@ -1,5 +1,5 @@ struct TerminalTabContextMenuActions { - let changeTitle: (TerminalTabID) -> Void + let renameTab: (TerminalTabID) -> Void let changeIcon: (TerminalTabID) -> Void let closeTab: (TerminalTabID) -> Void let closeOthers: (TerminalTabID) -> Void diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index ea2b687a..c7e53c0d 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -27,7 +27,7 @@ struct TerminalTabLabelView: View { ) .accessibilityHidden(true) } - Text(tab.title) + Text(tab.displayTitle) .font(.caption) .lineLimit(1) .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 7ba90640..55fab6ba 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -9,11 +9,19 @@ struct TerminalTabView: View { let fixedWidth: CGFloat? let onSelect: () -> Void let onClose: () -> Void + let onRename: (String) -> Void @Binding var closeButtonGestureActive: Bool + let isEditing: Bool + let onBeginRename: () -> Void + let onEndRename: () -> Void @State private var isHovering = false @State private var isHoveringClose = false @State private var isPressing = false + @State private var editingTitle = "" + @State private var initialEditingTitle = "" + @State private var cancelOnExit = false + @FocusState private var isFieldFocused: Bool @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings @@ -38,8 +46,10 @@ struct TerminalTabView: View { ) .frame(width: fixedWidth) .contentShape(.rect) - .help("Open tab \(tab.title)") - .accessibilityLabel(tab.title) + .help("Open tab \(tab.displayTitle)") + .accessibilityLabel(tab.displayTitle) + .allowsHitTesting(!isEditing) + .opacity(isEditing ? 0 : 1) TerminalTabCloseButton( isHoveringTab: isHovering, @@ -50,6 +60,33 @@ struct TerminalTabView: View { isHoveringClose: $isHoveringClose ) .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) + .opacity(isEditing ? 0 : 1) + .allowsHitTesting(!isEditing) + } + .overlay { + if isEditing { + TextField("", text: $editingTitle) + .textFieldStyle(.plain) + .font(.caption) + .focused($isFieldFocused) + .foregroundStyle(TerminalTabBarColors.activeText) + .accessibilityLabel("Rename tab") + .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) + .padding( + .trailing, + TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .onSubmit { onEndRename() } + .onExitCommand { + cancelOnExit = true + onEndRename() + } + .onChange(of: isFieldFocused) { _, focused in + guard !focused, isEditing else { return } + onEndRename() + } + } } .background { TerminalTabBackground( @@ -67,6 +104,30 @@ struct TerminalTabView: View { .onHover { hovering in isHovering = hovering } + .simultaneousGesture( + TapGesture(count: 2).onEnded { + guard !tab.isTitleLocked else { return } + onBeginRename() + } + ) + .onChange(of: isEditing) { _, editing in + if editing { + editingTitle = tab.displayTitle + initialEditingTitle = tab.displayTitle + cancelOnExit = false + isFieldFocused = true + } else if cancelOnExit { + cancelOnExit = false + } else if editingTitle != initialEditingTitle { + onRename(editingTitle) + } + } + .onDisappear { + guard isEditing else { return } + defer { onEndRename() } + guard !cancelOnExit, editingTitle != initialEditingTitle else { return } + onRename(editingTitle) + } .zIndex(isActive ? 2 : (isDragging ? 3 : 0)) .overlay { MiddleClickView(action: onClose) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index 771a2b5d..e865a424 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -8,7 +8,7 @@ struct TerminalTabsRowView: View { @Binding var draggingStartLocation: CGFloat? @Binding var closeButtonGestureActive: Bool let fixedTabWidth: CGFloat? - let changeTitle: (TerminalTabID) -> Void + let renameTab: (TerminalTabID) -> Void let changeIcon: (TerminalTabID) -> Void let closeTab: (TerminalTabID) -> Void let closeOthers: (TerminalTabID) -> Void @@ -36,7 +36,18 @@ struct TerminalTabsRowView: View { onClose: { closeTab(id) }, - closeButtonGestureActive: $closeButtonGestureActive + onRename: { newTitle in + manager.setCustomTitle(id, title: newTitle) + }, + closeButtonGestureActive: $closeButtonGestureActive, + isEditing: manager.editingTabID == id, + onBeginRename: { + manager.beginTabRename(id) + }, + onEndRename: { + guard manager.editingTabID == id else { return } + manager.endTabRename() + } ) .background( TerminalTabMeasurementView( @@ -51,7 +62,7 @@ struct TerminalTabsRowView: View { tabId: id, tabs: manager.tabs, actions: TerminalTabContextMenuActions( - changeTitle: changeTitle, + renameTab: renameTab, changeIcon: changeIcon, closeTab: closeTab, closeOthers: closeOthers, diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift index 04b60589..728466e6 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift @@ -2,7 +2,7 @@ import SwiftUI struct TerminalTabsView: View { @Bindable var manager: TerminalTabManager - let changeTitle: (TerminalTabID) -> Void + let renameTab: (TerminalTabID) -> Void let changeIcon: (TerminalTabID) -> Void let closeTab: (TerminalTabID) -> Void let closeOthers: (TerminalTabID) -> Void @@ -30,7 +30,7 @@ struct TerminalTabsView: View { draggingStartLocation: $draggingStartLocation, closeButtonGestureActive: $closeButtonGestureActive, fixedTabWidth: effectiveTabWidth, - changeTitle: changeTitle, + renameTab: renameTab, changeIcon: changeIcon, closeTab: closeTab, closeOthers: closeOthers, diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 0fe5ef13..5994b157 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -22,8 +22,8 @@ struct WorktreeTerminalTabsView: View { _ = state.performBindingActionOnFocusedSurface("new_split:right") }, canSplit: state.tabManager.selectedTabId != nil, - changeTitle: { tabId in - state.promptChangeTabTitle(tabId) + renameTab: { tabId in + state.tabManager.beginTabRename(tabId) }, changeIcon: { tabId in state.presentIconPicker(for: tabId) diff --git a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift index 70a5ffd1..0b109cd8 100644 --- a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift @@ -12,6 +12,16 @@ struct TerminalLayoutSnapshotPayloadTests { #expect(decoded == payload) } + @Test func decodeValidatedRoundTripsTabCustomTitle() throws { + let payload = makePayload(title: "live", customTitle: "Pinned") + let data = try JSONEncoder().encode(payload) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + + #expect(decoded == payload) + #expect(decoded?.worktrees.first?.tabs.first?.customTitle == "Pinned") + } + @Test func decodeValidatedRoundTripsLeafCwdPath() throws { let payload = makePayload(splitRoot: .leaf(surfaceID: "surface-1", cwdPath: "/tmp/repo/wt-1/src")) let data = try JSONEncoder().encode(payload) @@ -309,6 +319,8 @@ private func makePayload( version: Int = TerminalLayoutSnapshotPayload.currentVersion, worktreeID: String = "wt-1", tabID: String = "tab-1", + title: String? = nil, + customTitle: String? = nil, splitRoot: TerminalLayoutSnapshotPayload.SnapshotSplitNode = .leaf(surfaceID: "surface-1") ) -> TerminalLayoutSnapshotPayload { TerminalLayoutSnapshotPayload( @@ -318,7 +330,7 @@ private func makePayload( worktreeID: worktreeID, selectedTabID: tabID, tabs: [ - makeTab(tabID: tabID, splitRoot: splitRoot) + makeTab(tabID: tabID, title: title, customTitle: customTitle, splitRoot: splitRoot) ] ) ] @@ -340,12 +352,14 @@ private func makeWorktree( private func makeTab( tabID: String, title: String? = nil, + customTitle: String? = nil, icon: String? = nil, splitRoot: TerminalLayoutSnapshotPayload.SnapshotSplitNode = .leaf(surfaceID: "surface-1") ) -> TerminalLayoutSnapshotPayload.SnapshotTab { TerminalLayoutSnapshotPayload.SnapshotTab( tabID: tabID, title: title, + customTitle: customTitle, icon: icon, splitRoot: splitRoot ) diff --git a/supacodeTests/TerminalTabManagerTests.swift b/supacodeTests/TerminalTabManagerTests.swift index c358f9d7..fd9c07f6 100644 --- a/supacodeTests/TerminalTabManagerTests.swift +++ b/supacodeTests/TerminalTabManagerTests.swift @@ -4,6 +4,50 @@ import Testing @MainActor struct TerminalTabManagerTests { + @Test func customTitleOverridesDisplayTitleWithoutFreezingLiveTitle() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "shell", icon: nil) + + manager.setCustomTitle(tabId, title: " build ") + manager.updateTitle(tabId, title: "npm test") + + #expect(manager.tabs.first?.title == "npm test") + #expect(manager.tabs.first?.customTitle == "build") + #expect(manager.tabs.first?.displayTitle == "build") + } + + @Test func clearingCustomTitleRestoresLiveShellTitle() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "shell", icon: nil) + + manager.setCustomTitle(tabId, title: "build") + manager.updateTitle(tabId, title: "npm test") + manager.setCustomTitle(tabId, title: " ") + + #expect(manager.tabs.first?.customTitle == nil) + #expect(manager.tabs.first?.displayTitle == "npm test") + } + + @Test func customTitleIgnoresLockedTabs() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "RUN SCRIPT", icon: "play.fill", isTitleLocked: true) + + manager.setCustomTitle(tabId, title: "build") + + #expect(manager.tabs.first?.customTitle == nil) + #expect(manager.tabs.first?.displayTitle == "RUN SCRIPT") + } + + @Test func editingTabIDIsDroppedWhenTabCloses() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "one", icon: nil) + + manager.beginTabRename(tabId) + manager.closeTab(tabId) + + #expect(manager.editingTabID == nil) + } + @Test func createTabInsertsAfterSelection() { let manager = TerminalTabManager() let first = manager.createTab(title: "one", icon: nil) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4b8c3a1d..5b0d8486 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -246,6 +246,49 @@ struct WorktreeTerminalManagerTests { #expect(manager.hasUnseenNotifications(for: worktree.id) == false) } + @Test func makeLayoutSnapshotPersistsCustomTabTitle() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let tabID = try #require(state.createTab()) + + state.tabManager.updateTitle(tabID, title: "npm test") + state.tabManager.setCustomTitle(tabID, title: "Build") + + let snapshot = try #require(state.makeLayoutSnapshotWorktree()) + + #expect(snapshot.tabs.first?.title == "npm test") + #expect(snapshot.tabs.first?.customTitle == "Build") + } + + @Test func applyLayoutSnapshotRestoresCustomTabTitle() throws { + let tabID = UUID() + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + let snapshot = TerminalLayoutSnapshotPayload.SnapshotWorktree( + worktreeID: worktree.id, + selectedTabID: tabID.uuidString, + tabs: [ + TerminalLayoutSnapshotPayload.SnapshotTab( + tabID: tabID.uuidString, + title: "npm test", + customTitle: "Build", + icon: nil, + splitRoot: .leaf(surfaceID: UUID().uuidString) + ) + ] + ) + + #expect(state.applyLayoutSnapshot(snapshot)) + let restored = try #require(state.tabManager.tabs.first) + + #expect(restored.title == "npm test") + #expect(restored.customTitle == "Build") + #expect(restored.displayTitle == "Build") + #expect(restored.isTitleLocked == false) + } + @Test func restoreLayoutSnapshotFailClosedClearsSnapshotWhenWorktreeMissing() async { let clearCount = LockIsolated(0) let snapshot = TerminalLayoutSnapshotPayload( -- 2.51.2 From f965ae88f5ca816f10f6735ecd1c765da72d1ade Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:13:12 +0900 Subject: [PATCH 053/528] Add worktree history navigation --- supacode/App/AppShortcuts.swift | 22 ++++ supacode/Commands/WorktreeCommands.swift | 16 +++ ...RepositoriesFeature+WorktreeCreation.swift | 4 +- .../Reducer/RepositoriesFeature.swift | 122 +++++++++++++++++- .../Views/ShortcutsSettingsView.swift | 2 + supacodeTests/KeybindingSchemaTests.swift | 7 + supacodeTests/RepositoriesFeatureTests.swift | 111 ++++++++++++++++ 7 files changed, 279 insertions(+), 5 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index ed92d901..f38d164c 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -115,6 +115,8 @@ enum AppShortcuts { static let archivedWorktrees = "archived_worktrees" static let selectNextWorktree = "select_next_worktree" static let selectPreviousWorktree = "select_previous_worktree" + static let worktreeHistoryBack = "worktree_history_back" + static let worktreeHistoryForward = "worktree_history_forward" static let selectWorktree1 = "select_worktree_1" static let selectWorktree2 = "select_worktree_2" static let selectWorktree3 = "select_worktree_3" @@ -236,6 +238,12 @@ enum AppShortcuts { static let selectPreviousWorktree = AppShortcut( keyEquivalent: .upArrow, ghosttyKeyName: "arrow_up", modifiers: [.command, .control] ) + static let worktreeHistoryBack = AppShortcut( + keyEquivalent: .leftArrow, ghosttyKeyName: "arrow_left", modifiers: [.command, .option, .control] + ) + static let worktreeHistoryForward = AppShortcut( + keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .option, .control] + ) static let selectWorktree1 = AppShortcut(key: "1", modifiers: [.control]) static let selectWorktree2 = AppShortcut(key: "2", modifiers: [.control]) static let selectWorktree3 = AppShortcut(key: "3", modifiers: [.control]) @@ -523,6 +531,18 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: selectPreviousWorktree ), + .init( + id: CommandID.worktreeHistoryBack, + title: "Back in Worktree History", + scope: .configurableAppAction, + shortcut: worktreeHistoryBack + ), + .init( + id: CommandID.worktreeHistoryForward, + title: "Forward in Worktree History", + scope: .configurableAppAction, + shortcut: worktreeHistoryForward + ), .init( id: CommandID.selectWorktree1, title: "Select Worktree 1", @@ -860,6 +880,8 @@ enum AppShortcuts { archivedWorktrees, selectNextWorktree, selectPreviousWorktree, + worktreeHistoryBack, + worktreeHistoryForward, selectWorktree1, selectWorktree2, selectWorktree3, diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 08670cd3..28cf92dc 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -41,6 +41,22 @@ struct WorktreeCommands: Commands { ) .help(helpText(title: "Select Previous Worktree", commandID: AppShortcuts.CommandID.selectPreviousWorktree)) .disabled(orderedRows.isEmpty) + Button("Back in Worktree History") { + store.send(.repositories(.worktreeHistoryBack)) + } + .modifier( + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.worktreeHistoryBack)) + ) + .help(helpText(title: "Back in Worktree History", commandID: AppShortcuts.CommandID.worktreeHistoryBack)) + .disabled(!repositories.canNavigateWorktreeHistoryBackward) + Button("Forward in Worktree History") { + store.send(.repositories(.worktreeHistoryForward)) + } + .modifier( + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.worktreeHistoryForward)) + ) + .help(helpText(title: "Forward in Worktree History", commandID: AppShortcuts.CommandID.worktreeHistoryForward)) + .disabled(!repositories.canNavigateWorktreeHistoryForward) Divider() ForEach(worktreeMenuEntries(orderedRows: orderedRows)) { entry in worktreeMenuButton(entry: entry) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift index bce5d78e..d0c3fffb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift @@ -257,7 +257,7 @@ extension RepositoriesFeature { progress: WorktreeCreationProgress(stage: .loadingLocalBranches) ) ) - setSingleWorktreeSelection(pendingID, state: &state) + setSingleWorktreeSelection(pendingID, state: &state, recordHistory: true) let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) let createWorktreeStream = gitClient.createWorktreeStream let isValidBranchName = gitClient.isValidBranchName @@ -558,7 +558,7 @@ extension RepositoriesFeature { state.pendingTerminalFocusWorktreeIDs.insert(worktree.id) removePendingWorktree(pendingID, state: &state) if state.selection == .worktree(pendingID) { - setSingleWorktreeSelection(worktree.id, state: &state) + setSingleWorktreeSelection(worktree.id, state: &state, recordHistory: false) } insertWorktree(worktree, repositoryID: repositoryID, state: &state) return .merge( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 18be5ba1..41f47d6a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -216,6 +216,8 @@ struct RepositoriesFeature { var preCanvasWorktreeID: Worktree.ID? var preCanvasTerminalTargetID: Worktree.ID? var isShelfActive: Bool = false + var worktreeHistoryBackStack: [Worktree.ID] = [] + var worktreeHistoryForwardStack: [Worktree.ID] = [] /// IDs of worktrees (and plain-folder repositories) that have been /// "opened" at least once in this session — i.e., had their /// terminal state created by a user selection or CLI activation. @@ -305,6 +307,8 @@ struct RepositoriesFeature { case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) case selectNextWorktree case selectPreviousWorktree + case worktreeHistoryBack + case worktreeHistoryForward case revealSelectedWorktreeInSidebar case consumePendingSidebarReveal(Int) case requestRenameBranch(Worktree.ID, String) @@ -869,7 +873,7 @@ struct RepositoriesFeature { case .selectWorktree(let worktreeID, let focusTerminal): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } - setSingleWorktreeSelection(worktreeID, state: &state) + setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: true) if focusTerminal, let worktreeID { state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) } @@ -900,6 +904,12 @@ struct RepositoriesFeature { guard let id = state.worktreeID(byOffset: -1) else { return .none } return .send(.selectWorktree(id)) + case .worktreeHistoryBack: + return navigateWorktreeHistory(direction: .backward, state: &state) + + case .worktreeHistoryForward: + return navigateWorktreeHistory(direction: .forward, state: &state) + case .revealSelectedWorktreeInSidebar: guard let worktreeID = state.selectedWorktreeID, let repositoryID = state.repositoryID(containing: worktreeID) @@ -1542,6 +1552,14 @@ extension RepositoriesFeature.State { selection?.worktreeID } + var canNavigateWorktreeHistoryBackward: Bool { + canNavigateWorktreeHistory(stack: worktreeHistoryBackStack) + } + + var canNavigateWorktreeHistoryForward: Bool { + canNavigateWorktreeHistory(stack: worktreeHistoryForwardStack) + } + var selectedRepositoryID: Repository.ID? { guard case .repository(let repositoryID) = selection else { return nil } return repositoryID @@ -1623,6 +1641,12 @@ extension RepositoriesFeature.State { archivedWorktreeIDSet.contains(id) } + private func canNavigateWorktreeHistory(stack: [Worktree.ID]) -> Bool { + stack.reversed().contains { id in + id != selectedWorktreeID && isSelectionValid(id, state: self) + } + } + func worktreeInfo(for worktreeID: Worktree.ID) -> WorktreeInfoEntry? { worktreeInfoByID[worktreeID] } @@ -2344,8 +2368,10 @@ func restoreSelection( guard state.selection == .worktree(pendingID) else { return } setSingleWorktreeSelection( isSelectionValid(id, state: state) ? id : nil, - state: &state + state: &state, + recordHistory: false ) + pruneWorktreeHistoryTails(state: &state) } func isSelectionValid( @@ -2433,8 +2459,12 @@ private func isSidebarSelectionValid( func setSingleWorktreeSelection( _ worktreeID: Worktree.ID?, - state: inout RepositoriesFeature.State + state: inout RepositoriesFeature.State, + recordHistory: Bool = false ) { + if recordHistory { + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: worktreeID, state: &state) + } state.selection = worktreeID.map(SidebarSelection.worktree) if let worktreeID { state.sidebarSelectedWorktreeIDs = [worktreeID] @@ -2443,6 +2473,92 @@ func setSingleWorktreeSelection( } } +private enum WorktreeHistoryDirection { + case backward + case forward +} + +private let worktreeHistoryStackLimit = 50 + +private func navigateWorktreeHistory( + direction: WorktreeHistoryDirection, + state: inout RepositoriesFeature.State +) -> Effect { + guard let currentID = state.selectedWorktreeID else { return .none } + var sourceStack = + direction == .backward + ? state.worktreeHistoryBackStack + : state.worktreeHistoryForwardStack + guard let destinationID = popValidWorktreeHistoryDestination(from: &sourceStack, currentID: currentID, state: state) + else { + if direction == .backward { + state.worktreeHistoryBackStack = sourceStack + } else { + state.worktreeHistoryForwardStack = sourceStack + } + return .none + } + + if direction == .backward { + state.worktreeHistoryBackStack = sourceStack + pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryForwardStack) + } else { + state.worktreeHistoryForwardStack = sourceStack + pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryBackStack) + } + setSingleWorktreeSelection(destinationID, state: &state, recordHistory: false) + state.openedWorktreeIDs.insert(destinationID) + return .send(.delegate(.selectedWorktreeChanged(state.worktree(for: destinationID)))) +} + +private func recordWorktreeHistoryTransition( + from previousID: Worktree.ID?, + to nextID: Worktree.ID?, + state: inout RepositoriesFeature.State +) { + guard let previousID, let nextID, previousID != nextID else { return } + guard isSelectionValid(previousID, state: state), isSelectionValid(nextID, state: state) else { return } + pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) + state.worktreeHistoryForwardStack = [] +} + +private func popValidWorktreeHistoryDestination( + from stack: inout [Worktree.ID], + currentID: Worktree.ID, + state: RepositoriesFeature.State +) -> Worktree.ID? { + while let candidateID = stack.popLast() { + guard candidateID != currentID, isSelectionValid(candidateID, state: state) else { + continue + } + return candidateID + } + return nil +} + +private func pushWorktreeHistoryID(_ id: Worktree.ID, onto stack: inout [Worktree.ID]) { + stack.append(id) + if stack.count > worktreeHistoryStackLimit { + stack.removeFirst(stack.count - worktreeHistoryStackLimit) + } +} + +private func pruneWorktreeHistoryTails(state: inout RepositoriesFeature.State) { + pruneWorktreeHistoryTail(stack: &state.worktreeHistoryBackStack, state: state) + pruneWorktreeHistoryTail(stack: &state.worktreeHistoryForwardStack, state: state) +} + +private func pruneWorktreeHistoryTail( + stack: inout [Worktree.ID], + state: RepositoriesFeature.State +) { + while let last = stack.last, + last == state.selectedWorktreeID || !isSelectionValid(last, state: state) + { + stack.removeLast() + } +} + func repositoryForWorktreeCreation( _ state: RepositoriesFeature.State ) -> Repository? { diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 25cf9a91..8b7e4745 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -906,6 +906,8 @@ private enum ShortcutGroup: String, CaseIterable, Identifiable { switch commandID { case AppShortcuts.CommandID.selectNextWorktree, AppShortcuts.CommandID.selectPreviousWorktree, + AppShortcuts.CommandID.worktreeHistoryBack, + AppShortcuts.CommandID.worktreeHistoryForward, AppShortcuts.CommandID.renameBranch, AppShortcuts.CommandID.selectWorktree1, AppShortcuts.CommandID.selectWorktree2, diff --git a/supacodeTests/KeybindingSchemaTests.swift b/supacodeTests/KeybindingSchemaTests.swift index d4bd2bba..7399cdbb 100644 --- a/supacodeTests/KeybindingSchemaTests.swift +++ b/supacodeTests/KeybindingSchemaTests.swift @@ -53,6 +53,13 @@ struct KeybindingSchemaTests { #expect(selectAllCanvasCards?.conflictPolicy == .localOnly) } + @Test func worktreeHistoryShortcutsDoNotConflictWithShelfBookNavigation() { + #expect(AppShortcuts.worktreeHistoryBack != AppShortcuts.selectPreviousShelfBook) + #expect(AppShortcuts.worktreeHistoryForward != AppShortcuts.selectNextShelfBook) + #expect(AppShortcuts.worktreeHistoryBack.display == "⌘⌥⌃←") + #expect(AppShortcuts.worktreeHistoryForward.display == "⌘⌥⌃→") + } + @Test func resolverAppliesUserOverrideOverMigratedOverride() { let schema = KeybindingSchemaDocument( version: 1, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2545f92e..b51cede4 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -944,6 +944,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4025,6 +4026,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] + $0.worktreeHistoryBackStack = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4044,6 +4046,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4082,6 +4085,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4148,6 +4152,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt3.id) $0.sidebarSelectedWorktreeIDs = [wt3.id] $0.openedWorktreeIDs = [wt3.id] + $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4171,6 +4176,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] + $0.worktreeHistoryBackStack = [wt3.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4220,10 +4226,115 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] + $0.worktreeHistoryBackStack = [wt3.id] } await store.receive(\.delegate.selectedWorktreeChanged) } + // MARK: - Worktree History Back/Forward + + @Test func selectingDifferentWorktreePushesPreviousOntoBackStack() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt1.id) + state.worktreeHistoryForwardStack = [wt2.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.selectWorktree(wt2.id)) { + $0.selection = .worktree(wt2.id) + $0.sidebarSelectedWorktreeIDs = [wt2.id] + $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] + $0.worktreeHistoryForwardStack = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func worktreeHistoryBackPopsPreviousAndPushesCurrentToForward() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt2.id) + state.worktreeHistoryBackStack = [wt1.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeHistoryBack) { + $0.selection = .worktree(wt1.id) + $0.sidebarSelectedWorktreeIDs = [wt1.id] + $0.openedWorktreeIDs = [wt1.id] + $0.worktreeHistoryBackStack = [] + $0.worktreeHistoryForwardStack = [wt2.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func worktreeHistoryForwardPopsNextAndPushesCurrentToBack() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt1.id) + state.worktreeHistoryForwardStack = [wt2.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeHistoryForward) { + $0.selection = .worktree(wt2.id) + $0.sidebarSelectedWorktreeIDs = [wt2.id] + $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] + $0.worktreeHistoryForwardStack = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func worktreeHistoryBackSkipsStaleEntriesUntilValidIDFound() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt3]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt3.id) + state.worktreeHistoryBackStack = [wt1.id, "/tmp/wt2-deleted"] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeHistoryBack) { + $0.selection = .worktree(wt1.id) + $0.sidebarSelectedWorktreeIDs = [wt1.id] + $0.openedWorktreeIDs = [wt1.id] + $0.worktreeHistoryBackStack = [] + $0.worktreeHistoryForwardStack = [wt3.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func canNavigateBackwardFiltersStaleAndSelfReferentialEntries() { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt1.id) + + state.worktreeHistoryBackStack = ["/tmp/gone-a", "/tmp/gone-b"] + #expect(!state.canNavigateWorktreeHistoryBackward) + + state.worktreeHistoryBackStack = [wt1.id] + #expect(!state.canNavigateWorktreeHistoryBackward) + + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + state.repositories = [makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2])] + state.worktreeHistoryBackStack = [wt2.id, "/tmp/gone"] + #expect(state.canNavigateWorktreeHistoryBackward) + } + private func makeWorktree( id: String, name: String, -- 2.51.2 From 750e2b9aed6f7f6c08ca6a36ada80d9da7bf94de Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:30:36 +0900 Subject: [PATCH 054/528] Adapt window title and quit confirmation --- supacode/App/ContentView.swift | 6 + supacode/App/WindowSurfacing.swift | 34 ++++++ supacode/App/WindowTitle.swift | 106 ++++++++++++++++++ supacode/App/supacodeApp.swift | 51 ++------- supacode/CLIService/OpenCommandHandler.swift | 8 +- .../AppLifecycle/AppLifecycleClient.swift | 30 +++++ supacode/Commands/WindowCommands.swift | 5 +- .../Features/App/Reducer/AppFeature.swift | 42 ++++--- supacodeTests/AppFeatureQuitTests.swift | 64 +++++++++++ supacodeTests/WindowTitleTests.swift | 73 ++++++++++++ 10 files changed, 342 insertions(+), 77 deletions(-) create mode 100644 supacode/App/WindowSurfacing.swift create mode 100644 supacode/App/WindowTitle.swift create mode 100644 supacode/Clients/AppLifecycle/AppLifecycleClient.swift create mode 100644 supacodeTests/AppFeatureQuitTests.swift create mode 100644 supacodeTests/WindowTitleTests.swift diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index fd87b11f..f25d26d7 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -100,6 +100,12 @@ struct ContentView: View { ) } .background(WindowTabbingDisabler()) + .navigationTitle( + WindowTitle.compute( + repositories: store.repositories, + terminalManager: terminalManager + ) + ) } private func toggleLeftSidebar() { diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift new file mode 100644 index 00000000..efb65645 --- /dev/null +++ b/supacode/App/WindowSurfacing.swift @@ -0,0 +1,34 @@ +import AppKit + +enum WindowID { + static let main = "main" + static let settings = "settings" +} + +extension NSApplication { + @MainActor + @discardableResult + func surfaceMainWindow() -> Bool { + guard let window = mainWindowCandidate() else { + activate(ignoringOtherApps: true) + return false + } + if window.isMiniaturized { + window.deminiaturize(nil) + } + activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + return true + } + + private func mainWindowCandidate() -> NSWindow? { + if let window = windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { + return window + } + let candidates = windows.filter { !($0 is NSPanel) } + if let window = candidates.first(where: { $0.identifier?.rawValue != WindowID.settings }) { + return window + } + return candidates.first + } +} diff --git a/supacode/App/WindowTitle.swift b/supacode/App/WindowTitle.swift new file mode 100644 index 00000000..f891903e --- /dev/null +++ b/supacode/App/WindowTitle.swift @@ -0,0 +1,106 @@ +import Foundation +import IdentifiedCollections + +enum WindowTitle { + static let appName = "Prowl" + static let archivedWorktreesTitle = "Archived Worktrees" + static let canvasTitle = "Canvas" + + static func format(repository: String, tab: String?) -> String { + guard let tab, !tab.isEmpty else { return repository } + return "\(repository) · \(tab)" + } + + @MainActor + static func compute( + repositories: RepositoriesFeature.State, + terminalManager: WorktreeTerminalManager + ) -> String { + compute( + repositories: repositories, + terminalState: { terminalManager.stateIfExists(for: $0) } + ) + } + + @MainActor + static func compute( + repositories: RepositoriesFeature.State, + terminalState: (Worktree.ID) -> WorktreeTerminalState? + ) -> String { + switch repositories.selection { + case .archivedWorktrees: + return archivedWorktreesTitle + case .canvas: + return canvasTitle + case .repository(let repositoryID): + return repositoryTitle(repositoryID: repositoryID, repositories: repositories, terminalState: terminalState) + case .worktree(let worktreeID): + return worktreeTitle(worktreeID: worktreeID, repositories: repositories, terminalState: terminalState) + case nil: + return appName + } + } + + static func sanitize(_ raw: String) -> String? { + let scalars = raw.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } + let trimmed = String(String.UnicodeScalarView(scalars)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + @MainActor + private static func worktreeTitle( + worktreeID: Worktree.ID, + repositories: RepositoriesFeature.State, + terminalState: (Worktree.ID) -> WorktreeTerminalState? + ) -> String { + guard let repositoryID = repositories.repositoryID(containing: worktreeID), + repositories.repositories[id: repositoryID] != nil + else { + return appName + } + return format( + repository: repositoryDisplayTitle(repositoryID: repositoryID, repositories: repositories), + tab: selectedTabTitle(in: terminalState(worktreeID)) + ) + } + + @MainActor + private static func repositoryTitle( + repositoryID: Repository.ID, + repositories: RepositoriesFeature.State, + terminalState: (Worktree.ID) -> WorktreeTerminalState? + ) -> String { + guard repositories.repositories[id: repositoryID] != nil else { + return appName + } + return format( + repository: repositoryDisplayTitle(repositoryID: repositoryID, repositories: repositories), + tab: selectedTabTitle(in: terminalState(repositoryID)) + ) + } + + private static func repositoryDisplayTitle( + repositoryID: Repository.ID, + repositories: RepositoriesFeature.State + ) -> String { + if let customTitle = repositories.repositoryCustomTitles[repositoryID]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !customTitle.isEmpty + { + return customTitle + } + return repositories.repositories[id: repositoryID]?.name ?? appName + } + + @MainActor + private static func selectedTabTitle(in state: WorktreeTerminalState?) -> String? { + guard let state, + let selectedTabID = state.tabManager.selectedTabId, + let tab = state.tabManager.tabs.first(where: { $0.id == selectedTabID }) + else { + return nil + } + return sanitize(tab.title) + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 7487cb83..971cc3b9 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -43,13 +43,16 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { func applicationDidBecomeActive(_ notification: Notification) { let app = NSApplication.shared - guard !app.windows.contains(where: \.isVisible) else { return } - _ = showMainWindow(from: app) + let hasVisibleMainWindow = app.windows.contains { window in + window.isVisible && !(window is NSPanel) + } + guard !hasVisibleMainWindow else { return } + app.surfaceMainWindow() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if flag { return true } - return showMainWindow(from: sender) ? false : true + return !sender.surfaceMainWindow() } func applicationWillTerminate(_ notification: Notification) { @@ -63,25 +66,6 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { false } - private func mainWindow(from sender: NSApplication) -> NSWindow? { - if let window = sender.windows.first(where: { $0.identifier?.rawValue == "main" }) { - return window - } - if let window = sender.windows.first(where: { $0.identifier?.rawValue != "settings" }) { - return window - } - return sender.windows.first - } - - private func showMainWindow(from sender: NSApplication) -> Bool { - guard let window = mainWindow(from: sender) else { return false } - if window.isMiniaturized { - window.deminiaturize(nil) - } - sender.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - return true - } } @main @@ -582,30 +566,11 @@ struct SupacodeApp: App { } private static func bringMainWindowToFront() -> Bool { - let app = NSApplication.shared - guard let window = mainWindow(from: app) else { - return false - } - if window.isMiniaturized { - window.deminiaturize(nil) - } - app.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - return true - } - - private static func mainWindow(from app: NSApplication) -> NSWindow? { - if let window = app.windows.first(where: { $0.identifier?.rawValue == "main" }) { - return window - } - if let window = app.windows.first(where: { $0.identifier?.rawValue != "settings" }) { - return window - } - return app.windows.first + NSApplication.shared.surfaceMainWindow() } var body: some Scene { - Window("Prowl", id: "main") { + Window("Prowl", id: WindowID.main) { GhosttyColorSchemeSyncView( ghostty: ghostty, preferredColorScheme: store.settings.appearanceMode.colorScheme diff --git a/supacode/CLIService/OpenCommandHandler.swift b/supacode/CLIService/OpenCommandHandler.swift index 98c9579b..cd3100ae 100644 --- a/supacode/CLIService/OpenCommandHandler.swift +++ b/supacode/CLIService/OpenCommandHandler.swift @@ -397,13 +397,7 @@ final class OpenCommandHandler: CommandHandler { } private func bringAppToFront() { - NSApplication.shared.activate(ignoringOtherApps: true) - if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "main" }) { - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - } + NSApplication.shared.surfaceMainWindow() } private func waitForRepositoriesReadyIfNeeded() async { diff --git a/supacode/Clients/AppLifecycle/AppLifecycleClient.swift b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift new file mode 100644 index 00000000..422d4228 --- /dev/null +++ b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift @@ -0,0 +1,30 @@ +import AppKit +import ComposableArchitecture + +struct AppLifecycleClient: Sendable { + var surfaceMainWindow: @MainActor @Sendable () -> Bool + var terminate: @MainActor @Sendable () -> Void +} + +extension AppLifecycleClient: DependencyKey { + static let liveValue = AppLifecycleClient( + surfaceMainWindow: { + NSApplication.shared.surfaceMainWindow() + }, + terminate: { + NSApplication.shared.terminate(nil) + } + ) + + static let testValue = AppLifecycleClient( + surfaceMainWindow: { false }, + terminate: {} + ) +} + +extension DependencyValues { + var appLifecycleClient: AppLifecycleClient { + get { self[AppLifecycleClient.self] } + set { self[AppLifecycleClient.self] = newValue } + } +} diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index 5f25aa85..8b9f766f 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -36,10 +36,7 @@ struct WindowCommands: Commands { CommandGroup(replacing: .windowArrangement) { Button("Prowl") { - if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } + NSApp.surfaceMainWindow() } .help("Show main window") Divider() diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 4dd2bb92..8a523f15 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -109,6 +109,7 @@ struct AppFeature { @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence @Dependency(WorkspaceClient.self) private var workspaceClient @Dependency(SettingsWindowClient.self) private var settingsWindowClient + @Dependency(AppLifecycleClient.self) private var appLifecycleClient @Dependency(NotificationSoundClient.self) private var notificationSoundClient @Dependency(SystemNotificationClient.self) private var systemNotificationClient @Dependency(TerminalClient.self) private var terminalClient @@ -578,31 +579,26 @@ struct AppFeature { return .none case .requestQuit: - #if !DEBUG - guard state.settings.confirmBeforeQuit else { - analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) - return .run { @MainActor _ in - NSApplication.shared.terminate(nil) - } + guard state.settings.confirmBeforeQuit else { + analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) + return .run { @MainActor _ in + appLifecycleClient.terminate() } - state.alert = AlertState { - TextState("Quit Prowl?") - } actions: { - ButtonState(action: .confirmQuit) { - TextState("Quit") - } - ButtonState(role: .cancel, action: .dismiss) { - TextState("Cancel") - } - } message: { - TextState("This will close all terminal sessions.") + } + _ = appLifecycleClient.surfaceMainWindow() + state.alert = AlertState { + TextState("Quit Prowl?") + } actions: { + ButtonState(action: .confirmQuit) { + TextState("Quit") } - return .none - #else - return .run { @MainActor _ in - NSApplication.shared.terminate(nil) + ButtonState(role: .cancel, action: .dismiss) { + TextState("Cancel") } - #endif + } message: { + TextState("This will close all terminal sessions.") + } + return .none case .newTerminal: guard let worktree = state.repositories.selectedTerminalWorktree else { @@ -863,7 +859,7 @@ struct AppFeature { analyticsClient.capture("app_quit", appQuitProperties(launchedAt: state.launchedAt)) state.alert = nil return .run { @MainActor _ in - NSApplication.shared.terminate(nil) + appLifecycleClient.terminate() } case .alert: diff --git a/supacodeTests/AppFeatureQuitTests.swift b/supacodeTests/AppFeatureQuitTests.swift new file mode 100644 index 00000000..67639568 --- /dev/null +++ b/supacodeTests/AppFeatureQuitTests.swift @@ -0,0 +1,64 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureQuitTests { + @Test(.dependencies) func requestQuitWithConfirmationSurfacesMainWindowAndShowsAlert() async { + var settings = SettingsFeature.State() + settings.confirmBeforeQuit = true + let surfaced = LockIsolated(false) + let store = TestStore( + initialState: AppFeature.State(settings: settings) + ) { + AppFeature() + } withDependencies: { + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.setValue(true) + return true + } + } + + await store.send(.requestQuit) { + $0.alert = AlertState { + TextState("Quit Prowl?") + } actions: { + ButtonState(action: .confirmQuit) { + TextState("Quit") + } + ButtonState(role: .cancel, action: .dismiss) { + TextState("Cancel") + } + } message: { + TextState("This will close all terminal sessions.") + } + } + + #expect(surfaced.value) + } + + @Test(.dependencies) func requestQuitWithoutConfirmationTerminatesThroughLifecycleClient() async { + var settings = SettingsFeature.State() + settings.confirmBeforeQuit = false + let terminated = LockIsolated(false) + let store = TestStore( + initialState: AppFeature.State(settings: settings) + ) { + AppFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_000) + $0.appLifecycleClient.terminate = { + terminated.setValue(true) + } + } + + await store.send(.requestQuit) + await store.finish() + + #expect(terminated.value) + #expect(store.state.alert == nil) + } +} diff --git a/supacodeTests/WindowTitleTests.swift b/supacodeTests/WindowTitleTests.swift new file mode 100644 index 00000000..a845ff05 --- /dev/null +++ b/supacodeTests/WindowTitleTests.swift @@ -0,0 +1,73 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct WindowTitleTests { + @Test func formatIncludesTabWhenPresent() { + #expect(WindowTitle.format(repository: "Prowl", tab: "codex") == "Prowl · codex") + } + + @Test func formatDropsEmptyTab() { + #expect(WindowTitle.format(repository: "Prowl", tab: nil) == "Prowl") + #expect(WindowTitle.format(repository: "Prowl", tab: "") == "Prowl") + } + + @Test func sanitizeRemovesControlCharacters() { + #expect(WindowTitle.sanitize("codex\nsecret\u{1B}") == "codexsecret") + #expect(WindowTitle.sanitize("\n\t\u{07}") == nil) + } + + @Test func computeUsesAppTitleWithoutSelection() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: RepositoriesFeature.State(), terminalManager: manager) == "Prowl") + } + + @Test func computeUsesViewTitlesForCanvasAndArchive() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + var state = RepositoriesFeature.State() + + state.selection = .canvas + #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Canvas") + + state.selection = .archivedWorktrees + #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Archived Worktrees") + } + + @Test func computeUsesCustomRepositoryTitleAndSelectedTabTitle() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let worktree = makeWorktree(rootURL: rootURL) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + state.repositoryCustomTitles[repository.id] = "Prowl" + + let terminalState = WorktreeTerminalState(runtime: GhosttyRuntime(), worktree: worktree) + _ = terminalState.tabManager.createTab(title: "codex\nhidden", icon: nil) + + #expect( + WindowTitle.compute( + repositories: state, + terminalState: { id in id == worktree.id ? terminalState : nil } + ) == "Prowl · codexhidden" + ) + } + + private func makeWorktree(rootURL: URL) -> Worktree { + Worktree( + id: rootURL.appendingPathComponent("main").path(percentEncoded: false), + name: "main", + detail: "detail", + workingDirectory: rootURL.appendingPathComponent("main"), + repositoryRootURL: rootURL + ) + } +} -- 2.51.2 From 413100fc61651fe4192e0b58dec47260bf91a6b5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:38:52 +0900 Subject: [PATCH 055/528] Adapt worktree loading detail polish --- supacode/Domain/WorktreeLoadingInfo.swift | 47 ++++++++ .../Views/WorktreeDetailView.swift | 2 + .../Views/WorktreeLoadingView.swift | 100 ++++-------------- supacodeTests/WorktreeLoadingInfoTests.swift | 48 +++++++++ 4 files changed, 117 insertions(+), 80 deletions(-) create mode 100644 supacodeTests/WorktreeLoadingInfoTests.swift diff --git a/supacode/Domain/WorktreeLoadingInfo.swift b/supacode/Domain/WorktreeLoadingInfo.swift index 779e9d92..332a717f 100644 --- a/supacode/Domain/WorktreeLoadingInfo.swift +++ b/supacode/Domain/WorktreeLoadingInfo.swift @@ -2,8 +2,55 @@ struct WorktreeLoadingInfo: Hashable { let name: String let repositoryName: String? let state: WorktreeLoadingState + let isFolder: Bool let statusTitle: String? let statusDetail: String? let statusCommand: String? let statusLines: [String] + + init( + name: String, + repositoryName: String?, + state: WorktreeLoadingState, + isFolder: Bool = false, + statusTitle: String?, + statusDetail: String?, + statusCommand: String?, + statusLines: [String] + ) { + self.name = name + self.repositoryName = repositoryName + self.state = state + self.isFolder = isFolder + self.statusTitle = statusTitle + self.statusDetail = statusDetail + self.statusCommand = statusCommand + self.statusLines = statusLines + } + + var actionLabel: String { + switch state { + case .creating: + "Creating" + case .archiving: + "Archiving" + case .removing: + "Removing" + } + } + + var statusSubtitle: String { + let tail = statusLines.suffix(5) + guard tail.isEmpty else { + return tail.joined(separator: "\n") + } + if let status = statusDetail ?? statusTitle { + return status + } + let noun = isFolder ? "folder" : "worktree" + if !isFolder, let repositoryName { + return "\(actionLabel) \(noun) in \(repositoryName)" + } + return "\(actionLabel) \(noun)..." + } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 985d6a65..ddf5908d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -668,11 +668,13 @@ struct WorktreeDetailView: View { ) -> WorktreeLoadingInfo? { guard let selectedRow else { return nil } let repositoryName = repositories.repositoryName(for: selectedRow.repositoryID) + let isFolder = repositories.repositories[id: selectedRow.repositoryID]?.kind == .plain if selectedRow.isDeleting { return WorktreeLoadingInfo( name: selectedRow.name, repositoryName: repositoryName, state: .removing, + isFolder: isFolder, statusTitle: nil, statusDetail: nil, statusCommand: nil, diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index 62c44f22..a1f07bf3 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -3,96 +3,36 @@ import SwiftUI struct WorktreeLoadingView: View { let info: WorktreeLoadingInfo @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity - private let bottomAnchorID = "worktree-loading-bottom" var body: some View { - let actionLabel = - if info.state == .creating { - "Creating" - } else if info.state == .archiving { - "Archiving" - } else { - "Removing" - } - let fallbackStatus = - if let repositoryName = info.repositoryName { - "\(actionLabel) worktree in \(repositoryName)" - } else { - "\(actionLabel) worktree..." - } - let statusLine = info.statusDetail ?? info.statusTitle ?? fallbackStatus let statusCommand = info.statusCommand - VStack(spacing: 10) { + VStack(spacing: 12) { ProgressView() + .controlSize(.large) Text(info.name) - .font(.headline) - .multilineTextAlignment(.center) - if info.statusLines.isEmpty { - Text(statusLine) + .font(.title3) + .lineLimit(1) + .truncationMode(.middle) + if let statusCommand { + Text(statusCommand) .font(.subheadline) + .monospaced() .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - if let statusCommand { - Text(statusCommand) - .font(.caption) - .monospaced() - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - } else { - ScrollViewReader { scrollProxy in - ScrollView { - VStack(alignment: .leading, spacing: 4) { - if let statusCommand { - Text(statusCommand) - .font(.caption) - .monospaced() - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - ForEach(Array(info.statusLines.enumerated()), id: \.offset) { _, line in - Text(line) - .font(.caption) - .monospaced() - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - Color.clear - .frame(height: 1) - .id(bottomAnchorID) - } - .padding(12) - } - .frame(maxWidth: 560, minHeight: 180, maxHeight: 380) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - } - .onAppear { - scrollToBottom(using: scrollProxy, animated: false) - } - .onChange(of: info.statusLines) { _, _ in - scrollToBottom(using: scrollProxy, animated: true) - } - } + .lineLimit(1) + .truncationMode(.middle) } + Text(info.statusSubtitle) + .font(.subheadline) + .monospaced() + .foregroundStyle(.tertiary) + .lineLimit(5, reservesSpace: true) + .truncationMode(.head) + .contentTransition(.opacity) + .animation(.easeInOut, value: info.statusSubtitle) } - .frame(maxWidth: 640) - .padding(.horizontal, 16) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity)) } - - private func scrollToBottom(using proxy: ScrollViewProxy, animated: Bool) { - if animated { - withAnimation(.easeOut(duration: 0.12)) { - proxy.scrollTo(bottomAnchorID, anchor: .bottom) - } - } else { - proxy.scrollTo(bottomAnchorID, anchor: .bottom) - } - } } diff --git a/supacodeTests/WorktreeLoadingInfoTests.swift b/supacodeTests/WorktreeLoadingInfoTests.swift new file mode 100644 index 00000000..cca43ded --- /dev/null +++ b/supacodeTests/WorktreeLoadingInfoTests.swift @@ -0,0 +1,48 @@ +import Testing + +@testable import supacode + +struct WorktreeLoadingInfoTests { + @Test func statusSubtitleUsesLatestFiveOutputLines() { + let info = WorktreeLoadingInfo( + name: "feature", + repositoryName: "Prowl", + state: .creating, + statusTitle: "Creating worktree", + statusDetail: "Preparing worktree", + statusCommand: "git worktree add", + statusLines: ["one", "two", "three", "four", "five", "six"] + ) + + #expect(info.statusSubtitle == "two\nthree\nfour\nfive\nsix") + } + + @Test func statusSubtitleFallsBackToProgressDetailBeforeTitle() { + let info = WorktreeLoadingInfo( + name: "feature", + repositoryName: "Prowl", + state: .creating, + statusTitle: "Creating worktree", + statusDetail: "Preparing worktree", + statusCommand: nil, + statusLines: [] + ) + + #expect(info.statusSubtitle == "Preparing worktree") + } + + @Test func removingPlainFolderUsesFolderNounWithoutRepositorySuffix() { + let info = WorktreeLoadingInfo( + name: "Documents", + repositoryName: "Documents", + state: .removing, + isFolder: true, + statusTitle: nil, + statusDetail: nil, + statusCommand: nil, + statusLines: [] + ) + + #expect(info.statusSubtitle == "Removing folder...") + } +} -- 2.51.2 From 553e070c12d85568dae759e70de6aaa6ee5e708c Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:43:15 +0900 Subject: [PATCH 056/528] Use phase animator for progress bar sweep --- .../Views/GhosttySurfaceProgressBar.swift | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift index 2b733be7..1d0e699e 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift @@ -5,8 +5,6 @@ struct GhosttySurfaceProgressBar: View { let progressState: ghostty_action_progress_report_state_e let progressValue: Int? - @State private var position: CGFloat = 0 - var body: some View { let color: Color = switch progressState { @@ -52,18 +50,11 @@ struct GhosttySurfaceProgressBar: View { Rectangle() .fill(color) .frame(width: geometry.size.width * 0.25, height: geometry.size.height) - .offset(x: position * (geometry.size.width * 0.75)) - } - .onAppear { - withAnimation( - .easeInOut(duration: 1.2) - .repeatForever(autoreverses: true) - ) { - position = 1 - } - } - .onDisappear { - position = 0 + .phaseAnimator([false, true]) { content, moved in + content.offset(x: moved ? geometry.size.width * 0.75 : 0) + } animation: { _ in + .easeInOut(duration: 1.2) + } } } } -- 2.51.2 From 4fd0b5e191f30b3e6f8d66b4c1bdc32c229dec9d Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:47:37 +0900 Subject: [PATCH 057/528] Add Android Studio worktree action --- supacode/Domain/OpenWorktreeAction.swift | 25 +++++++++++++-------- supacodeTests/OpenWorktreeActionTests.swift | 3 +++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/supacode/Domain/OpenWorktreeAction.swift b/supacode/Domain/OpenWorktreeAction.swift index 678dbf63..9316e754 100644 --- a/supacode/Domain/OpenWorktreeAction.swift +++ b/supacode/Domain/OpenWorktreeAction.swift @@ -7,6 +7,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } case alacritty + case androidStudio case antigravity case editor case finder @@ -41,6 +42,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: "Open Finder" case .editor: "$EDITOR" case .alacritty: "Alacritty" + case .androidStudio: "Android Studio" case .antigravity: "Antigravity" case .cursor: "Cursor" case .githubDesktop: "GitHub Desktop" @@ -72,9 +74,10 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { switch self { case .finder: "Finder" case .editor: "$EDITOR" - case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, .terminal, - .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, + .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, + .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, + .xcode, .zed: title } } @@ -94,9 +97,10 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { switch self { case .finder, .editor: return true - case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, .terminal, - .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, + .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, + .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, + .xcode, .zed: return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } } @@ -106,6 +110,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: "finder" case .editor: "editor" case .alacritty: "alacritty" + case .androidStudio: "android-studio" case .antigravity: "antigravity" case .cursor: "cursor" case .fork: "fork" @@ -138,6 +143,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: "com.apple.finder" case .editor: "" case .alacritty: "org.alacritty" + case .androidStudio: "com.google.android.studio" case .antigravity: "com.google.antigravity" case .cursor: "com.todesktop.230313mzl4w4u92" case .fork: "com.DanPristupov.Fork" @@ -174,6 +180,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { .windsurf, .vscodeInsiders, .vscodium, + .androidStudio, .intellij, .webstorm, .pycharm, @@ -252,7 +259,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: NSWorkspace.shared.activateFileViewerSelecting([worktree.workingDirectory]) // Apps that require CLI arguments instead of Apple Events to open directories. - case .intellij, .webstorm, .pycharm, .rustrover: + case .androidStudio, .intellij, .webstorm, .pycharm, .rustrover: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier @@ -284,8 +291,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } } case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, .vscodium, - .warp, .wezterm, .windsurf, .xcode, .zed: + .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, + .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index f1daaadf..e3cfd1bc 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -6,6 +6,7 @@ struct OpenWorktreeActionTests { @Test func menuOrderIncludesExpectedWorkspaceActions() { let settingsIDs = OpenWorktreeAction.menuOrder.map(\.settingsID) + #expect(settingsIDs.contains("android-studio")) #expect(settingsIDs.contains("antigravity")) #expect(settingsIDs.contains("intellij")) #expect(settingsIDs.contains("rustrover")) @@ -16,6 +17,7 @@ struct OpenWorktreeActionTests { } @Test func jetBrainsIDEsHaveCorrectBundleIdentifiers() { + #expect(OpenWorktreeAction.androidStudio.bundleIdentifier == "com.google.android.studio") #expect(OpenWorktreeAction.intellij.bundleIdentifier == "com.jetbrains.intellij") #expect(OpenWorktreeAction.webstorm.bundleIdentifier == "com.jetbrains.WebStorm") #expect(OpenWorktreeAction.pycharm.bundleIdentifier == "com.jetbrains.pycharm") @@ -24,6 +26,7 @@ struct OpenWorktreeActionTests { @Test func jetBrainsIDEsAreInEditorPriority() { let editors = OpenWorktreeAction.editorPriority + #expect(editors.contains(.androidStudio)) #expect(editors.contains(.intellij)) #expect(editors.contains(.webstorm)) #expect(editors.contains(.pycharm)) -- 2.51.2 From 630b1aefb849f989c1d0e6813c87ece1de3a77bf Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 00:54:09 +0900 Subject: [PATCH 058/528] Cancel superseded test workflow runs --- .github/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e3d38f3..65ca73c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: branches: - main pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: macos-26 -- 2.51.2 From 5dd20d817755e85065c903760661de82d6469e4e Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 01:01:05 +0900 Subject: [PATCH 059/528] Record upstream review through post-v0.8.5 --- doc-onevcat/change-list.md | 47 +++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/doc-onevcat/change-list.md b/doc-onevcat/change-list.md index f13ac7dd..e59b7c25 100644 --- a/doc-onevcat/change-list.md +++ b/doc-onevcat/change-list.md @@ -4,15 +4,56 @@ | Key | Value | | --- | --- | -| Commit | `c4e9be3b74085c60a209e8c231d98a9638c838f3` | -| Tag | v0.8.1 | -| Date | 2026-04-19 | +| Commit | `5e88ec5dfc74af165781e8d5a41505867b23dc06` | +| Tag | post-v0.8.5 | +| Date | 2026-05-05 | All upstream changes up to and including this commit have been reviewed. Future upstream checks should only inspect commits **after** this baseline. --- +## 2026-05-08 — Review through post-v0.8.5 + +### Upstream changes reviewed + +Reviewed 25 commits on `supabitapp/supacode` from `c4e9be3b` (v0.8.1, 2026-04-19) through +`5e88ec5d` (post-v0.8.5, 2026-05-05). Significant additions: + +- **Ghostty key event routing** (`6c807c63`, #259; `539c0feb`, #264) — upstream fixed two + `performKeyEquivalent` edge cases: only forward unmatched Ghostty bindings to a real main-menu item, and only + consume keys from the actual first responder. +- **Repository and PR targeting** (`a4cdad9e`, #261) — resolves the GitHub PR owner/repo with `gh repo view`, which + matters for fork clones whose remotes may point at different owners. +- **Notification, tab, focus, and window UX** (`072ad1e7`, #266; `6615f49c`, #269; `b34a66d5`, #279; + `71dc4b57`, #295; `028ef412`, #297; `5e88ec5d`, #298) — upstream added jump-to-unread notifications, + custom tab titles, worktree history, sidebar-to-terminal arrow focus, dynamic window titles, and main-window-only + quit confirmation. +- **Sidebar/repository appearance and polish** (`4d19b068`, #260; `9bae228e`, #276; `3af3a164`, #258; + `57e620a7`, #265; `514c3ceb`, #283) — dim inactive split panes, per-repo title/color, detail toolbar and loading + state polish, sidebar animation CPU reductions, and script menu cache fixes. +- **Agent/editor/platform additions** (`943f3fab`, #245; `6fff0218`, #262; `650a6b52`, #292; `e06c07a0`) — + Kiro and Pi agents, Android Studio editor support, and app icon bundle metadata. +- **Build/release housekeeping** (`92c7c461`, `644bd468`, `3b07571b`, `34da9417`, `bd9da925`, `4a8611d9`) — + CI concurrency and upstream semantic version/build-number bumps. + +### Decisions + +- **Ported to Prowl PRs**: Ghostty key routing (#255), fork-aware PR repo resolution (#256), worktree history + (#260), dynamic window title plus main-window quit behavior (#261), loading overlay polish (#262), sidebar animation + CPU fix (#263), Android Studio editor support (#264), sidebar right-arrow focus (#265), test workflow concurrency + (#266), and `CFBundleIconName` metadata (#267). +- **Reviewed and skipped**: Notifications UX (#266 upstream), inactive split dimming (#260 upstream), tab renaming + (#269 upstream), and bare-repo detection (#263 upstream) were already covered, intentionally different, or not + currently applicable in the fork. +- **Skipped due fork-specific architecture**: Repository title/color (#276 upstream) conflicts with Prowl's richer + repository appearance model; Kiro/Pi agent hooks rely on upstream settings/hook modules not present in this fork; + Run Script dropdown caching does not apply to Prowl's current popover/button implementation. +- **Skipped due fork release policy**: Upstream release-tip/warm-cache/inspect-dependencies workflow concurrency and + semantic version bumps do not apply. Prowl keeps local, notarized, date-based releases. + +--- + ## 2026-04-20 — Review through v0.8.1 ### Upstream changes reviewed -- 2.51.2 From ec307a95d9f7e8bfee09f531e7d29a8b5076b0bb Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 01:31:38 +0900 Subject: [PATCH 060/528] Limit worktree history to standard navigation --- supacode/App/AppShortcuts.swift | 8 ++--- .../Reducer/RepositoriesFeature.swift | 20 +++++++---- supacodeTests/KeybindingSchemaTests.swift | 8 +++-- supacodeTests/ShelfFeatureTests.swift | 36 +++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index f38d164c..53c3e0a4 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -238,12 +238,8 @@ enum AppShortcuts { static let selectPreviousWorktree = AppShortcut( keyEquivalent: .upArrow, ghosttyKeyName: "arrow_up", modifiers: [.command, .control] ) - static let worktreeHistoryBack = AppShortcut( - keyEquivalent: .leftArrow, ghosttyKeyName: "arrow_left", modifiers: [.command, .option, .control] - ) - static let worktreeHistoryForward = AppShortcut( - keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .option, .control] - ) + static let worktreeHistoryBack = AppShortcut(key: "[", modifiers: [.command, .option]) + static let worktreeHistoryForward = AppShortcut(key: "]", modifiers: [.command, .option]) static let selectWorktree1 = AppShortcut(key: "1", modifiers: [.control]) static let selectWorktree2 = AppShortcut(key: "2", modifiers: [.control]) static let selectWorktree3 = AppShortcut(key: "3", modifiers: [.control]) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 41f47d6a..31a25156 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -304,7 +304,7 @@ struct RepositoriesFeature { case markWorktreeClosed(Worktree.ID) case setSidebarSelectedWorktreeIDs(Set) case selectRepository(Repository.ID?) - case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) + case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) case selectNextWorktree case selectPreviousWorktree case worktreeHistoryBack @@ -870,10 +870,10 @@ struct RepositoriesFeature { } return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) - case .selectWorktree(let worktreeID, let focusTerminal): + case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } - setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: true) + setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: recordHistory) if focusTerminal, let worktreeID { state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) } @@ -1553,11 +1553,13 @@ extension RepositoriesFeature.State { } var canNavigateWorktreeHistoryBackward: Bool { - canNavigateWorktreeHistory(stack: worktreeHistoryBackStack) + guard canUseWorktreeHistory else { return false } + return canNavigateWorktreeHistory(stack: worktreeHistoryBackStack) } var canNavigateWorktreeHistoryForward: Bool { - canNavigateWorktreeHistory(stack: worktreeHistoryForwardStack) + guard canUseWorktreeHistory else { return false } + return canNavigateWorktreeHistory(stack: worktreeHistoryForwardStack) } var selectedRepositoryID: Repository.ID? { @@ -1633,6 +1635,10 @@ extension RepositoriesFeature.State { isShelfActive } + private var canUseWorktreeHistory: Bool { + !isShowingShelf && !isShowingCanvas + } + var archivedWorktreeIDSet: Set { Set(archivedWorktrees.map(\.id)) } @@ -2435,7 +2441,7 @@ func shelfBookSelectionEffect( ) -> Effect { switch book.kind { case .worktree: - return .send(.selectWorktree(book.id, focusTerminal: true)) + return .send(.selectWorktree(book.id, focusTerminal: true, recordHistory: false)) case .plainFolder: return .send(.selectRepository(book.repositoryID)) } @@ -2484,6 +2490,7 @@ private func navigateWorktreeHistory( direction: WorktreeHistoryDirection, state: inout RepositoriesFeature.State ) -> Effect { + guard !state.isShowingShelf, !state.isShowingCanvas else { return .none } guard let currentID = state.selectedWorktreeID else { return .none } var sourceStack = direction == .backward @@ -2516,6 +2523,7 @@ private func recordWorktreeHistoryTransition( to nextID: Worktree.ID?, state: inout RepositoriesFeature.State ) { + guard !state.isShowingShelf, !state.isShowingCanvas else { return } guard let previousID, let nextID, previousID != nextID else { return } guard isSelectionValid(previousID, state: state), isSelectionValid(nextID, state: state) else { return } pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) diff --git a/supacodeTests/KeybindingSchemaTests.swift b/supacodeTests/KeybindingSchemaTests.swift index 7399cdbb..452d5959 100644 --- a/supacodeTests/KeybindingSchemaTests.swift +++ b/supacodeTests/KeybindingSchemaTests.swift @@ -56,8 +56,12 @@ struct KeybindingSchemaTests { @Test func worktreeHistoryShortcutsDoNotConflictWithShelfBookNavigation() { #expect(AppShortcuts.worktreeHistoryBack != AppShortcuts.selectPreviousShelfBook) #expect(AppShortcuts.worktreeHistoryForward != AppShortcuts.selectNextShelfBook) - #expect(AppShortcuts.worktreeHistoryBack.display == "⌘⌥⌃←") - #expect(AppShortcuts.worktreeHistoryForward.display == "⌘⌥⌃→") + #expect(AppShortcuts.worktreeHistoryBack != AppShortcuts.selectPreviousTerminalPane) + #expect(AppShortcuts.worktreeHistoryForward != AppShortcuts.selectNextTerminalPane) + #expect(AppShortcuts.worktreeHistoryBack != AppShortcuts.selectPreviousTerminalTab) + #expect(AppShortcuts.worktreeHistoryForward != AppShortcuts.selectNextTerminalTab) + #expect(AppShortcuts.worktreeHistoryBack.display == "⌘⌥[") + #expect(AppShortcuts.worktreeHistoryForward.display == "⌘⌥]") } @Test func resolverAppliesUserOverrideOverMigratedOverride() { diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index a835bf11..89f12e1e 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -442,6 +442,41 @@ struct ShelfFeatureTests { #expect(sentCommands.value == [.performBindingAction(wt1, action: "previous_tab")]) } + @Test(.dependencies) func worktreeHistoryIsUnavailableWhileShelfIsActive() async { + let fixture = threeWorktreeFixture() + var state = RepositoriesFeature.State(repositories: [fixture.repo]) + state.selection = .worktree(fixture.worktrees[1].id) + state.isShelfActive = true + state.worktreeHistoryBackStack = [fixture.worktrees[0].id] + state.worktreeHistoryForwardStack = [fixture.worktrees[2].id] + #expect(!state.canNavigateWorktreeHistoryBackward) + #expect(!state.canNavigateWorktreeHistoryForward) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeHistoryBack) + await store.send(.worktreeHistoryForward) + await store.finish() + } + + @Test(.dependencies) func worktreeHistoryIsUnavailableWhileCanvasIsActive() async { + let fixture = threeWorktreeFixture() + var state = RepositoriesFeature.State(repositories: [fixture.repo]) + state.selection = .canvas + state.worktreeHistoryBackStack = [fixture.worktrees[0].id] + state.worktreeHistoryForwardStack = [fixture.worktrees[2].id] + #expect(!state.canNavigateWorktreeHistoryBackward) + #expect(!state.canNavigateWorktreeHistoryForward) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeHistoryBack) + await store.send(.worktreeHistoryForward) + await store.finish() + } + @Test(.dependencies) func selectNextWorktreeOutsideShelfStillCyclesWorktrees() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let wt1 = Worktree( @@ -477,6 +512,7 @@ struct ShelfFeatureTests { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] + $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() -- 2.51.2 From 8b97e2f3beddd81687531b679e7e47c9a1261cb9 Mon Sep 17 00:00:00 2001 From: onevpaw Date: Fri, 8 May 2026 09:20:48 +0900 Subject: [PATCH 061/528] Fix shifted menu shortcut matching --- .../Ghostty/GhosttySurfaceView.swift | 65 ++++++++++++++--- supacodeTests/GhosttySurfaceViewTests.swift | 72 ++++++++++++++----- 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 8faf80ae..f97295ee 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1454,9 +1454,8 @@ final class GhosttySurfaceView: NSView, Identifiable { } static func mainMenuHasMatchingItem(for event: NSEvent, in menu: NSMenu) -> Bool { - guard let characters = event.charactersIgnoringModifiers?.lowercased(), !characters.isEmpty else { return false } - let shortcutMask: NSEvent.ModifierFlags = [.shift, .control, .option, .command] - let eventModifiers = event.modifierFlags.intersection(shortcutMask) + let eventEquivalents = normalizedEventKeyEquivalents(for: event) + guard !eventEquivalents.isEmpty else { return false } for item in menu.items { if let submenu = item.submenu, mainMenuHasMatchingItem(for: event, in: submenu) { @@ -1464,14 +1463,11 @@ final class GhosttySurfaceView: NSView, Identifiable { } guard !item.keyEquivalent.isEmpty else { continue } - let itemKey = item.keyEquivalent - guard itemKey.lowercased() == characters else { continue } - - var itemModifiers = item.keyEquivalentModifierMask.intersection(shortcutMask) - if itemKey != itemKey.lowercased() { - itemModifiers.insert(.shift) - } - if itemModifiers == eventModifiers { + guard let itemEquivalent = normalizedKeyEquivalent( + key: item.keyEquivalent, + modifiers: item.keyEquivalentModifierMask + ) else { continue } + if eventEquivalents.contains(where: { $0 == itemEquivalent }) { return true } } @@ -1479,6 +1475,53 @@ final class GhosttySurfaceView: NSView, Identifiable { return false } + private static let shortcutMask: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + private static let shiftedKeyEquivalentBases: [Character: Character] = [ + "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", + "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", + ":": ";", "\"": "'", "<": ",", ">": ".", "?": "/", + ] + + private struct KeyEquivalentSignature: Equatable { + var key: String + var modifiers: NSEvent.ModifierFlags + } + + private static func normalizedEventKeyEquivalents(for event: NSEvent) -> [KeyEquivalentSignature] { + let eventModifiers = event.modifierFlags.intersection(shortcutMask) + return [event.charactersIgnoringModifiers, event.characters] + .compactMap { characters in + characters.flatMap { normalizedKeyEquivalent(key: $0, modifiers: eventModifiers) } + } + .reduce(into: []) { result, equivalent in + if !result.contains(equivalent) { + result.append(equivalent) + } + } + } + + private static func normalizedKeyEquivalent( + key: String, + modifiers: NSEvent.ModifierFlags + ) -> KeyEquivalentSignature? { + guard !key.isEmpty else { return nil } + + var normalizedKey = key.lowercased() + var normalizedModifiers = modifiers.intersection(shortcutMask) + + if key.count == 1, let character = key.first { + if let base = shiftedKeyEquivalentBases[character] { + normalizedKey = String(base) + normalizedModifiers.insert(.shift) + } else if normalizedKey != key { + normalizedModifiers.insert(.shift) + } + } + + return KeyEquivalentSignature(key: normalizedKey, modifiers: normalizedModifiers) + } + private func shouldAttemptMenu(for flags: ghostty_binding_flags_e) -> Bool { if bridge.state.keySequenceActive == true { return false } if bridge.state.keyTableDepth > 0 { return false } diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index 62f08942..d0ece057 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -7,13 +7,13 @@ import Testing @MainActor struct GhosttySurfaceViewTests { - @Test func mainMenuExactMatchRejectsShiftVariantOfCommandComma() { + @Test func mainMenuExactMatchRejectsShiftVariantOfCommandComma() throws { let menu = NSMenu() let item = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",") item.keyEquivalentModifierMask = [.command] menu.addItem(item) - let event = makeKeyEvent( + let event = try makeKeyEvent( characters: "<", charactersIgnoringModifiers: ",", modifiers: [.command, .shift], @@ -23,13 +23,13 @@ struct GhosttySurfaceViewTests { #expect(!GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) } - @Test func mainMenuExactMatchAcceptsExactCommandComma() { + @Test func mainMenuExactMatchAcceptsExactCommandComma() throws { let menu = NSMenu() let item = NSMenuItem(title: "Settings", action: nil, keyEquivalent: ",") item.keyEquivalentModifierMask = [.command] menu.addItem(item) - let event = makeKeyEvent( + let event = try makeKeyEvent( characters: ",", charactersIgnoringModifiers: ",", modifiers: [.command], @@ -39,7 +39,39 @@ struct GhosttySurfaceViewTests { #expect(GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) } - @Test func mainMenuExactMatchFindsSubmenuItems() { + @Test func mainMenuExactMatchAcceptsShiftedSymbolKeyEquivalent() throws { + let menu = NSMenu() + let item = NSMenuItem(title: "Help", action: nil, keyEquivalent: "?") + item.keyEquivalentModifierMask = [.command] + menu.addItem(item) + + let event = try makeKeyEvent( + characters: "?", + charactersIgnoringModifiers: "/", + modifiers: [.command, .shift], + keyCode: 44 + ) + + #expect(GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) + } + + @Test func mainMenuExactMatchRejectsUnshiftedVariantOfShiftedSymbolKeyEquivalent() throws { + let menu = NSMenu() + let item = NSMenuItem(title: "Help", action: nil, keyEquivalent: "?") + item.keyEquivalentModifierMask = [.command] + menu.addItem(item) + + let event = try makeKeyEvent( + characters: "/", + charactersIgnoringModifiers: "/", + modifiers: [.command], + keyCode: 44 + ) + + #expect(!GhosttySurfaceView.mainMenuHasMatchingItem(for: event, in: menu)) + } + + @Test func mainMenuExactMatchFindsSubmenuItems() throws { let menu = NSMenu() let parent = NSMenuItem(title: "View", action: nil, keyEquivalent: "") let submenu = NSMenu() @@ -49,7 +81,7 @@ struct GhosttySurfaceViewTests { parent.submenu = submenu menu.addItem(parent) - let event = makeKeyEvent( + let event = try makeKeyEvent( characters: "Y", charactersIgnoringModifiers: "y", modifiers: [.command, .shift], @@ -435,18 +467,20 @@ struct GhosttySurfaceViewTests { charactersIgnoringModifiers: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16 - ) -> NSEvent { - NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: 1, - windowNumber: 0, - context: nil, - characters: characters, - charactersIgnoringModifiers: charactersIgnoringModifiers, - isARepeat: false, - keyCode: keyCode - )! + ) throws -> NSEvent { + try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 1, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, + isARepeat: false, + keyCode: keyCode + ) + ) } } -- 2.51.2 From 25b8be84f23275b93faaa707af9e02dd11b15cc0 Mon Sep 17 00:00:00 2001 From: onevpaw Date: Fri, 8 May 2026 09:30:31 +0900 Subject: [PATCH 062/528] Guard PR mutations on resolved GitHub repo --- supacode/Clients/Github/GithubCLIClient.swift | 19 +++--- .../GithubGraphQLPullRequestResponse.swift | 2 + ...epositoriesFeature+GithubIntegration.swift | 56 ++++++++++++----- .../Reducer/RepositoriesFeature.swift | 2 +- supacodeTests/RepositoriesFeatureTests.swift | 61 +++++++++++++++++++ 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index e740e6ba..11223f71 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -45,9 +45,9 @@ struct GithubCLIClient: Sendable { var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] - var mergePullRequest: @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void - var closePullRequest: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void - var markPullRequestReady: @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void + var mergePullRequest: @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy) async throws -> Void + var closePullRequest: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void + var markPullRequestReady: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void var failedRunLogs: @Sendable (URL, Int) async throws -> String var runLogs: @Sendable (URL, Int) async throws -> String @@ -299,7 +299,7 @@ nonisolated private func batchPullRequestsFetcher( nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int, PullRequestMergeStrategy) async throws -> Void { +) -> @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber, strategy in _ = try await runGh( shell: shell, @@ -318,7 +318,7 @@ nonisolated private func mergePullRequestFetcher( nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { +) -> @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, @@ -336,7 +336,7 @@ nonisolated private func closePullRequestFetcher( nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo?, Int) async throws -> Void { +) -> @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void { { repoRoot, remoteInfo, pullRequestNumber in _ = try await runGh( shell: shell, @@ -351,11 +351,8 @@ nonisolated private func markPullRequestReadyFetcher( } } -nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo?) -> [String] { - guard let remoteInfo else { - return [] - } - return ["--repo", "\(remoteInfo.host)/\(remoteInfo.owner)/\(remoteInfo.repo)"] +nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo) -> [String] { + ["--repo", "\(remoteInfo.host)/\(remoteInfo.owner)/\(remoteInfo.repo)"] } nonisolated private func rerunFailedJobsFetcher( diff --git a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift index af4ad74e..8bd776f1 100644 --- a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift +++ b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift @@ -20,6 +20,8 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { if !upstreamCandidates.isEmpty { candidates = upstreamCandidates } else { + // Without an upstream-repository match, same-name base branches are likely from unrelated + // fork workflows and can shadow the local worktree branch this app is trying to resolve. let forkCandidates = connection.nodes.filter { $0.headRepository != nil && $0.doesNotTargetSameBranch(branch) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index a1f13f0b..1458d7c9 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -2,8 +2,11 @@ import AppKit import ComposableArchitecture import Foundation +private let unresolvedGithubRepositoryMessage = + "Prowl could not determine which GitHub repository owns this pull request. Check the repository remote and try again." + extension RepositoriesFeature { - func resolveGithubRemoteInfo( + static func resolveGithubRemoteInfo( repositoryRootURL: URL, githubCLI: GithubCLIClient, gitClient: GitClientDependency @@ -338,11 +341,18 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Marking PR ready…"))) do { - let remoteInfo = await resolveGithubRemoteInfo( - repositoryRootURL: repoRoot, - githubCLI: githubCLI, - gitClient: gitClient - ) + guard + let remoteInfo = await Self.resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + else { + await send(.dismissToast) + await send( + .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) + return + } try await githubCLI.markPullRequestReady(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request marked ready"))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) @@ -376,11 +386,18 @@ extension RepositoriesFeature { let strategy = repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy await send(.showToast(.inProgress("Merging pull request…"))) do { - let remoteInfo = await resolveGithubRemoteInfo( - repositoryRootURL: repoRoot, - githubCLI: githubCLI, - gitClient: gitClient - ) + guard + let remoteInfo = await Self.resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + else { + await send(.dismissToast) + await send( + .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) + return + } try await githubCLI.mergePullRequest(worktreeRoot, remoteInfo, pullRequest.number, strategy) await send(.showToast(.success("Pull request merged"))) await send(.worktreeInfoEvent(pullRequestRefresh)) @@ -412,11 +429,18 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Closing pull request…"))) do { - let remoteInfo = await resolveGithubRemoteInfo( - repositoryRootURL: repoRoot, - githubCLI: githubCLI, - gitClient: gitClient - ) + guard + let remoteInfo = await Self.resolveGithubRemoteInfo( + repositoryRootURL: repoRoot, + githubCLI: githubCLI, + gitClient: gitClient + ) + else { + await send(.dismissToast) + await send( + .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) + return + } try await githubCLI.closePullRequest(worktreeRoot, remoteInfo, pullRequest.number) await send(.showToast(.success("Pull request closed"))) await send(.worktreeInfoEvent(pullRequestRefresh)) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index d2381eae..63cf7802 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1153,7 +1153,7 @@ struct RepositoriesFeature { let githubCLI = githubCLI return .run { send in guard - let remoteInfo = await resolveGithubRemoteInfo( + let remoteInfo = await Self.resolveGithubRemoteInfo( repositoryRootURL: repositoryRootURL, githubCLI: githubCLI, gitClient: gitClient diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 7501d737..a13be7f0 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3301,11 +3301,13 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") let mergedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.mergePullRequest = { _, _, number, _ in mergedNumbers.withValue { $0.append(number) } } @@ -3343,6 +3345,7 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") let mergedStrategies = LockIsolated<[PullRequestMergeStrategy]>([]) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { @@ -3356,6 +3359,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.mergePullRequest = { _, _, _, strategy in mergedStrategies.withValue { $0.append(strategy) } } @@ -3425,6 +3429,61 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func pullRequestActionMergeRequiresResolvedRemoteInfo() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .disabled + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: openPullRequest + ) + let mergeAttempts = LockIsolated(0) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.gitClient.remoteInfo = { _ in nil } + $0.githubCLI.mergePullRequest = { _, _, _, _ in + mergeAttempts.withValue { $0 += 1 } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Merging pull request…") + } + await store.receive(\.dismissToast) { + $0.statusToast = nil + } + await store.receive(\.presentAlert) { + $0.alert = AlertState { + TextState("GitHub repository not resolved") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState( + "Prowl could not determine which GitHub repository owns this pull request. " + + "Check the repository remote and try again." + ) + } + } + #expect(mergeAttempts.value == 0) + await store.finish() + } + @Test func pullRequestActionCloseRefreshesImmediately() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -3442,11 +3501,13 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) + let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") let closedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.closePullRequest = { _, _, number in closedNumbers.withValue { $0.append(number) } } -- 2.51.2 From 4244a3299531cca9bf07bb69ccf4a257ed61fa00 Mon Sep 17 00:00:00 2001 From: onevtail Date: Fri, 8 May 2026 09:42:45 +0900 Subject: [PATCH 063/528] Add latest unread command palette action --- supacode/App/supacodeApp.swift | 3 + .../Clients/Terminal/TerminalClient.swift | 7 ++- .../Features/App/Reducer/AppFeature.swift | 4 ++ .../CommandPalette/CommandPaletteItem.swift | 7 ++- .../Reducer/CommandPaletteFeature.swift | 61 ++++++++++++++----- .../Views/CommandPaletteOverlayView.swift | 8 ++- .../WorktreeTerminalManager.swift | 4 ++ .../AppFeatureCommandPaletteTests.swift | 9 +++ .../AppFeatureJumpToLatestUnreadTests.swift | 5 ++ .../CommandPaletteFeatureTests.swift | 13 ++++ 10 files changed, 101 insertions(+), 20 deletions(-) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 044b0380..52bdcc7b 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -264,6 +264,9 @@ struct SupacodeApp: App { }, markNotificationRead: { worktreeID, notificationID in terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + }, + markNotificationsReadForSurface: { worktreeID, surfaceID in + terminalManager.markNotificationsRead(worktreeID: worktreeID, surfaceID: surfaceID) } ) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index da35439f..94187a70 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -8,6 +8,7 @@ struct TerminalClient { var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void + var markNotificationsReadForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Void enum Command: Equatable { case createTab(Worktree, runSetupScriptIfNew: Bool) @@ -74,7 +75,8 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, - markNotificationRead: { _, _ in } + markNotificationRead: { _, _ in }, + markNotificationsReadForSurface: { _, _ in } ) static let testValue = TerminalClient( @@ -83,7 +85,8 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, - markNotificationRead: { _, _ in } + markNotificationRead: { _, _ in }, + markNotificationsReadForSurface: { _, _ in } ) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 9f049646..d7f0a8fe 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -885,6 +885,7 @@ struct AppFeature { .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), .run { _ in _ = await terminalClient.focusSurface(worktreeID, surfaceID) + await terminalClient.markNotificationsReadForSurface(worktreeID, surfaceID) } ) @@ -943,6 +944,9 @@ struct AppFeature { case .commandPalette(.delegate(.refreshWorktrees)): return .send(.repositories(.refreshWorktrees)) + case .commandPalette(.delegate(.jumpToLatestUnread)): + return .send(.jumpToLatestUnread) + case .commandPalette(.delegate(.installCLI)): return .send(.settings(.installCLIButtonTapped(showAlert: false))) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index d67e0dde..c906ff2c 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -31,6 +31,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case archiveWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees + case jumpToLatestUnread case ghosttyCommand(String) case openPullRequest(Worktree.ID) case openRepositoryOnCodeHost(Worktree.ID) @@ -52,7 +53,7 @@ struct CommandPaletteItem: Identifiable, Equatable { var isGlobal: Bool { switch kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI: + .refreshWorktrees, .installCLI, .jumpToLatestUnread: return true case .ghosttyCommand: return false @@ -81,7 +82,7 @@ struct CommandPaletteItem: Identifiable, Equatable { var isRootAction: Bool { switch kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI: + .refreshWorktrees, .installCLI, .jumpToLatestUnread: return true case .ghosttyCommand: return false @@ -120,6 +121,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.archivedWorktrees case .refreshWorktrees: return AppShortcuts.CommandID.refreshWorktrees + case .jumpToLatestUnread: + return AppShortcuts.CommandID.jumpToLatestUnread case .openPullRequest, .openRepositoryOnCodeHost: return AppShortcuts.CommandID.openPullRequest diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index e9f2f6d7..19a824c7 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -40,6 +40,7 @@ struct CommandPaletteFeature { case archiveWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees + case jumpToLatestUnread case ghosttyCommand(String) case openPullRequest(Worktree.ID) case markPullRequestReady(Worktree.ID) @@ -209,6 +210,14 @@ struct CommandPaletteFeature { kind: .refreshWorktrees ) ) + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalJumpToLatestUnread, + title: "Jump to Latest Unread", + subtitle: nil, + kind: .jumpToLatestUnread + ) + ) items.append( CommandPaletteItem( id: CommandPaletteItemID.globalViewArchivedWorktrees, @@ -493,6 +502,7 @@ private enum CommandPaletteItemID { static let globalOpenRepository = "global.open-repository" static let globalNewWorktree = "global.new-worktree" static let globalRefreshWorktrees = "global.refresh-worktrees" + static let globalJumpToLatestUnread = "global.jump-to-latest-unread" static let globalViewArchivedWorktrees = "global.view-archived-worktrees" static let globalInstallCLI = "global.install-cli" @@ -503,6 +513,7 @@ private enum CommandPaletteItemID { globalOpenRepository, globalNewWorktree, globalRefreshWorktrees, + globalJumpToLatestUnread, globalViewArchivedWorktrees, globalInstallCLI, ] @@ -599,27 +610,16 @@ private func commandPaletteRecencyScore( } private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate { + if let appAction = appDelegateAction(for: kind) { + return appAction + } switch kind { case .worktreeSelect(let id): return .selectWorktree(id) - case .checkForUpdates: - return .checkForUpdates - case .openSettings: - return .openSettings - case .newWorktree: - return .newWorktree - case .openRepository: - return .openRepository case .removeWorktree(let worktreeID, let repositoryID): return .removeWorktree(worktreeID, repositoryID) case .archiveWorktree(let worktreeID, let repositoryID): return .archiveWorktree(worktreeID, repositoryID) - case .viewArchivedWorktrees: - return .viewArchivedWorktrees - case .refreshWorktrees: - return .refreshWorktrees - case .installCLI: - return .installCLI case .ghosttyCommand(let action): return .ghosttyCommand(action) case .changeFocusedTabIcon(let worktreeID): @@ -640,6 +640,38 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette case .debugSimulateUpdateFound: return .debugSimulateUpdateFound #endif + case .checkForUpdates, + .openSettings, + .newWorktree, + .openRepository, + .viewArchivedWorktrees, + .refreshWorktrees, + .jumpToLatestUnread, + .installCLI: + fatalError("appDelegateAction should handle app-level command palette actions") + } +} + +private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .checkForUpdates: + return .checkForUpdates + case .openSettings: + return .openSettings + case .newWorktree: + return .newWorktree + case .openRepository: + return .openRepository + case .viewArchivedWorktrees: + return .viewArchivedWorktrees + case .refreshWorktrees: + return .refreshWorktrees + case .jumpToLatestUnread: + return .jumpToLatestUnread + case .installCLI: + return .installCLI + default: + return nil } } @@ -673,6 +705,7 @@ private func pullRequestDelegateAction( .archiveWorktree, .viewArchivedWorktrees, .refreshWorktrees, + .jumpToLatestUnread, .installCLI, .ghosttyCommand, .changeFocusedTabIcon: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index c4d716c2..61377a32 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -348,7 +348,7 @@ private struct CommandPaletteRowView: View { private var badge: String? { switch row.kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .ghosttyCommand, + .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, @@ -379,6 +379,8 @@ private struct CommandPaletteRowView: View { return "archivebox" case .refreshWorktrees: return "arrow.clockwise" + case .jumpToLatestUnread: + return "bell.badge" case .ghosttyCommand: return "terminal" case .openPullRequest, .openRepositoryOnCodeHost: @@ -419,7 +421,7 @@ private struct CommandPaletteRowView: View { private var emphasis: Bool { switch row.kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .ghosttyCommand, + .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, @@ -515,6 +517,8 @@ private struct CommandPaletteRowView: View { base = "View Archived Worktrees" case .refreshWorktrees: base = "Refresh Worktrees" + case .jumpToLatestUnread: + base = "Jump to Latest Unread" case .ghosttyCommand: base = row.title case .removeWorktree: diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 109334b3..48294c16 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -466,6 +466,10 @@ final class WorktreeTerminalManager { states[worktreeID]?.markNotificationRead(id: notificationID) } + func markNotificationsRead(worktreeID: Worktree.ID, surfaceID: UUID) { + states[worktreeID]?.markNotificationsRead(forSurfaceID: surfaceID) + } + func surfaceBackgroundOpacity() -> Double { runtime?.backgroundOpacity() ?? 1.0 } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index a4244deb..a245da9d 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -79,6 +79,15 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.updates.checkForUpdates) } + @Test(.dependencies) func jumpToLatestUnreadDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.jumpToLatestUnread))) + await store.receive(\.jumpToLatestUnread) + } + @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToTerminalClient() async { let worktree = makeWorktree( id: "/tmp/repo-ghostty/wt-1", diff --git a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift index d445ae64..daab59ce 100644 --- a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift +++ b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift @@ -58,6 +58,7 @@ struct AppFeatureJumpToLatestUnreadTests { var repositoriesState = RepositoriesFeature.State(repositories: [repository]) repositoriesState.snapshotPersistencePhase = .active let focusedSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) + let readSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) let store = TestStore( initialState: AppFeature.State(repositories: repositoriesState) ) { @@ -67,6 +68,9 @@ struct AppFeatureJumpToLatestUnreadTests { focusedSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } return true } + $0.terminalClient.markNotificationsReadForSurface = { worktreeID, targetSurfaceID in + readSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } + } } store.exhaustivity = .off @@ -77,6 +81,7 @@ struct AppFeatureJumpToLatestUnreadTests { await store.finish() #expect(focusedSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) + #expect(readSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) } private func makeWorktree() -> Worktree { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 5bf5fdbd..954866dd 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -16,6 +16,7 @@ struct CommandPaletteFeatureTests { "global.open-repository", "global.new-worktree", "global.refresh-worktrees", + "global.jump-to-latest-unread", "global.view-archived-worktrees", "global.install-cli", ] @@ -29,6 +30,18 @@ struct CommandPaletteFeatureTests { expectNoDifference(items.map(\.id), expectedIDs) } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + let item = items.first { $0.id == "global.jump-to-latest-unread" } + + #expect(item?.title == "Jump to Latest Unread") + #expect(item?.kind == .jumpToLatestUnread) + #expect(item?.appShortcutCommandID == AppShortcuts.CommandID.jumpToLatestUnread) + + let filtered = CommandPaletteFeature.filterItems(items: items, query: "jump unread") + #expect(filtered.first?.id == "global.jump-to-latest-unread") + } + @Test func commandPaletteItems_skipsPendingAndDeletingWorktrees() { let rootPath = "/tmp/repo" let keep = makeWorktree(id: "\(rootPath)/wt-keep", name: "keep", repoRoot: rootPath) -- 2.51.2 From ae386636c1b29aac41415dad07a0665103445cf5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 21:56:01 +0900 Subject: [PATCH 064/528] Apply swift-format to GhosttySurfaceView --- .../Infrastructure/Ghostty/GhosttySurfaceView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index f97295ee..0c297488 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1463,10 +1463,12 @@ final class GhosttySurfaceView: NSView, Identifiable { } guard !item.keyEquivalent.isEmpty else { continue } - guard let itemEquivalent = normalizedKeyEquivalent( - key: item.keyEquivalent, - modifiers: item.keyEquivalentModifierMask - ) else { continue } + guard + let itemEquivalent = normalizedKeyEquivalent( + key: item.keyEquivalent, + modifiers: item.keyEquivalentModifierMask + ) + else { continue } if eventEquivalents.contains(where: { $0 == itemEquivalent }) { return true } -- 2.51.2 From ad1dfd90fd0ee1cf2f4fee5c8e1e6f8e77234054 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 22:34:12 +0900 Subject: [PATCH 065/528] Polish persistent custom tab titles - Drop dead overrideTitle/clearTitleOverride wrappers; setCustomTitle is the only writer. - Bump snapshot schema to v2 and migrate v1 title -> customTitle on decode so previously frozen titles survive the upgrade as user overrides instead of getting overwritten by the next OSC-2 update. - Auto select-all when entering inline rename so users can immediately overwrite. - Frame the rename TextField with a bordered field to make edit mode visually obvious. - Remove redundant onDisappear rename-save path; tabs.didSet already drives the editing teardown. --- .../TerminalLayoutSnapshotPayload.swift | 43 ++++++++++++- .../Terminal/Models/TerminalTabManager.swift | 8 --- .../TabBar/TerminalTabBarMetrics.swift | 2 + .../TabBar/Views/TerminalTabView.swift | 32 +++++++--- .../TerminalLayoutSnapshotPayloadTests.swift | 62 +++++++++++++++++++ 5 files changed, 126 insertions(+), 21 deletions(-) diff --git a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift index 0d0050d7..b66e7acc 100644 --- a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift +++ b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift @@ -1,7 +1,8 @@ import Foundation nonisolated struct TerminalLayoutSnapshotPayload: Codable, Equatable, Sendable { - nonisolated static let currentVersion = 1 + nonisolated static let currentVersion = 2 + nonisolated static let minSupportedVersion = 1 nonisolated static let maxSnapshotFileBytes = 2 * 1024 * 1024 nonisolated static let maxWorktrees = 128 nonisolated static let maxTabsPerWorktree = 128 @@ -32,11 +33,47 @@ nonisolated struct TerminalLayoutSnapshotPayload: Codable, Equatable, Sendable { guard let payload = try? decoder.decode(Self.self, from: data) else { return nil } - return payload.isValid ? payload : nil + let migrated = payload.migratedToCurrentVersion() + return migrated.isValid ? migrated : nil + } + + /// Upgrade v1 payloads to the current schema. + /// + /// In v1 the `title` field on `SnapshotTab` doubled as both the live shell + /// title (which was frozen on restore) and the user's overridden title — they + /// were indistinguishable on disk. After v2 the user override moves to + /// `customTitle`, so promoting a v1 `title` to `customTitle` preserves what + /// the user actually saw across the upgrade. The downside (a previously + /// non-overridden tab now looks "pinned") is something the user can clear via + /// the new inline rename. + func migratedToCurrentVersion() -> TerminalLayoutSnapshotPayload { + guard version < Self.currentVersion else { return self } + let migratedWorktrees = worktrees.map { worktree -> SnapshotWorktree in + let migratedTabs = worktree.tabs.map { tab -> SnapshotTab in + guard tab.customTitle == nil, let title = tab.title else { return tab } + return SnapshotTab( + tabID: tab.tabID, + title: nil, + customTitle: title, + icon: tab.icon, + splitRoot: tab.splitRoot + ) + } + return SnapshotWorktree( + worktreeID: worktree.worktreeID, + selectedTabID: worktree.selectedTabID, + tabs: migratedTabs + ) + } + return TerminalLayoutSnapshotPayload( + version: Self.currentVersion, + selectedWorktreeID: selectedWorktreeID, + worktrees: migratedWorktrees + ) } var isValid: Bool { - guard version == Self.currentVersion else { + guard (Self.minSupportedVersion...Self.currentVersion).contains(version) else { return false } guard !worktrees.isEmpty, worktrees.count <= Self.maxWorktrees else { diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index c6bb8759..436b15f4 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -37,14 +37,6 @@ final class TerminalTabManager { tabs[index].title = title } - func overrideTitle(_ id: TerminalTabID, title: String) { - setCustomTitle(id, title: title) - } - - func clearTitleOverride(_ id: TerminalTabID) { - setCustomTitle(id, title: "") - } - func setCustomTitle(_ id: TerminalTabID, title: String) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } guard !tabs[index].isTitleLocked else { return } diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 69841735..78e30e74 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -25,4 +25,6 @@ enum TerminalTabBarMetrics { static let selectionAnimationDuration: Double = 0.15 static let reorderAnimationDuration: Double = 0.3 static let reorderAnimationBounce: Double = 0.15 + static let renameFieldCornerRadius: CGFloat = 4 + static let renameFieldInset: CGFloat = 4 } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 3fd75b79..e155b353 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -79,11 +79,24 @@ struct TerminalTabView: View { .focused($isFieldFocused) .foregroundStyle(TerminalTabBarColors.activeText) .accessibilityLabel("Rename tab") - .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) - .padding( - .trailing, - TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing + .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) + .background( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .strokeBorder(Color.accentColor, lineWidth: 1.5) + ) ) + .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.contentSpacing) + .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .onSubmit { onEndRename() } .onExitCommand { @@ -124,18 +137,17 @@ struct TerminalTabView: View { initialEditingTitle = tab.displayTitle cancelOnExit = false isFieldFocused = true + // The field editor only attaches after SwiftUI promotes the TextField + // to first responder, so defer selectAll one hop to land on it. + DispatchQueue.main.async { + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) + } } else if cancelOnExit { cancelOnExit = false } else if editingTitle != initialEditingTitle { onRename(editingTitle) } } - .onDisappear { - guard isEditing else { return } - defer { onEndRename() } - guard !cancelOnExit, editingTitle != initialEditingTitle else { return } - onRename(editingTitle) - } .zIndex(isActive ? 2 : (isDragging ? 3 : 0)) .overlay { MiddleClickView(action: onClose) diff --git a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift index 0b109cd8..7b52a7fe 100644 --- a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift @@ -273,6 +273,68 @@ struct TerminalLayoutSnapshotPayloadTests { #expect(decodedTab?.icon == nil) } + @Test func decodeValidatedMigratesV1TitleIntoCustomTitle() { + let json = #""" + { + "version": 1, + "worktrees": [ + { + "worktreeID": "wt-1", + "selectedTabID": "tab-1", + "tabs": [ + { + "tabID": "tab-1", + "title": "Frozen Title", + "splitRoot": { + "kind": "leaf", + "surfaceID": "surface-1" + } + } + ] + } + ] + } + """# + let data = Data(json.utf8) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + #expect(decoded?.version == TerminalLayoutSnapshotPayload.currentVersion) + let decodedTab = decoded?.worktrees.first?.tabs.first + #expect(decodedTab?.title == nil) + #expect(decodedTab?.customTitle == "Frozen Title") + } + + @Test func decodeValidatedV1MigrationLeavesExistingCustomTitleAlone() { + let json = #""" + { + "version": 1, + "worktrees": [ + { + "worktreeID": "wt-1", + "selectedTabID": "tab-1", + "tabs": [ + { + "tabID": "tab-1", + "title": "Live", + "customTitle": "Pinned", + "splitRoot": { + "kind": "leaf", + "surfaceID": "surface-1" + } + } + ] + } + ] + } + """# + let data = Data(json.utf8) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + let decodedTab = decoded?.worktrees.first?.tabs.first + #expect(decodedTab?.title == "Live") + #expect(decodedTab?.customTitle == "Pinned") + } + @Test func decodeValidatedBackwardCompatibleWithoutTitleAndIcon() { let json = #""" { -- 2.51.2 From 9d872892626ca33d106527a705717b394628e146 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 22:59:36 +0900 Subject: [PATCH 066/528] Wrap inline tab rename in NSTextField for reliable select-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI TextField + @FocusState plus NSApp.sendAction(selectAll:) was unreliable on macOS — the focus promotion crossed multiple runloop ticks, and sendAction along the responder chain reached the active GhosttySurface first, selecting every glyph in the terminal instead of the tab title. Own the NSTextField directly via NSViewRepresentable so we can drive selectAll on its own field editor in viewDidMoveToWindow without fighting SwiftUI focus timing. Cancel/commit semantics route through the delegate's doCommandBy and controlTextDidEndEditing callbacks. --- .../TabBar/Views/TerminalTabView.swift | 152 +++++++++++++----- 1 file changed, 116 insertions(+), 36 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index e155b353..28bb4b36 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -22,7 +22,6 @@ struct TerminalTabView: View { @State private var editingTitle = "" @State private var initialEditingTitle = "" @State private var cancelOnExit = false - @FocusState private var isFieldFocused: Bool @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings @@ -73,40 +72,34 @@ struct TerminalTabView: View { } .overlay { if isEditing { - TextField("", text: $editingTitle) - .textFieldStyle(.plain) - .font(.caption) - .focused($isFieldFocused) - .foregroundStyle(TerminalTabBarColors.activeText) - .accessibilityLabel("Rename tab") - .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) - .background( + RenameTextField( + text: $editingTitle, + onCommit: { onEndRename() }, + onCancel: { + cancelOnExit = true + onEndRename() + } + ) + .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) + .background( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( RoundedRectangle( cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, style: .continuous ) - .fill(Color(nsColor: .textBackgroundColor)) - .overlay( - RoundedRectangle( - cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, - style: .continuous - ) - .strokeBorder(Color.accentColor, lineWidth: 1.5) - ) + .strokeBorder(Color.accentColor, lineWidth: 1.5) ) - .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.contentSpacing) - .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) - .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .onSubmit { onEndRename() } - .onExitCommand { - cancelOnExit = true - onEndRename() - } - .onChange(of: isFieldFocused) { _, focused in - guard !focused, isEditing else { return } - onEndRename() - } + ) + .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.contentSpacing) + .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .accessibilityLabel("Rename tab") } } .background { @@ -136,12 +129,6 @@ struct TerminalTabView: View { editingTitle = tab.displayTitle initialEditingTitle = tab.displayTitle cancelOnExit = false - isFieldFocused = true - // The field editor only attaches after SwiftUI promotes the TextField - // to first responder, so defer selectAll one hop to land on it. - DispatchQueue.main.async { - NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) - } } else if cancelOnExit { cancelOnExit = false } else if editingTitle != initialEditingTitle { @@ -217,3 +204,96 @@ private final class MiddleClickNSView: NSView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } + +/// Inline rename field for tabs. +/// +/// SwiftUI's `TextField` + `@FocusState` plus `NSApp.sendAction(selectAll:)` +/// is unreliable here: the focus promotion spans multiple runloop ticks, and +/// `sendAction` along the responder chain reaches whatever owns the chain +/// first — typically the active GhosttySurface, which happily selects every +/// glyph in the terminal instead of the tab title. Owning the `NSTextField` +/// directly lets us drive `selectAll` on its own field editor without +/// fighting SwiftUI's focus timing. +private struct RenameTextField: NSViewRepresentable { + @Binding var text: String + let onCommit: () -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeNSView(context: Context) -> RenameNSTextField { + let field = RenameNSTextField() + field.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + field.textColor = .labelColor + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + field.cell?.usesSingleLineMode = true + field.cell?.wraps = false + field.cell?.isScrollable = true + field.lineBreakMode = .byClipping + field.stringValue = text + field.delegate = context.coordinator + field.setContentHuggingPriority(.defaultLow, for: .horizontal) + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + func updateNSView(_ nsView: RenameNSTextField, context: Context) { + context.coordinator.parent = self + if nsView.stringValue != text { + nsView.stringValue = text + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: RenameTextField + private var hasResolved = false + + init(parent: RenameTextField) { self.parent = parent } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.cancelOperation(_:)): + hasResolved = true + parent.onCancel() + return true + case #selector(NSResponder.insertNewline(_:)): + hasResolved = true + parent.onCommit() + return true + default: + return false + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard !hasResolved else { return } + hasResolved = true + parent.onCommit() + } + } +} + +private final class RenameNSTextField: NSTextField { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + // Defer one runloop hop so AppKit finishes mounting the field before we + // request first-responder; otherwise `currentEditor()` returns nil. + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window else { return } + window.makeFirstResponder(self) + self.currentEditor()?.selectAll(nil) + } + } +} -- 2.51.2 From b36c60339ef6201fb0b8d76263d4544a64a6ff98 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 23:11:12 +0900 Subject: [PATCH 067/528] Refine inline tab rename interaction - Keep the tab icon visible during inline rename so users retain context for which tab they are renaming. Extracted the icon column into TerminalTabIconBadge so the rename overlay reuses it. - Split double-click handling by hit zone: double-clicking the icon column opens the icon picker, while double-clicking the title area still starts inline rename. Driven by a SpatialTapGesture and a small isInIconHitArea helper. --- .../TabBar/Views/TerminalTabLabelView.swift | 39 +++++++----- .../TabBar/Views/TerminalTabView.swift | 61 ++++++++++++------- .../TabBar/Views/TerminalTabsRowView.swift | 3 + 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index c7e53c0d..1c68ac1e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -11,21 +11,7 @@ struct TerminalTabLabelView: View { var body: some View { HStack(spacing: TerminalTabBarMetrics.contentSpacing) { if tab.isDirty || tab.icon != nil { - ZStack { - if tab.isDirty { - ProgressView() - .controlSize(.small) - .tint(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) - } else if let icon = tab.icon { - TabIconImage(rawName: icon, pointSize: 12) - .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) - } - } - .frame( - width: TerminalTabBarMetrics.closeButtonSize, - height: TerminalTabBarMetrics.closeButtonSize - ) - .accessibilityHidden(true) + TerminalTabIconBadge(tab: tab, isActive: isActive) } Text(tab.displayTitle) .font(.caption) @@ -44,3 +30,26 @@ struct TerminalTabLabelView: View { .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) } } + +struct TerminalTabIconBadge: View { + let tab: TerminalTabItem + let isActive: Bool + + var body: some View { + ZStack { + if tab.isDirty { + ProgressView() + .controlSize(.small) + .tint(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) + } else if let icon = tab.icon { + TabIconImage(rawName: icon, pointSize: 12) + .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) + } + } + .frame( + width: TerminalTabBarMetrics.closeButtonSize, + height: TerminalTabBarMetrics.closeButtonSize + ) + .accessibilityHidden(true) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 28bb4b36..443b8d78 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -11,6 +11,7 @@ struct TerminalTabView: View { let onSelect: () -> Void let onClose: () -> Void let onRename: (String) -> Void + let onChangeIcon: () -> Void @Binding var closeButtonGestureActive: Bool let isEditing: Bool let onBeginRename: () -> Void @@ -72,34 +73,39 @@ struct TerminalTabView: View { } .overlay { if isEditing { - RenameTextField( - text: $editingTitle, - onCommit: { onEndRename() }, - onCancel: { - cancelOnExit = true - onEndRename() + HStack(spacing: TerminalTabBarMetrics.contentSpacing) { + if tab.isDirty || tab.icon != nil { + TerminalTabIconBadge(tab: tab, isActive: isActive) } - ) - .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) - .background( - RoundedRectangle( - cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, - style: .continuous + RenameTextField( + text: $editingTitle, + onCommit: { onEndRename() }, + onCancel: { + cancelOnExit = true + onEndRename() + } ) - .fill(Color(nsColor: .textBackgroundColor)) - .overlay( + .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) + .background( RoundedRectangle( cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, style: .continuous ) - .strokeBorder(Color.accentColor, lineWidth: 1.5) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .strokeBorder(Color.accentColor, lineWidth: 1.5) + ) ) - ) - .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.contentSpacing) + .accessibilityLabel("Rename tab") + } + .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding) .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .accessibilityLabel("Rename tab") } } .background { @@ -119,9 +125,12 @@ struct TerminalTabView: View { isHovering = hovering } .simultaneousGesture( - TapGesture(count: 2).onEnded { - guard !tab.isTitleLocked else { return } - onBeginRename() + SpatialTapGesture(count: 2, coordinateSpace: .local).onEnded { value in + if isInIconHitArea(value.location) { + onChangeIcon() + } else if !tab.isTitleLocked { + onBeginRename() + } } ) .onChange(of: isEditing) { _, editing in @@ -152,6 +161,16 @@ struct TerminalTabView: View { private var isShowingNotificationDot: Bool { hasNotification && !isHovering && !isHoveringClose && !isDragging && !showsShortcutHint } + + /// Hit zone for the icon in tab-local coordinates. Covers the leading + /// padding plus the icon column, so a double-click anywhere on the icon + /// (or the empty strip just to its left) opens the icon picker. The rest + /// of the tab routes to inline rename. + private func isInIconHitArea(_ point: CGPoint) -> Bool { + guard tab.isDirty || tab.icon != nil else { return false } + let maxX = TerminalTabBarMetrics.tabHorizontalPadding + TerminalTabBarMetrics.closeButtonSize + return point.x >= 0 && point.x <= maxX + } } private struct TabNotificationDot: View { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index b3983c53..6687a301 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -41,6 +41,9 @@ struct TerminalTabsRowView: View { onRename: { newTitle in manager.setCustomTitle(id, title: newTitle) }, + onChangeIcon: { + changeIcon(id) + }, closeButtonGestureActive: $closeButtonGestureActive, isEditing: manager.editingTabID == id, onBeginRename: { -- 2.51.2 From 374610ae87a318fc11b31f477f3e2da3ff021a3b Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 23:57:34 +0900 Subject: [PATCH 068/528] Tighten worktree history availability and forward-stack reset - Disable Back/Forward menu items when selection is .repository, .archivedWorktrees, or nil so the shortcut isn't a no-op. - Clear the redo stack on any worktree-selection change, including detours through a repository or archived view, and only push the previous worktree onto the back stack when it's still a valid worktree. - Hook .selectRepository and .selectArchivedWorktrees into the history transition so leaving a worktree by sidebar click is recorded the same as a worktree-to-worktree switch. --- .../Reducer/RepositoriesFeature.swift | 24 +++- supacodeTests/RepositoriesFeatureTests.swift | 107 ++++++++++++++++++ supacodeTests/ShelfFeatureTests.swift | 1 + 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 31a25156..44684fcc 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -707,6 +707,7 @@ struct RepositoriesFeature { case .selectArchivedWorktrees: state.isShelfActive = false + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .archivedWorktrees state.sidebarSelectedWorktreeIDs = [] return .send(.delegate(.selectedWorktreeChanged(nil))) @@ -862,6 +863,7 @@ struct RepositoriesFeature { let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") defer { repositoriesLogger.endInterval(selectRepoToken) } guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .repository(repositoryID) state.sidebarSelectedWorktreeIDs = [] if state.repositories[id: repositoryID]?.kind == .plain { @@ -1636,7 +1638,11 @@ extension RepositoriesFeature.State { } private var canUseWorktreeHistory: Bool { - !isShowingShelf && !isShowingCanvas + // History navigation only makes sense when a worktree is the current + // anchor. With selection on `.repository`, `.archivedWorktrees`, or `nil`, + // there is no "current" position to step away from, so the menu items + // (and their shortcuts) must be disabled. + !isShowingShelf && !isShowingCanvas && selectedWorktreeID != nil } var archivedWorktreeIDSet: Set { @@ -2523,11 +2529,21 @@ private func recordWorktreeHistoryTransition( to nextID: Worktree.ID?, state: inout RepositoriesFeature.State ) { + // Shelf / Canvas are mode switches, not worktree navigation — leave + // history frozen so users can resume Back/Forward where they left off. guard !state.isShowingShelf, !state.isShowingCanvas else { return } - guard let previousID, let nextID, previousID != nextID else { return } - guard isSelectionValid(previousID, state: state), isSelectionValid(nextID, state: state) else { return } - pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) + // No-op transitions (same worktree, or both endpoints nil) leave history alone. + if previousID == nextID { return } + // Any user-initiated selection change invalidates the redo path. Crucially + // this also fires when the user navigates to/from .repository or + // .archivedWorktrees (one or both IDs nil), so a stale forward stack + // can't carry over into an unrelated path. state.worktreeHistoryForwardStack = [] + // Only push onto the back stack when leaving a still-valid worktree; we + // don't want non-worktree selections (repository / archived) showing up + // as Back targets. + guard let previousID, isSelectionValid(previousID, state: state) else { return } + pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) } private func popValidWorktreeHistoryDestination( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b51cede4..a3f075eb 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -960,6 +960,7 @@ struct RepositoriesFeatureTests { } await store.send(.selectRepository(repository.id)) { + $0.worktreeHistoryBackStack = [worktree.id] $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] } @@ -1087,6 +1088,7 @@ struct RepositoriesFeatureTests { } await store.send(.selectArchivedWorktrees) { + $0.worktreeHistoryBackStack = [worktree1.id] $0.selection = .archivedWorktrees $0.sidebarSelectedWorktreeIDs = [] } @@ -4335,6 +4337,111 @@ struct RepositoriesFeatureTests { #expect(state.canNavigateWorktreeHistoryBackward) } + @Test func canNavigateWorktreeHistoryDisabledWhenSelectionIsNotAWorktree() { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.worktreeHistoryBackStack = [wt1.id] + state.worktreeHistoryForwardStack = [wt2.id] + + state.selection = .repository(repository.id) + #expect(!state.canNavigateWorktreeHistoryBackward) + #expect(!state.canNavigateWorktreeHistoryForward) + + state.selection = .archivedWorktrees + #expect(!state.canNavigateWorktreeHistoryBackward) + #expect(!state.canNavigateWorktreeHistoryForward) + + state.selection = nil + #expect(!state.canNavigateWorktreeHistoryBackward) + #expect(!state.canNavigateWorktreeHistoryForward) + + state.selection = .worktree(wt1.id) + #expect(state.canNavigateWorktreeHistoryForward) + } + + @Test func selectRepositoryPushesWorktreeOntoBackStackAndClearsForwardStack() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt1.id) + state.sidebarSelectedWorktreeIDs = [wt1.id] + state.worktreeHistoryForwardStack = [wt2.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.selectRepository(repository.id)) { + $0.worktreeHistoryBackStack = [wt1.id] + $0.worktreeHistoryForwardStack = [] + $0.selection = .repository(repository.id) + $0.sidebarSelectedWorktreeIDs = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func selectArchivedWorktreesPushesWorktreeOntoBackStackAndClearsForwardStack() async { + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(wt1.id) + state.sidebarSelectedWorktreeIDs = [wt1.id] + state.worktreeHistoryForwardStack = [wt2.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.selectArchivedWorktrees) { + $0.worktreeHistoryBackStack = [wt1.id] + $0.worktreeHistoryForwardStack = [] + $0.selection = .archivedWorktrees + $0.sidebarSelectedWorktreeIDs = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + } + + @Test func reselectingWorktreeAfterRepositoryDoesNotResurrectStaleForwardEntry() async { + // Reproduces the scenario from the Copilot review: after the user + // navigates Back and then leaves the worktree view through a + // non-worktree selection, the stale forward target must not survive + // the next worktree selection. + let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") + let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") + let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") + let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) + var state = makeState(repositories: [repository]) + // User landed on wt2 via Back, leaving wt3 in the forward stack. + state.selection = .worktree(wt2.id) + state.worktreeHistoryBackStack = [wt1.id] + state.worktreeHistoryForwardStack = [wt3.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + // Detour through repository view. + await store.send(.selectRepository(repository.id)) { + $0.worktreeHistoryBackStack = [wt1.id, wt2.id] + $0.worktreeHistoryForwardStack = [] + $0.selection = .repository(repository.id) + $0.sidebarSelectedWorktreeIDs = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + + // Reselect a worktree — no leftover forward target should remain. + await store.send(.selectWorktree(wt2.id)) { + $0.selection = .worktree(wt2.id) + $0.sidebarSelectedWorktreeIDs = [wt2.id] + $0.openedWorktreeIDs = [wt2.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + + #expect(!store.state.canNavigateWorktreeHistoryForward) + #expect(store.state.canNavigateWorktreeHistoryBackward) + } + private func makeWorktree( id: String, name: String, diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 89f12e1e..4d8570ea 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -801,6 +801,7 @@ struct ShelfFeatureTests { await store.send(.selectArchivedWorktrees) { $0.isShelfActive = false + $0.worktreeHistoryBackStack = [worktree.id] $0.selection = .archivedWorktrees $0.sidebarSelectedWorktreeIDs = [] } -- 2.51.2 From 1b42be9723a59cbd18e6bbaa9e2a55208558c4a7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 00:14:50 +0900 Subject: [PATCH 069/528] Update AppFeatureArchivedSelectionTests for new history push selectArchivedWorktrees now records the previous worktree onto the back stack, so the test must include that mutation in its expected state diff. Fixes CI build after the previous worktree-history fixes. --- supacodeTests/AppFeatureArchivedSelectionTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index eba02fb7..fc55813d 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -42,6 +42,7 @@ struct AppFeatureArchivedSelectionTests { } await store.send(.repositories(.selectArchivedWorktrees)) { + $0.repositories.worktreeHistoryBackStack = [worktree.id] $0.repositories.selection = .archivedWorktrees } await store.receive(\.repositories.delegate.selectedWorktreeChanged) -- 2.51.2 From 4a8d6d6d1d0730cf121dc7033b069c32797a5c2d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 00:35:15 +0900 Subject: [PATCH 070/528] Move SPM cache out of /tmp to survive macOS tmp_cleaner /usr/libexec/tmp_cleaner runs at 00:00 daily and prunes any file in /tmp whose atime, mtime, and ctime are all older than three days. SwiftPM's Package.swift and other top-level metadata in each checkout are read once at resolve time and then ignored by the build, so their atime never refreshes. After three days they get deleted while their parent directory survives, leaving the cache in a broken half-state that fails xcodebuild with "Could not resolve package dependencies". Move SPM_CACHE_DIR to ~/Library/Caches, which is the standard macOS cache location and isn't touched by tmp_cleaner. The CI cache path in setup-macos action moves with it so actions/cache restore stays aligned with the build path. --- .github/actions/setup-macos/action.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index 38c49b13..bd9b10bf 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -51,7 +51,7 @@ runs: - name: SPM cache uses: actions/cache@v4 with: - path: /tmp/supacode-spm-cache/SourcePackages + path: ~/Library/Caches/supacode-spm-cache/SourcePackages key: ${{ runner.os }}-${{ runner.arch }}-spm-v0-${{ hashFiles('**/Package.resolved') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-spm-v0- diff --git a/Makefile b/Makefile index f23eeaf5..10123f2c 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ GHOSTTY_TERMINFO_PATH := $(CURRENT_MAKEFILE_DIR)/Resources/terminfo GHOSTTY_BUILD_OUTPUTS := $(GHOSTTY_XCFRAMEWORK_PATH) $(GHOSTTY_RESOURCE_PATH) $(GHOSTTY_TERMINFO_PATH) GHOSTTY_BUILD_STAMP := $(CURRENT_MAKEFILE_DIR)/.ghostty_build_stamp GHOSTTY_HASH_FILE := $(CURRENT_MAKEFILE_DIR)/.ghostty_hash -SPM_CACHE_DIR := /tmp/supacode-spm-cache/SourcePackages +SPM_CACHE_DIR := $(HOME)/Library/Caches/supacode-spm-cache/SourcePackages VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= -- 2.51.2 From 6cf6e2fd942883763f731d1373dcd6922c93c652 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 11:04:16 +0900 Subject: [PATCH 071/528] Wire window title through detail view and refine Window menu The PR's outer navigationTitle was shadowed by WorktreeDetailView's inner hardcoded title, so the dynamic title never reached NSWindow.title. Move the title onto the detail view itself, drive the hardcoded "Prowl" menu button from the same source, and fall back to the app name when the selected worktree has no active tab so the title doesn't outlive the last closed tab. Reorganize Window menu so tab/pane navigation comes first and the window list (main window + Settings) lands at the bottom. Make Settings visible only while the window is actually open by exposing isOpen on SettingsWindowManager and excluding both windows from the AppKit auto-populated list to avoid duplicate entries. --- supacode/App/ContentView.swift | 6 ---- supacode/App/WindowTabbingDisabler.swift | 3 +- supacode/App/WindowTitle.swift | 6 +++- supacode/App/supacodeApp.swift | 5 ++- supacode/Commands/WindowCommands.swift | 31 +++++++++++++++---- .../Views/WorktreeDetailView.swift | 2 +- .../BusinessLogic/SettingsWindowManager.swift | 31 ++++++++++++++----- supacodeTests/WindowTitleTests.swift | 31 +++++++++++++++++++ 8 files changed, 92 insertions(+), 23 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index f25d26d7..fd87b11f 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -100,12 +100,6 @@ struct ContentView: View { ) } .background(WindowTabbingDisabler()) - .navigationTitle( - WindowTitle.compute( - repositories: store.repositories, - terminalManager: terminalManager - ) - ) } private func toggleLeftSidebar() { diff --git a/supacode/App/WindowTabbingDisabler.swift b/supacode/App/WindowTabbingDisabler.swift index 4ab75d33..358f7308 100644 --- a/supacode/App/WindowTabbingDisabler.swift +++ b/supacode/App/WindowTabbingDisabler.swift @@ -20,7 +20,8 @@ final class WindowTabbingView: NSView, NSWindowDelegate { func disallowTabbing() { guard let window else { return } window.tabbingMode = .disallowed - window.identifier = NSUserInterfaceItemIdentifier("main") + window.identifier = NSUserInterfaceItemIdentifier(WindowID.main) + window.isExcludedFromWindowsMenu = true if window.delegate !== self { window.delegate = self } diff --git a/supacode/App/WindowTitle.swift b/supacode/App/WindowTitle.swift index f891903e..e228d028 100644 --- a/supacode/App/WindowTitle.swift +++ b/supacode/App/WindowTitle.swift @@ -59,9 +59,13 @@ enum WindowTitle { else { return appName } + // No active tab → drop worktree context so the title doesn't outlive the last tab. + guard let tab = selectedTabTitle(in: terminalState(worktreeID)) else { + return appName + } return format( repository: repositoryDisplayTitle(repositoryID: repositoryID, repositories: repositories), - tab: selectedTabTitle(in: terminalState(worktreeID)) + tab: tab ) } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 971cc3b9..9ee203d4 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -598,8 +598,11 @@ struct SupacodeApp: App { SidebarCommands(store: store) TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) WindowCommands( + store: store, + terminalManager: terminalManager, ghosttyShortcuts: ghosttyShortcuts, - resolvedKeybindings: store.resolvedKeybindings + resolvedKeybindings: store.resolvedKeybindings, + settingsWindowManager: SettingsWindowManager.shared ) } CommandGroup(after: .textEditing) { diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index 8b9f766f..067b7a90 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -1,8 +1,13 @@ +import ComposableArchitecture import SwiftUI struct WindowCommands: Commands { + @Bindable var store: StoreOf + let terminalManager: WorktreeTerminalManager let ghosttyShortcuts: GhosttyShortcutManager let resolvedKeybindings: ResolvedKeybindingMap + @Bindable var settingsWindowManager: SettingsWindowManager + @Dependency(SettingsWindowClient.self) private var settingsWindowClient @FocusedValue(\.closeTabAction) private var closeTabAction @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction @@ -34,13 +39,13 @@ struct WindowCommands: Commands { ) } - CommandGroup(replacing: .windowArrangement) { - Button("Prowl") { - NSApp.surfaceMainWindow() - } - .help("Show main window") - Divider() + let mainWindowTitle = WindowTitle.compute( + repositories: store.repositories, + terminalManager: terminalManager + ) + let isSettingsOpen = settingsWindowManager.isOpen + CommandGroup(replacing: .windowArrangement) { Button("Select Previous Tab") { selectPreviousTerminalTabAction?() } @@ -124,6 +129,20 @@ struct WindowCommands: Commands { ) .disabled(selectTerminalPaneRightAction == nil) } + + Divider() + + Button(mainWindowTitle) { + NSApp.surfaceMainWindow() + } + .help("Show main window") + + if isSettingsOpen { + Button("Settings") { + settingsWindowClient.show() + } + .help("Show Settings window") + } } } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 985d6a65..d1a6ca09 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -62,7 +62,7 @@ struct WorktreeDetailView: View { selectedTerminalWorktree: selectedTerminalWorktree, selectedWorktreeSummaries: selectedWorktreeSummaries ) - .navigationTitle(repositories.isShowingCanvas ? "Canvas" : "") + .navigationTitle(WindowTitle.compute(repositories: repositories, terminalManager: terminalManager)) .toolbar(removing: repositories.isShowingCanvas ? nil : .title) .toolbar { if repositories.isShowingCanvas { diff --git a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift index 3043ea7d..2096e55a 100644 --- a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift +++ b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift @@ -3,13 +3,17 @@ import ComposableArchitecture import SwiftUI @MainActor +@Observable final class SettingsWindowManager { - static let shared = SettingsWindowManager() + @ObservationIgnored static let shared = SettingsWindowManager() - private var settingsWindow: NSWindow? - private var store: StoreOf? - private var ghosttyShortcuts: GhosttyShortcutManager? - private var commandKeyObserver: CommandKeyObserver? + private(set) var isOpen: Bool = false + + @ObservationIgnored private var settingsWindow: NSWindow? + @ObservationIgnored private var store: StoreOf? + @ObservationIgnored private var ghosttyShortcuts: GhosttyShortcutManager? + @ObservationIgnored private var commandKeyObserver: CommandKeyObserver? + @ObservationIgnored private var willCloseObserver: NSObjectProtocol? private init() {} @@ -29,6 +33,7 @@ final class SettingsWindowManager { existingWindow.deminiaturize(nil) } existingWindow.makeKeyAndOrderFront(nil) + isOpen = true return } @@ -41,9 +46,9 @@ final class SettingsWindowManager { let hostingController = NSHostingController(rootView: settingsView) let window = NSWindow(contentViewController: hostingController) - window.title = "" + window.title = "Settings" window.titleVisibility = .hidden - window.identifier = NSUserInterfaceItemIdentifier("settings") + window.identifier = NSUserInterfaceItemIdentifier(WindowID.settings) window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] window.tabbingMode = .disallowed window.titlebarAppearsTransparent = true @@ -53,12 +58,24 @@ final class SettingsWindowManager { window.toolbar?.showsBaselineSeparator = false } window.isReleasedWhenClosed = false + window.isExcludedFromWindowsMenu = true window.setContentSize(NSSize(width: 800, height: 600)) window.minSize = NSSize(width: 750, height: 500) + willCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.isOpen = false + } + } + window.center() window.makeKeyAndOrderFront(nil) settingsWindow = window + isOpen = true } } diff --git a/supacodeTests/WindowTitleTests.swift b/supacodeTests/WindowTitleTests.swift index a845ff05..e280c790 100644 --- a/supacodeTests/WindowTitleTests.swift +++ b/supacodeTests/WindowTitleTests.swift @@ -37,6 +37,37 @@ struct WindowTitleTests { #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Archived Worktrees") } + @Test func computeFallsBackToAppNameWhenWorktreeHasNoActiveTab() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let worktree = makeWorktree(rootURL: rootURL) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let terminalState = WorktreeTerminalState(runtime: GhosttyRuntime(), worktree: worktree) + let tabId = terminalState.tabManager.createTab(title: "codex", icon: nil) + terminalState.tabManager.closeTab(tabId) + + #expect( + WindowTitle.compute( + repositories: state, + terminalState: { id in id == worktree.id ? terminalState : nil } + ) == "Prowl" + ) + + #expect( + WindowTitle.compute( + repositories: state, + terminalState: { _ in nil } + ) == "Prowl" + ) + } + @Test func computeUsesCustomRepositoryTitleAndSelectedTabTitle() { let rootURL = URL(fileURLWithPath: "/tmp/repo") let worktree = makeWorktree(rootURL: rootURL) -- 2.51.2 From 6a0af35b94be291a02518234761f51497bd32733 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 11:49:54 +0900 Subject: [PATCH 072/528] Remove dead sidebar focus state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isSidebarFocused` had a single setter in `revealPendingSidebarWorktree` and a `.focused($isSidebarFocused)` binding, but no readers. The Ghostty surface NSView keeps NSWindow first responder regardless of SwiftUI's @FocusState, so the binding never produced observable behavior — verified by typing into the terminal immediately after Reveal in Sidebar (Cmd+Shift+L) without any focus reset. Drop the state, the binding, and the setter. Add a one-line note on the struct explaining that the LazyVStack choice is a deliberate tradeoff for repository drag precision and that keyboard worktree navigation lives on Cmd+Ctrl+↑/↓ via `selectNextWorktree`. --- supacode/Features/Repositories/Views/SidebarListView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index d9beccf0..fa8228cf 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -1,6 +1,8 @@ import ComposableArchitecture import SwiftUI +// Uses LazyVStack rather than List for repository drag precision; keyboard +// worktree navigation goes through Cmd+Ctrl+↑/↓ (`selectNextWorktree`). struct SidebarListView: View { enum RepositoryListHeaderAction: Equatable { case expandAll @@ -33,7 +35,6 @@ struct SidebarListView: View { @Binding var expandedRepoIDs: Set @Binding var sidebarSelections: Set let terminalManager: WorktreeTerminalManager - @FocusState private var isSidebarFocused: Bool @State private var isDragActive = false @State private var draggingRepositoryID: Repository.ID? @State private var targetedRepositoryDropDestination: Int? @@ -122,7 +123,6 @@ struct SidebarListView: View { store.send(.repositoryManagement(.openRepositories(fileURLs))) return true } - .focused($isSidebarFocused) .onAppear { resetSidebarDrag() } @@ -344,7 +344,6 @@ struct SidebarListView: View { // Give SwiftUI time to materialize newly expanded section rows before scrolling. await Task.yield() await Task.yield() - isSidebarFocused = true withAnimation(.easeOut(duration: 0.2)) { scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) } -- 2.51.2 From 5a3d15b8683c71b04ed5a2a97aca1230aabdbd79 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 12:32:46 +0900 Subject: [PATCH 073/528] Hold Cmd-W on terminal layer through Shelf book switch Closing the last tab of a Shelf book briefly leaves every focused close target nil while the reducer advances to the next book. The existing `WindowCloseShortcutPolicy` saw `hasTerminalCloseTarget` flip false in that gap and handed Cmd+W to "Close Window", so an auto-repeating Cmd+W shut the whole window mid-transition. Pass `shelfHasOpenBooks` (Shelf active and at least one book in `openedWorktreeIDs`) into the policy. While that's true, the terminal layer keeps Cmd+W ownership across the transition; once the Shelf empties, Close Window reclaims the shortcut. Adds three policy tests for the new branch (transition gap, empty Shelf, and Cmd+W not bound to terminal). --- supacode/Commands/WindowCommands.swift | 16 ++++++-- .../WindowCloseShortcutPolicyTests.swift | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index 067b7a90..8cc73dd5 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -22,10 +22,13 @@ struct WindowCommands: Commands { var body: some Commands { let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") let closeTabHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_tab") + let shelfHasOpenBooks = + store.repositories.isShelfActive && !store.repositories.openedWorktreeIDs.isEmpty let closeWindowShortcut = WindowCloseShortcutPolicy.closeWindowShortcut( closeSurfaceShortcut: closeSurfaceHotkey, closeTabShortcut: closeTabHotkey, - hasTerminalCloseTarget: closeTabAction != nil || closeSurfaceAction != nil + hasTerminalCloseTarget: closeTabAction != nil || closeSurfaceAction != nil, + shelfHasOpenBooks: shelfHasOpenBooks ) CommandGroup(replacing: .saveItem) { @@ -151,9 +154,16 @@ enum WindowCloseShortcutPolicy { static func closeWindowShortcut( closeSurfaceShortcut: KeyboardShortcut?, closeTabShortcut: KeyboardShortcut?, - hasTerminalCloseTarget: Bool + hasTerminalCloseTarget: Bool, + shelfHasOpenBooks: Bool = false ) -> KeyboardShortcut? { - if hasTerminalCloseTarget && (isCommandW(closeSurfaceShortcut) || isCommandW(closeTabShortcut)) { + // `shelfHasOpenBooks` keeps Cmd+W with the terminal layer through the brief + // gap between closing a book's last tab and Shelf advancing to the next + // book. Without it, `hasTerminalCloseTarget` momentarily flips false during + // that transition, which would let an auto-repeated Cmd+W press fall + // through to the "Close Window" menu shortcut and shut the window. + let terminalOwnsCommandW = hasTerminalCloseTarget || shelfHasOpenBooks + if terminalOwnsCommandW && (isCommandW(closeSurfaceShortcut) || isCommandW(closeTabShortcut)) { return nil } return KeyboardShortcut("w") diff --git a/supacodeTests/WindowCloseShortcutPolicyTests.swift b/supacodeTests/WindowCloseShortcutPolicyTests.swift index e61fcd34..2604dbf1 100644 --- a/supacodeTests/WindowCloseShortcutPolicyTests.swift +++ b/supacodeTests/WindowCloseShortcutPolicyTests.swift @@ -45,4 +45,44 @@ struct WindowCloseShortcutPolicyTests { #expect(shortcut?.key == "w") #expect(shortcut?.modifiers == .command) } + + // Closing the last tab of a Shelf book momentarily clears every focused + // close target while the Shelf advances to the next book. If Close Window + // is allowed to claim Cmd+W in that window, the next auto-repeated press + // shuts the whole window. While Shelf still has at least one open book, + // Cmd+W must stay with the terminal layer. + @Test func closeWindowYieldsCommandWDuringShelfBookSwitchEvenWithoutFocusedTarget() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w"), + closeTabShortcut: nil, + hasTerminalCloseTarget: false, + shelfHasOpenBooks: true + ) + + #expect(shortcut == nil) + } + + @Test func closeWindowKeepsCommandWWhenShelfIsEmptyEvenWithoutFocusedTarget() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w"), + closeTabShortcut: nil, + hasTerminalCloseTarget: false, + shelfHasOpenBooks: false + ) + + #expect(shortcut?.key == "w") + #expect(shortcut?.modifiers == .command) + } + + @Test func closeWindowKeepsCommandWWhenShelfHasBooksButTerminalUsesDifferentShortcut() { + let shortcut = WindowCloseShortcutPolicy.closeWindowShortcut( + closeSurfaceShortcut: KeyboardShortcut("w", modifiers: [.option, .command]), + closeTabShortcut: nil, + hasTerminalCloseTarget: false, + shelfHasOpenBooks: true + ) + + #expect(shortcut?.key == "w") + #expect(shortcut?.modifiers == .command) + } } -- 2.51.2 From 2e8e3cfdab45459b29b91ffe61a807ddfa41c4ed Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 12:51:41 +0900 Subject: [PATCH 074/528] bump v2026.5.9 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 9c06a93d..3da337cf 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260504; + CURRENT_PROJECT_VERSION = 20260509; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.4; + MARKETING_VERSION = 2026.5.9; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260504; + CURRENT_PROJECT_VERSION = 20260509; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.4; + MARKETING_VERSION = 2026.5.9; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260504; + CURRENT_PROJECT_VERSION = 20260509; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.4; + MARKETING_VERSION = 2026.5.9; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260504; + CURRENT_PROJECT_VERSION = 20260509; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.4; + MARKETING_VERSION = 2026.5.9; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index cfca3ff3..b2f74ced 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.4" + public static let current = "2026.5.9" } -- 2.51.2 From 49c77f49f93aa86b92fd408319ca6a689b95c4e6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 12:51:42 +0900 Subject: [PATCH 075/528] Update CHANGELOG for 2026.5.9 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1bda30..205e8a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [2026.5.9](https://github.com/onevcat/Prowl/releases/tag/v2026.5.9) + +This release adds browser-style worktree navigation, inline tab title editing, dynamic window titles, and smarter notification handling, alongside loading-state polish and several notable bug fixes. + +### New + +- Worktree history navigation: use ⌘⌥[ and ⌘⌥] to move back and forward through recent worktree selections. History is paused while Shelf or Canvas is active; both shortcuts are also accessible from the Worktrees menu. +- Terminal tabs now support inline tab titles editing. Double-click a tab in the tab bar to rename it; custom names survive restarts and appear in Canvas, Shelf, and CLI snapshots. +- Unread notification indicators now appear on individual terminal tabs and split surfaces. Press ⌘⌥U or use "Jump to Latest Unread" in the Command Palette to jump to the surface with the newest unread notification. Tapping a system notification also focuses the originating surface. +- Android Studio is now available as a worktree editor action alongside Xcode, VS Code, and other supported editors. + +### Improved + +- The main window title now reflects the current repository, worktree, canvas, archive view, or selected terminal tab. Reopening from the Dock, the CLI, the Window menu, or the quit confirmation flow now consistently targets the real main window instead of accidentally landing on Settings or other panels. +- The Add Repository button has moved from the sidebar footer to a `+` icon next to the Repositories header. The "Repositories" header is now always visible (previously hidden until you had more than 10 repos), and new users with no repositories see a pulsing arrow hint pointing to the new button. +- The worktree loading overlay now surfaces the latest five lines of streaming output inline, replacing the previous nested scroll region. Plain-folder removals now read as folder removals in the loading copy. +- Inactive split pane dimming now reads fill color and opacity from Ghostty's runtime configuration rather than a hardcoded tint, so your Ghostty theme is honored consistently. +- The Ghostty indeterminate progress bar now uses SwiftUI's phase animator, producing a smoother sweep with less state churn. +- The Help (?) menu has moved to the leading edge of the sidebar footer, separating it from the Refresh / Archived / Settings action cluster on the trailing side. Repository, Shelf, Archived, and Diff empty states now share a single `ContentUnavailableView` layout for consistent typography and Dynamic Type behavior. + +### Fixed + +- Holding Cmd+W to close tabs across Shelf book boundaries no longer accidentally closes the window during the brief transition between books. +- Ghostty key equivalents now require the terminal surface to be the active first responder, preventing unintended key capture when another part of the app has focus. +- Shifted menu shortcuts (e.g. Cmd+Shift+?) now match correctly when routing keys to Ghostty, fixing cases where the shifted variant was silently dropped. +- GitHub PR operations (merge, close, ready) now correctly resolve the repository for fork clones, fixing failures caused by same-branch false positives and deleted fork heads. + ## [2026.5.4](https://github.com/onevcat/Prowl/releases/tag/v2026.5.4) This release brings visual polish to split panes and the sidebar, along with a fix for keyboard-driven tab closing. -- 2.51.2 From c710b5daa05fa8de7d5254cde6b6fc3ec4be05ce Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 16:09:47 +0900 Subject: [PATCH 076/528] Hide Cmd+N hint on closed Shelf book spines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+1..9 only routes to the open book's tabs, but every spine was showing the ⌘N glyph (and dimming hotkey-less tabs) when ⌘ was held. Thread `isOpen` into ShelfSpineTabSlot and gate the hint, the dim, and the close-button hide on it so closed books keep their static icons while ⌘ is held. --- .../Features/Shelf/Views/ShelfSpineView.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 4a3c19b0..07f14b51 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -260,6 +260,10 @@ struct ShelfSpineView: View { tab: tab, hotkeyIndex: hotkeyIndex, isActive: terminalState.tabManager.selectedTabId == tab.id, + // Only the open book's tabs respond to Cmd+N — closed books + // show their static icons even while ⌘ is held so the + // shortcut hint isn't a lie. + isOpenBook: isOpen, hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id), activeHighlightTint: effectiveTintColor, activeHighlightAlpha: activeTabHighlightAlpha, @@ -402,6 +406,10 @@ private struct ShelfSpineTabSlot: View { let tab: TerminalTabItem let hotkeyIndex: Int? let isActive: Bool + /// Whether this tab belongs to the currently open book. Cmd+N only + /// targets the open book's tabs, so closed-book spines must not + /// advertise hotkeys they can't service. + let isOpenBook: Bool let hasUnseenNotification: Bool /// Hue used for the active-tab background fill — repo color when /// the owning book has one pinned, otherwise `Color.accentColor`. @@ -433,7 +441,11 @@ private struct ShelfSpineTabSlot: View { } .buttonStyle(.plain) .overlay(alignment: .topTrailing) { - if isHovering && !commandKeyObserver.isPressed { + // Hide the close button only when the ⌘ glyph would actually take + // its place — on closed-book spines the glyph never appears, so + // hovering should still surface the close affordance even with ⌘ + // held. + if isHovering && !(commandKeyObserver.isPressed && isOpenBook) { Button(action: onClose) { Image(systemName: "xmark.circle.fill") .imageScale(.small) @@ -452,12 +464,14 @@ private struct ShelfSpineTabSlot: View { .help(tab.displayTitle) } - /// When ⌘ is held AND this tab has a `Cmd+N` hotkey, swap the icon - /// for a compact `⌘N` glyph in-place. Slot frame stays the same either - /// way so nothing reflows. + /// When ⌘ is held AND this tab has a `Cmd+N` hotkey AND this slot is + /// on the open book's spine, swap the icon for a compact `⌘N` glyph + /// in-place. Closed-book spines opt out entirely: Cmd+N only routes + /// to the open book, so showing the hint there would be misleading. + /// Slot frame stays the same either way so nothing reflows. @ViewBuilder private var slotContent: some View { - let showsHotkey = commandKeyObserver.isPressed && hotkeyIndex != nil + let showsHotkey = commandKeyObserver.isPressed && hotkeyIndex != nil && isOpenBook if let hotkeyIndex, showsHotkey { HStack(spacing: 1) { Image(systemName: "command") @@ -474,10 +488,11 @@ private struct ShelfSpineTabSlot: View { pointSize: ShelfMetrics.tabIconPointSize ) .foregroundStyle(foregroundTint) - // Dim tabs without a hotkey when ⌘ is held, so the "this slot - // can't be jumped to via Cmd+N" affordance is legible without - // shifting any layout. - .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil ? 0.45 : 1) + // Dim tabs without a hotkey when ⌘ is held — but only on the open + // book, where the dimming pairs with the visible ⌘N glyphs on its + // siblings. On closed books no glyph appears, so dimming would be + // a stray visual change with no story behind it. + .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil && isOpenBook ? 0.45 : 1) .accessibilityHidden(true) } } -- 2.51.2 From 7370c5a69dbecdb898beae75de1fc2f593de4db0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 19:01:58 +0900 Subject: [PATCH 077/528] Add Active Agents panel implementation plan Two-phase design for a sidebar panel listing every running agent across worktrees/tabs/panes, ported from herdr's hybrid detection model. Phase 1 ports process detection + screen heuristics into Swift; Phase 2 wires the UI with slide-up animation and pane-level click-to-focus. Includes a Ghostty fork strategy (per-version release/v-patched branches for a small ghostty_surface_pid export), language-neutral signal layering for agent UI localization, and a drift-check skill rather than embedding herdr as a binary. No code changes yet; Phase 0 syscall smoke test still pending. --- .../2026-05-09-active-agents-panel-plan.md | 707 ++++++++++++++++++ 1 file changed, 707 insertions(+) create mode 100644 doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md diff --git a/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md b/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md new file mode 100644 index 00000000..e7b69e18 --- /dev/null +++ b/doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md @@ -0,0 +1,707 @@ +# Active Agents Panel + +## Context + +Prowl 当前对"agent 在不在跑"的检测很弱:状态只有 `idle` / `running` 两态(`supacode/Domain/WorktreeTaskStatus.swift`),粒度是 per-worktree,唯一信号源是 Ghostty 的 progress state(OSC 序列)。这意味着 (a) shell integration 缺失或 agent 不上报 progress 时完全识别不到;(b) 同一 tab 内多个 split pane 各跑一个 agent 时无法区分;(c) 没有 `blocked`(agent 等用户输入)状态;(d) 没有跨 worktree 的 agent 全局视图。 + +目标是新增 **Active Agents** 面板: + +- 位于左侧 sidebar 底部、worktree `LazyVStack` 下方 +- 高度可拖拽,可通过 footer 按钮折叠/展开 +- 折叠时面板从底部消失,展开时**从底部滑入**(带动画) +- 列出**所有** worktree/tab/pane 中正在运行的 agent,状态分四档:working / blocked / done(unread idle) / idle +- 点击跳转到对应 worktree → tab → pane(surface) + +参考实现是 [herdr](https://github.com/ogulcancelik/herdr) (Rust, ratatui)。本计划深度借鉴其 process detection + screen heuristics 混合检测算法,并改造为 Swift / GhosttyKit 适配的形态。 + +实施分两个阶段:**Phase 1 重写 agent 检测(核心,决定整个特性是否靠谱)**,**Phase 2 UI 与接线**。 + +--- + +## 关于 Ghostty fork(需要一个新决定) + +**现状**: + +- `ThirdParty/ghostty` 是 submodule,URL 指向 **upstream** `ghostty-org/ghostty`,目前锁在 tag `v1.3.1` (commit `332b2aef`) +- **没有 onevcat fork**,**没有任何本地 patch**(`change-list.md` 历来只记录 supacode 那边的同步,从未碰过 Ghostty 源码) +- Prowl 用 `make build-ghostty-xcframework` 在本地从 Zig 源码构建产物 `Frameworks/GhosttyKit.xcframework` + +**需要的改动**:暴露 `ghostty_surface_pid()` C 导出(≤ 30 行 Zig 代码)。这是 Phase 1 的硬前置——没 PID 就没 herdr-style process detection。 + +| 方案 | 描述 | 优 | 劣 | +|---|---|---|---| +| **建 `onevcat/ghostty` fork + per-version patched 分支** *(选定)* | fork 仓库;每个上游 tag 创建一条独立的 `release/vX.Y.Z-patched` 分支,把 onevcat 的 patches 应用在该 tag 之上;submodule pin 到对应分支的 HEAD commit | 每个版本可追溯(不重写历史);patches 可在分支间 cherry-pick;与 OpenClaw 派生但更适合 Ghostty 这种节奏稳定的发布 | 每次升级要新建分支 + cherry-pick;分支会随版本累积(接受) | + +**分支命名**:`release/v-patched`,例如 `release/v1.3.1-patched`、`release/v1.3.2-patched`。每条分支的"基底"是对应上游 tag,"附加" commits 都是 onevcat 的 patches(如 `ghostty_surface_pid` 导出)。 + +**新增文档**:`doc-onevcat/fork-sync-ghostty.md`,描述升级到新上游 tag 的流程: + +```bash +# 在 ThirdParty/ghostty 内(首次需 git remote add onevcat git@github.com:onevcat/ghostty.git) +cd ThirdParty/ghostty +git fetch upstream --tags +git fetch onevcat + +PREV=v1.3.1 +NEXT=v1.3.2 + +# 1. 从新 upstream tag 拉一条 patched 分支 +git checkout -b "release/${NEXT}-patched" "${NEXT}" + +# 2. 把上一条 patched 分支相对其基底 tag 多出来的 commits cherry-pick 过来 +# "${PREV}..onevcat/release/${PREV}-patched" 选出 = patches +git cherry-pick "${PREV}..onevcat/release/${PREV}-patched" + +# 3. 推到 fork(首次推新分支,不需要 force) +git push -u onevcat "release/${NEXT}-patched" + +# 4. (回到 Prowl 主仓库)更新 submodule 指针 +cd ../.. +git -C ThirdParty/ghostty checkout "release/${NEXT}-patched" +git add ThirdParty/ghostty +git commit -m "ghostty: bump submodule to ${NEXT}-patched" + +# 5. 重建 GhosttyKit +make build-ghostty-xcframework +``` + +**关于 force push**:per-version 分支模式下,patched 分支**只在新建时推一次**,之后不重写历史;所以不需要 `--force` / `--force-with-lease`。如果 cherry-pick 出错需要修补,先在 `release/${NEXT}-patched-fix` 分支调整,验证 OK 再 fast-forward 到 `release/${NEXT}-patched` 推上去。 + +--- + +## Phase 1 — Detection Layer Rewrite + +### 1.1 设计原则(来自 herdr,验证过靠谱) + +> herdr `INTEGRATIONS.md`: "process detection owns pane identity, liveness, and 'the process is gone'; screen heuristics remain the fallback for state." + +**三层职责切分**: + +- **Process detection** 决定 *身份与存活*("这个 pane 是不是有 agent / 是哪个 agent / 还在不在") +- **Screen heuristics** 决定 *fallback state*(working / blocked / idle) +- **Hook/integration**(**Phase 1 暂不做**):未来可让 Claude/Codex hook 通过 socket 上报权威状态,Phase 1 完全不依赖 + +**四档状态机**: + +``` +检测器内部: AgentRawState = { working, blocked, idle, unknown } +UI 显示: DisplayState = { working, blocked, done, idle } +``` + +- `done` 是派生:`state == idle && seen == false` +- `seen` 在 surface 被聚焦/可见时翻 true,在 `working|blocked → idle` 转移且当时不在前台时翻 false + +### 1.2 暴露 Ghostty surface 的 child PID + +**问题**:当前 GhosttyKit C API 没导出 surface 的 child process PID。Zig 内部 `termio.Exec.cmd.pid` 是有的(`ThirdParty/ghostty/src/termio/Exec.zig:1136`,`ThirdParty/ghostty/src/Surface.zig:140-142` 已有 `child_exited` 标志位证明 surface 持有这条信息)。 + +**改动**(在 `onevcat/ghostty` fork 的 `release/v1.3.1-patched` 分支上做;后续 Ghostty 升级时新建对应版本号的 `release/v-patched` 分支并 cherry-pick): + +- `src/Surface.zig`:新增 `pub fn getChildPid(self: *Surface) ?std.posix.pid_t`,从 surface 持有的 `Termio.Exec` 中读出 `cmd.pid` +- `src/apprt/embedded.zig`:紧贴现有 `ghostty_surface_process_exited` (line 1082 in header) 后面新增 `export fn ghostty_surface_pid(surface: ?*Surface) c_int`,返回 0 表示未知或已退出 +- `include/ghostty.h`(生成):新增声明 +- 通过 `make build-ghostty-xcframework` 重建 xcframework + +### 1.3 macOS process detection helpers + +**新文件**:`supacode/Infrastructure/AgentDetection/ProcessDetection.swift` + +逐条移植 [`herdr/src/platform/macos.rs`](https://github.com/ogulcancelik/herdr/blob/master/src/platform/macos.rs) 算法到 Swift,使用 Darwin C 接口: + +| 功能 | herdr Rust | Swift 实现 | +|---|---|---| +| 取 pty foreground PGID | `proc_pidinfo(pid, PROC_PIDTBSDINFO, …)` 读 `e_tpgid` | 同 syscall (`Darwin.proc_pidinfo`, `proc_bsdinfo`) | +| 列所有 PID | `proc_listallpids` | 同 | +| 过滤 fg group | 比较 `pbi_pgid == fg_pgid` | 同 | +| 取 argv[0] (catch `process.title="pi"`) | `sysctl(KERN_PROCARGS2)` 解析 | 同 (`Darwin.sysctl` with `[CTL_KERN, KERN_PROCARGS2, pid]`) | +| 取短名 fallback | `pbi_comm` | 同 | + +**输出 struct**:`ForegroundJob { processGroupID: pid_t, processes: [ForegroundProcess] }`,`ForegroundProcess { pid, name, argv0?, cmdline? }` + +### 1.4 Agent classifier + +**新文件**:`supacode/Infrastructure/AgentDetection/AgentClassifier.swift` + +跟 herdr 完全对齐,**初版即支持 herdr 列表的全部 11 个**(pi, claude, codex, gemini, cursor, cline, opencode, copilot, kimi, droid, amp): + +```swift +enum DetectedAgent: String, CaseIterable { + case pi, claude, codex, gemini, cursor, cline + case opencode, copilot, kimi, droid, amp +} + +func identifyAgent(processName: String) -> DetectedAgent? +func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: String)? +``` + +`identifyAgentInJob` 复刻 herdr 的 wrapped-runtime 逻辑:如果前台进程名是 `node`/`bun`/`python`/`sh`/`bash`/`zsh`/`fish`/`tmux` 等通用 runtime,扫 `cmdline` 里 token 的 basename 找 agent 名(`node /path/to/codex` → `Codex`);priority scoring 选最佳候选。 + +后续要支持新 agent,只需要 (a) 加 enum case (b) 在 `identifyAgent` 加映射 (c) 写 detector + 测试。 + +### 1.5 Screen heuristics — per-agent detectors + +> 这一节详细解释每个状态怎么从屏幕文本判定。下面分两部分:先讲整体架构与每个状态的判定规则,再走一个具体例子。 + +#### 1.5.1 输入:viewport text + +**怎么拿屏幕内容**:复用现有 C API `ghostty_surface_read_text`(已在 `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift:664` 用过 with selection): + +- 用 `ghostty_point_s { tag: GHOSTTY_POINT_VIEWPORT, coord: TOP_LEFT, x:0, y:0 }` 起,到 viewport 右下角 +- 这给我们当前可见行的纯文本(应该是最后 N 行,N = surface 高度,~30-50 行) +- 这就对应 herdr 的 `terminal.detection_text()` +- 封装成 `GhosttySurfaceBridge.readViewportText() -> String?` + +#### 1.5.2 整体策略:每个 agent 一个独立 detector + +```swift +func detectState(agent: DetectedAgent?, screen: String) -> AgentRawState { + guard let agent = agent else { return .unknown } + switch agent { + case .claude: return detectClaude(screen) + case .codex: return detectCodex(screen) + case .gemini: return detectGemini(screen) + // ... 每个 agent 一个 + } +} +``` + +每个 detector 是一个**纯函数** `(String) -> AgentRawState`,按优先级查 blocked → working → 默认 idle。**完全可单测,不需要 Ghostty / pty / async**。 + +#### 1.5.3 具体规则(直接来自 herdr,已被它的测试集验证) + +每条规则都是"屏幕里出现某段特定 UI 文字"。这些字符串是 agent CLI 自己渲染的("esc to interrupt"、"❯ Yes"、"approve?" 等),所以非常稳定。一旦 agent 升级 UI 文案,detector 就要更新——**这是已知的维护成本,跟 herdr 一样接受**。 + +##### Claude Code(最复杂的一个) + +Claude 的 UI 是个结构化 prompt box: + +``` + (agent 输出 / 工具结果) + ───────────────────── ← 上边框 + ❯ _ ← 输入行 + ───────────────────── ← 下边框 +``` + +判定优先级: + +1. **Blocked**: 内容里出现 `"do you want"` 或 `"would you like"`,且后面跟 `"yes"` 或 `❯`;或显式的 `"do you want to proceed?"` / `"waiting for permission"` / `"do you want to allow this connection?"`;或 (selection prompt + yes/no choice) 组合 +2. **Working**: 把 prompt box **上面**的内容单独抽出来(用边框定位,避免误把上次的 `esc to interrupt` 当成本次状态),如果上面那段含 `"esc to interrupt"` / `"ctrl+c to interrupt"`,或行首是 spinner glyph (`✱✲✳✴…` 或 `·` 中点) 后跟 `"…"` (U+2026) +3. **否则 idle** + +具体例子(直接是 herdr 测试 fixture): + +``` +✽ Tempering… +───────── +❯ +───────── +``` + +→ 行首 `✽` 是 spinner,后接 `…`,认定 **working** + +``` +Do you want to proceed? +❯ 1. Yes + 2. No + +Esc to cancel · Tab to amend +``` + +→ 含 `"do you want to proceed?"` + `❯` 跟数字选项,认定 **blocked** + +``` +Task complete. +───────────── +❯ +───────────── +``` + +→ 上面段无 spinner、无 interrupt 文字,认定 **idle** + +##### Codex + +判定(更平铺直叙,没结构化 box): + +1. **Blocked**: `"press enter to confirm or esc to cancel"` / `"enter to submit answer"` / `"allow command?"` / `"[y/n]"` / `"yes (y)"` 之一 +2. **Working**: `"esc to interrupt"` / `"ctrl+c to interrupt"`,或行首 `•` 后跟 `"Working ("`(codex 自己的状态行 header) +3. **否则 idle** + +##### 其他 agent + +- **Gemini**: blocked = `"waiting for user confirmation"` 或 box 字符 `│` 起头 + `"Apply this change"` / `"Allow execution"` / `"Do you want to proceed"`;working = `"esc to cancel"` +- **Cursor**: blocked = `"(y) (enter)"` / `"keep (n)"` / 含 `"(y)"` + (`"allow"` 或 `"run"`);working = `"ctrl+c to stop"` 或行首 `⬡⬢` + 含 `"ing"` 字(cursor 的 spinner) +- **Cline**: blocked = `"let cline use this tool"` 或 `[act mode]/[plan mode]` + `"yes"`;idle = `"cline is ready for your message"`;**注意 cline 默认是 working**(不像别的默认 idle),因为 cline 长时间执行不显式上报 +- **OpenCode**: blocked = `"△ Permission required"` 或问题菜单 (`↑↓ select` + `Enter confirm/submit/toggle` + `Esc dismiss`);working = `"esc to interrupt"` +- **Copilot (`ghcs`)**: blocked = `"│ do you want"` 或 `"confirm with ... enter"`;working = `"esc to cancel"` +- **Kimi**: blocked = `"allow?"` / `"confirm?"` / `"approve?"` / `"proceed?"` / `"[y/n]"` / `"(y/n)"`;working = `"thinking"` / `"processing"` / `"generating"` / `"waiting for response"` / `"ctrl+c to cancel"` +- **Droid**: blocked = `"EXECUTE"` 关键字 + 选择 chrome (`"enter to select"` / `"↑↓ to navigate"`);working = 行首 braille spinner (U+2800-28FF) + `"esc to stop"` +- **Amp**: blocked = `"approve"` 选项 + `"allow all for this session"` 等组合 + (`"waiting for approval"` 或 `"invoke tool"` / `"run this command?"` 等 header);working = `"esc to cancel"` +- **Pi**: working = `"Working..."`;其他 idle(最简单) + +#### 1.5.4 单 tick 完整流程(带具体例子) + +假设开了一个 pane,跑了 `claude`,让它读个文件。看一次 detection tick 干了什么: + +``` +t=0: [process probe] proc_pidinfo(panePid).e_tpgid → fgPgid=12345 + proc_listallpids 过滤出 pgid=12345 → [{pid:12345,name:"node",cmdline:"node /usr/local/bin/claude"}] + identifyAgentInJob → DetectedAgent.claude + AgentDetectionPresence: current=Claude (新识别) + [screen heuristic] viewport text: + "Reading file src/main.rs + ✽ Pondering… (esc to interrupt) + ───────── + ❯ + ─────────" + detectClaude: + - 不含 do_you_want → 不 blocked + - content_above_prompt_box() 切到 "Reading file..." + "✽ Pondering..." + - 含 "esc to interrupt" → working + stabilize_agent_state(Claude, prev=unknown, raw=working) → working + 发出 .agentStateChanged(surfaceID, agent: claude, state: working) + +t=300ms: 同样流程,仍 working + +t=2.4s: Claude 完成读取,UI 变成 + "Read 1245 lines + ───────── + ❯ + ─────────" + detectClaude: 上面段无 spinner、无 interrupt → raw=idle + stabilize: previous=working, raw=idle, 距离上次 last_claude_working_at < 1.2s → 仍返回 working (粘滞窗口防抖) + +t=3.5s: 同样 idle,但已超过 1.2s 粘滞窗口 → idle + 发出 .agentStateChanged(state: idle) + + 此时如果 surface 不在前台 → seen=false → UI 显示 "done" + 否则 seen=true → UI 显示 "idle" + +(用户跟 Claude 说 "rm -rf /tmp/test") +t=10s: viewport: + "Allow bash: rm -rf /tmp/test? + + Do you want to proceed? + + ❯ 1. Yes + 2. No + + esc to cancel" + detectClaude: + - has_claude_blocked_prompt 命中 "do you want to proceed?" → blocked + 发出 .agentStateChanged(state: blocked) + seen 立即翻 true (blocked 不算"完成",要醒目) + +t=15s: 用户点 1 (yes),Claude 又开始干活 + viewport 重新出现 "esc to interrupt" → working +``` + +**轮询频率**: + +- agent 已识别:300ms tick +- agent 未识别:500ms tick +- "pending release" 期:50ms tick(agent 刚退出后短暂窗口,避免漏掉重新启动) +- Process probe 节流:5s 一次(除非满足"立即检查"条件:当前无 agent / fg PGID 变了 / 有 pending release) + +**Agent 退出**:连续 6 次 process probe miss 才清掉 detected agent(约 1.8s @ 300ms tick),防止瞬时误读。 + +#### 1.5.5 多语言策略 + +**问题**:Screen heuristics 依赖匹配 agent CLI 渲染的 UI 文字。如果 agent 把 UI 本地化(中文 / 德文 / 日文 / ...),detector 会失效。 + +**现状盘点**(onevcat 实测过的 11 个 agent): + +| Agent | UI 是否本地化 | 风险等级 | +|---|---|---| +| Claude Code | 否,UI string 在 cli.js 里硬编码英文 | 极低 | +| Codex | 否,硬编码英文 | 极低 | +| Gemini CLI | 否 | 极低 | +| Cursor CLI | 否 | 极低 | +| Cline | 否(VS Code 扩展为主) | 极低 | +| OpenCode | 否 | 极低 | +| GitHub Copilot CLI | 否 | 极低 | +| **Kimi** | **可能是**——Moonshot 的中文优先 agent,部分 footer / prompt 可能是中文 | **真实风险** | +| Droid | 否 | 极低 | +| Amp | 否 | 极低 | +| Pi | 否 | 极低 | + +模型对话内容当然是多语言的,但 detector 看的是 **agent CLI 的 UI chrome**("esc to interrupt"、"Do you want to proceed?"、"[y/n]"),这些 99% 是英文常量。 + +**信号天然分两类**: + +- **A. Language-neutral signals**(无视语言,最稳): + - Spinner glyph:`✱✲✳✴✵`(Claude)、`⬡⬢`(Cursor)、`⠋⠙⠹⠸`(Droid braille)、`✽` 等 + - Box drawing chars:`─ │ ❯ ⌕`(Claude prompt box / Gemini `│ Apply` 等) + - Control keys:`esc`, `ctrl+c`, `enter`, `tab` —— 即便本地化也保持英文(标准 CLI 惯例) + - Symbols:`[y/n]`, `(y/n)`, `→ ↑ ↓`, `?`, ellipsis `…` + - 数字选项:`1.` `2.` `3.` +- **B. English-text signals**(最常见但易被本地化击穿):`"esc to interrupt"`、`"do you want to proceed"`、`"approve?"`、`"thinking"`、`"waiting for"` 等多词短语 + +**Phase 1 实装策略——分层防御**: + +1. **每个 detector 内部,A 类信号优先级抬高** + - working / blocked 判定用 `(A) OR (B)`,A 命中即不再看 B + - 例:Claude working = `行首 spinner glyph`(A)OR `"esc to interrupt"`(B) + - 例:blocked = `(selection prompt + ❯/数字选项)`(A 组合)OR `"do you want to proceed?"`(B) + - 这种"或"组合本来就在 herdr 里大量出现,A 类能命中的场景保留语言无关性 + +2. **Detector 注释里标注每条规则的类别** + + ```swift + // language-neutral: spinner glyph at line start + if hasSpinnerActivity(above) { return .working } + // english-only: tool footer hint + if aboveLower.contains("esc to interrupt") { return .working } + ``` + + 将来某 agent 突然本地化时,能一眼定位哪条规则要扩。 + +3. **Kimi 单独留一个 multi-pattern 接口** + + ```swift + func detectKimi(_ content: String) -> AgentRawState { + let blockedPatterns: [String] = [ + "allow?", "confirm?", "approve?", "proceed?", + "[y/n]", "(y/n)", + // 中文待 onevcat 跑实例后补充: "允许?", "确认?", ... + ] + // ... + } + ``` + + 等 onevcat 实际跑 Kimi 抓到 viewport sample 再补中文 pattern。其他 agent 维持纯英文。 + +**Phase 1 不做但 Phase 3 应该做**: + +- **Hook integration(herdr 也走这条路)**:Claude / Codex / OpenCode 都暴露了 hook,可让它们直接通过 socket 上报 `working/blocked/idle` **语义状态**——完全无视 UI 文字。这是治本方案,但 Phase 1 范围外。 + +### 1.6 Per-pane state machine + stabilization + +**新文件**:`supacode/Domain/AgentDetection/PaneAgentState.swift` + +复刻 herdr [`pane/state.rs`](https://github.com/ogulcancelik/herdr/blob/master/src/pane/state.rs) 的 `PaneState`: + +```swift +struct PaneAgentState { + var detectedAgent: DetectedAgent? + var fallbackState: AgentRawState + var state: AgentRawState // = fallback (Phase 1 没 hook authority) + var seen: Bool = true + var lastChangedAt: Date // 用于 UI 排序 +} + +// 抖动控制 +struct AgentDetectionPresence { + var currentAgent: DetectedAgent? + var consecutiveMisses: UInt8 // 6 次连续 miss 才清 +} + +func stabilizeAgentState( + agent: DetectedAgent?, + previous: AgentRawState, + raw: AgentRawState, + now: Date, + lastClaudeWorkingAt: inout Date? +) -> AgentRawState +``` + +`stabilizeAgentState` 关键逻辑(仅对 Claude):working → idle 转移有 **1.2s 粘滞窗口** (`CLAUDE_WORKING_HOLD`),防止 tool result 渲染瞬间被误读为 idle。其他 agent 直接透传 raw。 + +### 1.7 Process syscall smoke test(**Phase 1 第一个动作**) + +**前置事实**:Prowl **没开 App Sandbox**(`ENABLE_APP_SANDBOX = NO` in `supacode.xcodeproj/project.pbxproj`,`supacode.entitlements` 也无 `com.apple.security.app-sandbox`)。Hardened runtime + notarization 不限制 `proc_listallpids` / `proc_pidinfo` / `sysctl(KERN_PROCARGS2)` 这类只读 syscall。 + +所以这一步**不是验证 sandbox**,只是常规 smoke test 确认调用方式正确、数据格式符合预期: + +1. 在 Debug 构建里写一个 ~50 行的小测试:spawn 一个 shell,跑 claude,调上面三个 syscall 抓一帧数据 dump 出来比对预期。直接放在 `supacodeTests/Spikes/ProcessDetectionSpikeTests.swift` 跑一次扔掉 +2. 验证 `proc_pidinfo` 返回的 `e_tpgid` 跟独立 `ps` 命令的结果一致 +3. 验证 `KERN_PROCARGS2` 解析能正确拿到 `argv[0]`(比如 node spawn 的 claude 应该能看到 `claude` 而不只是 `node`) + +通过即开始正式实装;任何 syscall 报错(不太可能)才需要重新评估。 + +### 1.8 Wiring into existing model + +**修改文件**: + +- `supacode/Domain/WorktreeTaskStatus.swift` — 不动现有 enum;引入并行的新模型 `AgentRawState` 在 `AgentDetection/` 下 +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — 新增 `surfaceAgentStates: [GhosttySurfaceID: PaneAgentState]`,在 surface 创建/关闭时启停 detection task +- `supacode/Clients/Terminal/TerminalClient.swift` — `Event` 新增 `.agentStateChanged(worktreeID:surfaceID:state:agent:)` 和 `.agentSeenChanged(...)` +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — 转发新事件 +- 复用现有 `onTaskStatusChanged` 的派发模式(`WorktreeTerminalManager.swift:248`) + +**新建顶层 registry**:`supacode/Features/ActiveAgents/Models/ActiveAgentsRegistry.swift`,`@MainActor @Observable class`,订阅所有 worktree 的 agent state 事件,维护跨 worktree 的扁平 `[ActiveAgentEntry]`,给 reducer 读。 + +### 1.9 测试策略 + +- **Unit test 全覆盖**:`ScreenHeuristics` + `AgentClassifier` + `PaneAgentState.stabilize` — 全是纯函数 / 纯数据,**直接移植 herdr `detect.rs` 测试段(约 700 行)的所有 fixture**。每个 detector 都有 working/blocked/idle 样本 +- **Integration test**:`PaneAgentState` 的 detection loop 跑在 `TestClock` 上,喂假的 viewport text + 假的 ForegroundJob,断言状态转移 +- **Manual smoke**:跑 claude/codex 各开一个 pane,肉眼验证 working ↔ blocked ↔ idle 切换 +- **不写自动化 e2e**——对 Ghostty 真 pty 跑端到端的成本太高,靠手工冒烟覆盖 + +--- + +## Phase 2 — UI & Wiring + +### 2.1 Layout(支持从底部滑入动画) + +**核心问题**:希望 footer 按钮一点,面板**从底部滑出**带动画。SplitView 在 hidden/visible 之间切换会重建视图层级,动画会跳。 + +**方案**:始终用 VStack;面板用条件 `if !isHidden` 渲染并配 `.transition(.move(edge: .bottom))`;resize handle 是 panel 自带的顶边 drag bar,不依赖 SplitView。 + +```swift +// SidebarListView 改造后 +ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // 上半:worktree 列表,吃掉剩余高度 + ScrollView { LazyVStack { repositoryItems… } } + .scrollIndicators(.never) + .frame(maxHeight: .infinity) + + // 下半:Active Agents 面板(含顶部 resize handle) + if !isPanelHidden { + ActiveAgentsPanel(store: …) + .frame(height: panelHeight) // 用户拖拽时变化 + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } +} +.animation(.spring(response: 0.4, dampingFraction: 0.85), value: isPanelHidden) +.safeAreaInset(.bottom) { SidebarFooterView(...) } +.clipped() // 防止 transition 期间溢出 sidebar 边界 +``` + +`ActiveAgentsPanel` 内部: + +```swift +VStack(spacing: 0) { + // 顶边 drag handle + Rectangle() + .fill(.separator) + .frame(height: 1) + .overlay(Color.clear.frame(height: 6)) // 点击/拖拽热区 + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { v in + panelHeight = clamp(panelHeight - v.translation.height, 120, maxAllowed) + } + ) + .onHover { hovering in NSCursor.resizeUpDown.set() } // 视觉反馈 + + // 标题栏 + 列表 + Text("Active Agents") + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 12).padding(.top, 8) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(entries) { entry in + ActiveAgentRow(entry: entry).onTapGesture { … } + } + } + } +} +``` + +**与 SplitView 的对比**: + +- SplitView 现有组件依赖两侧都存在 + 拖动 divider,无法很好处理"右侧/下侧消失"的动画过渡 +- 用 transition + 自带 drag handle 更适合这种 "show/hide with slide-up" 场景 +- 缺点:失去 SplitView 的"双击均分"快捷功能;不重要 + +**持久化**: + +- `@Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false` +- `@Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200` + +### 2.2 TCA feature + +**新文件**:`supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift` + +```swift +@Reducer struct ActiveAgentsFeature { + @ObservableState struct State: Equatable { + var entries: IdentifiedArrayOf = [] + @Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false + @Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200 + } + enum Action { + case task // 启动时订阅 registry + case agentEntriesUpdated([ActiveAgentEntry]) + case entryTapped(ActiveAgentEntry.ID) + case togglePanelVisibility + case panelHeightChanged(Double) + } +} +``` + +挂载位置:作为 `RepositoriesFeature` 的子 reducer(`var activeAgents: ActiveAgentsFeature.State` + `Scope { state: \.activeAgents, action: \.activeAgents }`)。Sidebar 范畴内,不需要爬到 AppFeature。 + +### 2.3 Active Agents row UI + +**新文件**:`supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift` + +布局: + +``` +[icon] agent name [status pill] + worktree · tab · pane +``` + +- icon:复用 `CommandIconMap` 的 `TabIconSource`(`supacode/Features/Terminal/Models/CommandIconMap.swift`) +- agent name:`.body.monospaced()` +- 副标题:`.caption.foregroundStyle(.secondary)`,格式 `worktree-name · tab-N · pane-N` +- status pill:颜色严格走 system color(CLAUDE.md 强制) + - blocked → `.red` + - working → 旋转中的 spinner + `.orange`/`.yellow` + - done → `.blue`(亮,提示未读) + - idle → `.secondary`(灰) + +排序(在 reducer 里算):blocked → working → done → idle,组内按 `lastChangedAt` 倒序。 + +空态:`Text("No active agents").font(.caption).foregroundStyle(.secondary)` 居中。 + +### 2.4 Footer hide toggle + +**修改**:`supacode/Features/Repositories/Views/SidebarFooterView.swift` + +在现有 `HStack` 里(archive / refresh / settings 旁边)加一个按钮: + +```swift +Button { + store.send(.activeAgents(.togglePanelVisibility)) +} label: { + Image(systemName: isHidden ? "rectangle.bottomthird.inset" : "rectangle.bottomthird.inset.filled") +} +.help(isHidden ? "Show Active Agents" : "Hide Active Agents") +``` + +(CLAUDE.md "Buttons must have tooltips") + +### 2.5 Click-to-focus + +新增 TerminalClient 命令 `.focusSurface(worktreeID:tabID:surfaceID:)`: + +1. `setSelectedWorktreeID(worktreeID)` — 切换 worktree(已有) +2. 切到对应 `tabID`(`TerminalTabManager` 里有 `selectedTabID`,扩展为带 surface 参数) +3. 在 split tree 里把焦点设到那个 surface(调用 Ghostty focus + `selectedSurfaceID` 更新) + +reducer 流:`entryTapped(id)` → 找 entry 的 `(worktreeID, tabID, surfaceID)` → `terminalClient.send(.focusSurface(...))` → reducer 同时 `repositories.select(worktreeID)`。 + +副作用:聚焦后 registry 监听 focus 事件、把对应 entry 的 `seen` 翻 true,UI 上 `done` 立刻降级成 `idle`。 + +### 2.6 UX 收尾 + +- min panel height: 120pt;max: container height − 200pt(保证 worktree list 可见) +- 拖动时 throttle 持久化(避免每帧写 UserDefaults) +- 动画 spring 参数:`response: 0.4, dampingFraction: 0.85`(手感舒服,不弹) +- Dynamic Type 友好:所有文字走 `.font(.caption)` / `.body` 等系统 style +- `.scrollIndicators(.never)` 与 worktree list 一致 + +--- + +## File Map + +### 新增 + +**Ghostty fork patches** (在 `onevcat/ghostty` 的 `release/v1.3.1-patched` 分支) + +- `src/Surface.zig` — `getChildPid()` +- `src/apprt/embedded.zig` — `ghostty_surface_pid` C export + +**Prowl 主仓库** + +- `supacode/Infrastructure/AgentDetection/ProcessDetection.swift` +- `supacode/Infrastructure/AgentDetection/AgentClassifier.swift` +- `supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift` (可拆 `Detectors/{Claude,Codex,Gemini,Cursor,Cline,OpenCode,Copilot,Kimi,Droid,Amp,Pi}Detector.swift`) +- `supacode/Domain/AgentDetection/AgentRawState.swift` +- `supacode/Domain/AgentDetection/DetectedAgent.swift` +- `supacode/Domain/AgentDetection/PaneAgentState.swift` +- `supacode/Features/ActiveAgents/Models/ActiveAgentsRegistry.swift` +- `supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift` +- `supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift` +- `supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift` +- `supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift` +- `supacodeTests/AgentDetection/...` — 多个测试文件,移植 herdr `detect.rs` 测试 fixture + +**文档** + +- `doc-onevcat/active-agents-panel.md` — 本计划 +- `doc-onevcat/change-list.md` — 增加 "Ghostty fork patches" 段 +- `doc-onevcat/fork-sync-and-release.md` — 增加 Ghostty fork rebase 子节 + +### 修改 + +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` — per-surface agent state,spawn detection task +- `supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift` — `readViewportText()` / `childPID` +- `supacode/Clients/Terminal/TerminalClient.swift` — 新事件 + `focusSurface` 命令 +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` — 转发事件 + 实现 focusSurface +- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` — 嵌入 `ActiveAgentsFeature` +- `supacode/Features/Repositories/Views/SidebarListView.swift` — 嵌入 ZStack + ActiveAgentsPanel + transition +- `supacode/Features/Repositories/Views/SidebarFooterView.swift` — 加 hide toggle +- `supacode/Features/Terminal/BusinessLogic/TerminalTabManager.swift` — 扩展 focus 到 surface 级 +- `Frameworks/GhosttyKit.xcframework/.../ghostty.h` — 头文件同步(`make build-ghostty-xcframework` 自动) +- `.gitmodules` — `ThirdParty/ghostty` URL 改指 `onevcat/ghostty` + +--- + +## Verification + +**Phase 0(spike,最先做)**: + +1. 写小 smoke test 验证 `proc_pidinfo` / `proc_listallpids` / `sysctl(KERN_PROCARGS2)` 调用方式正确(详见 §1.7;Prowl 不在 sandbox 下,不预期阻碍) +2. 临时给 GhosttyKit 加 `ghostty_surface_pid`(在 submodule 里直接改,不进 PR),跑通 → 验证 fork 方案可行 +3. 决定方向后 setup `onevcat/ghostty` fork 正式落地(`release/v1.3.1-patched` 分支) + +**Phase 1 验证(不依赖 UI)**: + +1. `make test` — 重点跑 `AgentDetectionTests` / `ScreenHeuristicsTests`,所有从 herdr 移植的 fixture 必须通过 +2. `make run-app` Debug 构建,开 1 个 pane 跑 `claude`、1 个跑 `codex`、1 个跑 `bash`: + - `make log-stream` 观察 `agentStateChanged` 事件序列:claude 启动 → working → blocked (问 yes/no) → working → idle 全链路 + - 关掉 agent 后状态在 6 次 miss 内(约 1.8s @ 300ms tick)回到 unknown +3. Split 一个 pane 在同一 tab 里再跑一个 agent,确认 per-surface 粒度成立 + +**Phase 2 验证(UI)**: + +1. `make build-app` + `make run-app` — sidebar 底部出现 Active Agents 面板 +2. 点击 footer toggle:面板**从底部滑入/滑出**,动画顺畅;状态在重启 app 后保持 +3. 拖动 panel 顶边 drag handle:高度变化,重启后保持 +4. 列表实时反映 agent 状态: + - 启动 claude → 出现一条 entry,状态 working + - claude 问 `Do you want to proceed?` → entry 切到 blocked,红色徽章 + - 在另一个 worktree 等 claude 完成 → entry 切到 done,蓝色(未读) + - 点击 → sidebar 选中切到那个 worktree,tab 切对,pane 聚焦,done 立刻降为 idle +5. 关掉所有 agent,列表显示空态 +6. `make check` 通过;`make test` 全绿 + +**Manual smoke**: + +- 同一 tab 双 split:claude 在左、codex 在右,两条 entry 同时存在且独立 +- agent 通过 `node /path/to/codex` 间接启动也能识别(cmdline 扫描) +- 切换 worktree 时 `seen` 标记正确翻转 +- 长时间运行 (>10min) 不爆 CPU:detection tick 应非常便宜 + +--- + +## 风险与开放项 + +1. **Process syscall 调用形态**(低)— Prowl 无 sandbox,syscall 应直接可用;Phase 0 smoke test 验证一次即可,不预期阻碍。见 §1.7 +2. **Ghostty fork 维护成本** — 见顶部"关于 Ghostty fork"段。每次上游版本升级要新建 `release/v-patched` 分支并 cherry-pick patches,可接受 +3. **Agent 列表的扩展性** — 新 agent 需要同时改 enum + classifier + detector + 测试。可接受,与 herdr 同 +4. **Hook integration(未来)** — 本计划完全不做。Phase 1+2 落地稳定后,再考虑给 claude/codex/opencode 装 hook 上报权威状态(herdr 的 socket API 模型可以照搬) +5. **ScreenHeuristics 维护策略——为什么不嵌 herdr 二进制**: + + 考虑过把 herdr 的 Rust `detect.rs` 编成 dylib 直接链接。**最终选择走 Swift 移植路线**,理由: + + - **detect.rs 95% 是 `.contains("...")` 调用**,没有 Rust-only 算法精华,移植 1-2 天搞定 + - 嵌 Rust 二进制要加 cross-compile pipeline、universal dylib 签名、hardened runtime + 第三方 dylib 的 library validation 豁免、FFI marshaling(每 tick 跨 boundary 传 viewport text) + - herdr `detect.rs` 不是干净 leaf 模块——`use crate::platform::ForegroundJob` 跟其他模块耦合,要么编整个 crate 要么 fork 出 sub-crate + - 维护成本不会因为嵌入而消失:agent CLI 升级时 herdr 自己也得跟,我们等 herdr release 反而**延迟更长** + - 二次定制(Kimi 中文 pattern)会变成"改 Rust + 重编 + 重签 + 重 ship"——比改 Swift 痛苦 N 倍 + + **代替方案:drift-check skill**(一次性投入半天): + + - 新建 `~/.claude/skills/herdr-detect-sync/SKILL.md`,每月 onevcat 主动跑一次(或加到 cron) + - 拉最新 `https://raw.githubusercontent.com/ogulcancelik/herdr/master/src/detect.rs` + - 跟 `supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift` 做语义 diff:提取每个 agent detector 的 pattern 字符串列表,对比新增 / 删除 + - 输出"herdr 新加了哪些 pattern" + "我们有但 herdr 删了哪些"的报告,onevcat review 后手动 cherry-pick 进 Swift + - 这样我们在跟进 agent CLI 变化上**不慢于 herdr**(甚至能更快——不用等他们 release) +6. **panelHeight 在 sidebar 整体高度变化时的 clamp** — 窗口缩小到 worktree list 没空间时要自动让出。需要在 layout 里加 `GeometryReader` 或在 onChange 里 clamp。(CLAUDE.md "Avoid GeometryReader when containerRelativeFrame() ... would work"——优先尝试 containerRelativeFrame) -- 2.51.2 From 40349888423e489fc4067812b454c116a49356a6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 19:19:11 +0900 Subject: [PATCH 078/528] Add agent detection core logic --- doc-onevcat/active-agents-panel-task-log.md | 19 ++ .../Domain/AgentDetection/AgentRawState.swift | 15 + .../Domain/AgentDetection/DetectedAgent.swift | 55 ++++ .../AgentDetection/PaneAgentState.swift | 97 +++++++ .../AgentDetection/AgentClassifier.swift | 82 ++++++ .../AgentDetection/ProcessDetection.swift | 142 ++++++++++ .../AgentDetection/ScreenHeuristics.swift | 268 ++++++++++++++++++ supacodeTests/AgentClassifierTests.swift | 62 ++++ supacodeTests/PaneAgentStateTests.swift | 83 ++++++ supacodeTests/ScreenHeuristicsTests.swift | 92 ++++++ 10 files changed, 915 insertions(+) create mode 100644 doc-onevcat/active-agents-panel-task-log.md create mode 100644 supacode/Domain/AgentDetection/AgentRawState.swift create mode 100644 supacode/Domain/AgentDetection/DetectedAgent.swift create mode 100644 supacode/Domain/AgentDetection/PaneAgentState.swift create mode 100644 supacode/Infrastructure/AgentDetection/AgentClassifier.swift create mode 100644 supacode/Infrastructure/AgentDetection/ProcessDetection.swift create mode 100644 supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift create mode 100644 supacodeTests/AgentClassifierTests.swift create mode 100644 supacodeTests/PaneAgentStateTests.swift create mode 100644 supacodeTests/ScreenHeuristicsTests.swift diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md new file mode 100644 index 00000000..4053a76d --- /dev/null +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -0,0 +1,19 @@ +# Active Agents Panel Task Log + +## 2026-05-09 + +### Scope + +- Implement Phase 0, Phase 1, and Phase 2 from `doc-onevcat/plans/2026-05-09-active-agents-panel-plan.md`. +- Keep commits small enough to audit. +- Maintain high test coverage for pure detection logic and state transitions. + +### Progress + +- Started from branch `feat/active-agents-panel`. +- Initial worktree was clean; only existing branch commit was the implementation plan. +- Confirmed Xcode uses file-system synchronized root groups, so new Swift source/test files under `supacode/` and `supacodeTests/` are picked up automatically. + +### Decisions And Notes + +- Pure detection logic is implemented first with tests before wiring, because it is the most important stable contract for later UI iteration. diff --git a/supacode/Domain/AgentDetection/AgentRawState.swift b/supacode/Domain/AgentDetection/AgentRawState.swift new file mode 100644 index 00000000..101bbfda --- /dev/null +++ b/supacode/Domain/AgentDetection/AgentRawState.swift @@ -0,0 +1,15 @@ +import Foundation + +enum AgentRawState: String, Equatable, Sendable { + case working + case blocked + case idle + case unknown +} + +enum AgentDisplayState: String, Equatable, Sendable { + case working + case blocked + case done + case idle +} diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift new file mode 100644 index 00000000..6a29eccc --- /dev/null +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -0,0 +1,55 @@ +import Foundation + +enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { + case pi + case claude + case codex + case gemini + case cursor + case cline + case opencode + case copilot + case kimi + case droid + case amp + + var id: String { rawValue } + + var displayName: String { + switch self { + case .pi: + return "Pi" + case .claude: + return "Claude" + case .codex: + return "Codex" + case .gemini: + return "Gemini" + case .cursor: + return "Cursor" + case .cline: + return "Cline" + case .opencode: + return "OpenCode" + case .copilot: + return "Copilot" + case .kimi: + return "Kimi" + case .droid: + return "Droid" + case .amp: + return "Amp" + } + } + + var iconLookupToken: String { + switch self { + case .claude: + return "claude" + case .copilot: + return "copilot" + default: + return rawValue + } + } +} diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift new file mode 100644 index 00000000..2e199da9 --- /dev/null +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -0,0 +1,97 @@ +import Foundation + +struct PaneAgentState: Equatable, Sendable { + var detectedAgent: DetectedAgent? + var fallbackState: AgentRawState + var state: AgentRawState + var seen: Bool + var lastChangedAt: Date + + init( + detectedAgent: DetectedAgent? = nil, + fallbackState: AgentRawState = .unknown, + state: AgentRawState = .unknown, + seen: Bool = true, + lastChangedAt: Date = Date() + ) { + self.detectedAgent = detectedAgent + self.fallbackState = fallbackState + self.state = state + self.seen = seen + self.lastChangedAt = lastChangedAt + } + + var displayState: AgentDisplayState { + switch state { + case .working: + return .working + case .blocked: + return .blocked + case .idle: + return seen ? .idle : .done + case .unknown: + return .idle + } + } +} + +struct AgentDetectionPresence: Equatable, Sendable { + static let releaseMissThreshold = 6 + + var currentAgent: DetectedAgent? + var consecutiveMisses: UInt8 + + init(currentAgent: DetectedAgent? = nil, consecutiveMisses: UInt8 = 0) { + self.currentAgent = currentAgent + self.consecutiveMisses = consecutiveMisses + } + + mutating func update(detectedAgent: DetectedAgent?) -> DetectedAgent? { + if let detectedAgent { + currentAgent = detectedAgent + consecutiveMisses = 0 + return detectedAgent + } + + guard currentAgent != nil else { + consecutiveMisses = 0 + return nil + } + + consecutiveMisses = min(consecutiveMisses + 1, UInt8(Self.releaseMissThreshold)) + if consecutiveMisses >= Self.releaseMissThreshold { + currentAgent = nil + consecutiveMisses = 0 + } + return currentAgent + } +} + +private let claudeWorkingHold: TimeInterval = 1.2 + +func stabilizeAgentState( + agent: DetectedAgent?, + previous: AgentRawState, + raw: AgentRawState, + now: Date, + lastClaudeWorkingAt: inout Date? +) -> AgentRawState { + guard agent == .claude else { + return raw + } + + switch raw { + case .working: + lastClaudeWorkingAt = now + return .working + case .blocked: + return .blocked + case .idle where previous == .working: + guard let lastClaudeWorkingAt else { + return .idle + } + return now.timeIntervalSince(lastClaudeWorkingAt) < claudeWorkingHold ? .working : .idle + case .idle, .unknown: + return raw + } +} diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift new file mode 100644 index 00000000..c24c3e70 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -0,0 +1,82 @@ +import Foundation + +func identifyAgent(processName: String) -> DetectedAgent? { + switch processName.lowercased() { + case "pi": + return .pi + case "claude", "claude-code": + return .claude + case "codex": + return .codex + case "gemini": + return .gemini + case "cursor": + return .cursor + case "cline": + return .cline + case "opencode", "open-code": + return .opencode + case "copilot", "github-copilot", "ghcs": + return .copilot + case "kimi": + return .kimi + case "droid": + return .droid + case "amp", "amp-local": + return .amp + default: + return nil + } +} + +func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: String)? { + var best: (score: Int, agent: DetectedAgent, name: String)? + + for process in job.processes { + for candidate in agentCandidates(for: process) { + guard let agent = identifyAgent(processName: candidate.name) else { continue } + if best == nil || candidate.score > best!.score { + best = (candidate.score, agent, candidate.name) + } + } + } + + return best.map { ($0.agent, $0.name) } +} + +private func agentCandidates(for process: ForegroundProcess) -> [(name: String, score: Int)] { + var candidates: [(String, Int)] = [] + + if let argv0 = process.argv0, let name = normalizedProcessName(argv0) { + candidates.append((name, 80)) + } + if let name = normalizedProcessName(process.name) { + candidates.append((name, 70)) + } + + let primaryName = normalizedProcessName(process.argv0 ?? process.name) ?? process.name.lowercased() + if isWrappedRuntime(primaryName), let cmdline = process.cmdline { + for token in cmdline.split(whereSeparator: \.isWhitespace) { + guard let name = normalizedProcessName(String(token)) else { continue } + candidates.append((name, 40)) + } + } + + return candidates +} + +private func normalizedProcessName(_ raw: String) -> String? { + guard let basename = ProcessDetection.basename(raw) else { return nil } + let lower = basename.lowercased() + if lower.hasSuffix(".js") { + return String(lower.dropLast(3)) + } + return lower +} + +private func isWrappedRuntime(_ name: String) -> Bool { + [ + "node", "bun", "python", "python3", "ruby", "deno", + "sh", "bash", "zsh", "fish", "tmux", "npx", "bunx", + ].contains(name) +} diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift new file mode 100644 index 00000000..aaeec75c --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -0,0 +1,142 @@ +import Darwin +import Foundation + +struct ForegroundProcess: Equatable, Sendable { + let pid: pid_t + let name: String + let argv0: String? + let cmdline: String? +} + +struct ForegroundJob: Equatable, Sendable { + let processGroupID: pid_t + let processes: [ForegroundProcess] +} + +enum ProcessDetection { + static func foregroundJob(childPID: pid_t) -> ForegroundJob? { + guard childPID > 0, let processGroupID = foregroundProcessGroupID(pid: childPID) else { + return nil + } + + var pids = [pid_t](repeating: 0, count: 4096) + let bytes = pids.withUnsafeMutableBufferPointer { buffer in + proc_listallpids(buffer.baseAddress, Int32(buffer.count * MemoryLayout.size)) + } + guard bytes > 0 else { return nil } + + let count = Int(bytes) / MemoryLayout.size + let processes = pids.prefix(count).compactMap { pid -> ForegroundProcess? in + guard pid > 0, + let info = processBSDInfo(pid: pid), + pid_t(info.pbi_pgid) == processGroupID, + let name = comm(from: info) + else { + return nil + } + return ForegroundProcess( + pid: pid, + name: name, + argv0: processArgv0Name(pid: pid), + cmdline: processCommandLine(pid: pid) + ) + } + + guard !processes.isEmpty else { return nil } + return ForegroundJob(processGroupID: processGroupID, processes: processes) + } + + static func foregroundProcessGroupID(pid: pid_t) -> pid_t? { + guard let info = processBSDInfo(pid: pid), info.e_tpgid > 0 else { + return nil + } + return pid_t(info.e_tpgid) + } + + static func processCommandLine(pid: pid_t) -> String? { + guard let buffer = kernProcargs2(pid: pid), let argv = procargs2Argv(buffer), !argv.isEmpty else { + return nil + } + return argv.joined(separator: " ") + } + + static func processArgv0Name(pid: pid_t) -> String? { + guard let buffer = kernProcargs2(pid: pid), let argv0 = procargs2Argv(buffer)?.first else { + return nil + } + return basename(argv0) + } + + static func processBSDInfo(pid: pid_t) -> proc_bsdinfo? { + var info = proc_bsdinfo() + let size = MemoryLayout.size + let result = withUnsafeMutablePointer(to: &info) { pointer in + proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, pointer, Int32(size)) + } + return result == Int32(size) ? info : nil + } + + static func comm(from info: proc_bsdinfo) -> String? { + let bytes = withUnsafeBytes(of: info.pbi_comm) { rawBuffer -> [UInt8] in + Array(rawBuffer) + } + let end = bytes.firstIndex(of: 0) ?? bytes.count + guard end > 0 else { return nil } + return String(bytes: bytes[.. [UInt8]? { + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + var size = 0 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 0 else { + return nil + } + + var buffer = [UInt8](repeating: 0, count: size) + let result = buffer.withUnsafeMutableBufferPointer { pointer in + sysctl(&mib, u_int(mib.count), pointer.baseAddress, &size, nil, 0) + } + guard result == 0 else { return nil } + return Array(buffer.prefix(size)) + } + + static func procargs2Argv(_ buffer: [UInt8]) -> [String]? { + guard buffer.count >= MemoryLayout.size else { return nil } + let argc = buffer.withUnsafeBytes { rawBuffer in + rawBuffer.load(as: Int32.self) + } + guard argc > 0 else { return nil } + + var position = MemoryLayout.size + guard let execEnd = buffer[position...].firstIndex(of: 0) else { return nil } + position = execEnd + while position < buffer.count, buffer[position] == 0 { + position += 1 + } + + var argv: [String] = [] + while position < buffer.count, argv.count < Int(argc) { + let start = position + while position < buffer.count, buffer[position] != 0 { + position += 1 + } + if position > start, let value = String(bytes: buffer[start.. String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + guard !trimmed.isEmpty else { return nil } + let name = (trimmed as NSString).lastPathComponent + let stripped = name.hasPrefix("-") ? String(name.dropFirst()) : name + return stripped.isEmpty ? nil : stripped + } +} diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift new file mode 100644 index 00000000..67c0ade8 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -0,0 +1,268 @@ +import Foundation + +func detectState(agent: DetectedAgent?, screen: String) -> AgentRawState { + guard let agent else { return .unknown } + switch agent { + case .pi: + return detectPi(screen) + case .claude: + return detectClaude(screen) + case .codex: + return detectCodex(screen) + case .gemini: + return detectGemini(screen) + case .cursor: + return detectCursor(screen) + case .cline: + return detectCline(screen) + case .opencode: + return detectOpenCode(screen) + case .copilot: + return detectCopilot(screen) + case .kimi: + return detectKimi(screen) + case .droid: + return detectDroid(screen) + case .amp: + return detectAmp(screen) + } +} + +private func detectPi(_ content: String) -> AgentRawState { + content.contains("Working...") ? .working : .idle +} + +private func detectClaude(_ content: String) -> AgentRawState { + let lower = content.lowercased() + + if content.contains("⌕ Search…") || lower.contains("ctrl+r to toggle") { + return .idle + } + if hasClaudeBlockedPrompt(content: content, lower: lower) { + return .blocked + } + + let above = contentAbovePromptBox(content) + let aboveLower = above.lowercased() + if aboveLower.contains("esc to interrupt") || aboveLower.contains("ctrl+c to interrupt") { + return .working + } + if hasSpinnerActivity(above) { + return .working + } + return .idle +} + +private func detectCodex(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("press enter to confirm or esc to cancel") + || lower.contains("enter to submit answer") + || lower.contains("allow command?") + || lower.contains("[y/n]") + || lower.contains("yes (y)") + || hasConfirmationPrompt(lower) + { + return .blocked + } + if hasInterruptPattern(lower) || hasCodexWorkingHeader(content) { + return .working + } + return .idle +} + +private func detectGemini(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("waiting for user confirmation") + || content.contains("│ Apply this change") + || content.contains("│ Allow execution") + || content.contains("│ Do you want to proceed") + || hasConfirmationPrompt(lower) + { + return .blocked + } + if lower.contains("esc to cancel") { + return .working + } + return .idle +} + +private func detectCursor(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("(y) (enter)") + || lower.contains("keep (n)") + || lower.contains("skip (esc or n)") + || (lower.contains("(y)") && (lower.contains("allow") || lower.contains("run"))) + { + return .blocked + } + if lower.contains("ctrl+c to stop") || hasCursorSpinner(content) { + return .working + } + return .idle +} + +private func detectCline(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("let cline use this tool") + || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes")) + { + return .blocked + } + if lower.contains("cline is ready for your message") { + return .idle + } + return .working +} + +private func detectOpenCode(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if content.contains("△ Permission required") + || (lower.contains("↑↓ select") + && (lower.contains("enter confirm") || lower.contains("enter submit") || lower.contains("enter toggle")) + && lower.contains("esc dismiss")) + || hasConfirmationPrompt(lower) + { + return .blocked + } + if lower.contains("esc to interrupt") { + return .working + } + return .idle +} + +private func detectCopilot(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("│ do you want") + || (lower.contains("confirm with") && lower.contains("enter")) + || hasConfirmationPrompt(lower) + { + return .blocked + } + if lower.contains("esc to cancel") { + return .working + } + return .idle +} + +private func detectKimi(_ content: String) -> AgentRawState { + let lower = content.lowercased() + let blockedPatterns = [ + "allow?", "confirm?", "approve?", "proceed?", "[y/n]", "(y/n)", + ] + if blockedPatterns.contains(where: lower.contains) || hasConfirmationPrompt(lower) { + return .blocked + } + + let workingPatterns = [ + "thinking", "processing", "generating", "waiting for response", "ctrl+c to cancel", + ] + if workingPatterns.contains(where: lower.contains) { + return .working + } + return .idle +} + +private func detectDroid(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if content.contains("EXECUTE") + && (lower.contains("enter to select") || lower.contains("↑↓ to navigate")) + { + return .blocked + } + if hasDroidSpinner(content) { + return .working + } + return .idle +} + +private func detectAmp(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if (lower.contains("waiting for approval") + || lower.contains("invoke tool") + || lower.contains("run this command?")) + && (lower.contains("approve") || lower.contains("allow all for this session")) + { + return .blocked + } + if lower.contains("esc to cancel") { + return .working + } + return .idle +} + +private func contentAbovePromptBox(_ content: String) -> String { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { + return content + } + let borderIndex = lines[.. Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 3 else { return false } + return trimmed.allSatisfy { $0 == "─" || $0 == "-" } +} + +private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { + if lower.contains("do you want to proceed?") + || lower.contains("waiting for permission") + || lower.contains("do you want to allow this connection?") + { + return true + } + if (lower.contains("do you want") || lower.contains("would you like")) + && (lower.contains("yes") || content.contains("❯")) + { + return true + } + return hasSelectionPrompt(content) && (lower.contains("yes") || lower.contains("no")) +} + +private func hasSelectionPrompt(_ content: String) -> Bool { + content.contains("❯") + || content.split(separator: "\n").contains { line in + line.trimmingCharacters(in: .whitespaces).hasPrefix("1.") + } +} + +private func hasConfirmationPrompt(_ lower: String) -> Bool { + (lower.contains("allow") || lower.contains("approve") || lower.contains("confirm") || lower.contains("proceed")) + && (lower.contains("[y/n]") || lower.contains("(y/n)") || lower.contains(" yes") || lower.contains(" yes (y)")) +} + +private func hasInterruptPattern(_ lower: String) -> Bool { + lower.contains("esc to interrupt") || lower.contains("ctrl+c to interrupt") +} + +private func hasCodexWorkingHeader(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + line.trimmingCharacters(in: .whitespaces).hasPrefix("• Working (") + } +} + +private func hasSpinnerActivity(_ content: String) -> Bool { + let spinnerScalars: Set = ["✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "·"] + return content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.unicodeScalars.first else { return false } + return spinnerScalars.contains(first) && trimmed.contains("…") + } +} + +private func hasCursorSpinner(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() + return (trimmed.hasPrefix("⬡") || trimmed.hasPrefix("⬢")) && trimmed.contains("ing") + } +} + +private func hasDroidSpinner(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() + guard let first = trimmed.unicodeScalars.first else { return false } + return (0x2800...0x28FF).contains(Int(first.value)) && trimmed.contains("esc to stop") + } +} diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift new file mode 100644 index 00000000..763f2baa --- /dev/null +++ b/supacodeTests/AgentClassifierTests.swift @@ -0,0 +1,62 @@ +import Testing + +@testable import supacode + +struct AgentClassifierTests { + @Test func identifiesDirectAgentProcessNames() { + #expect(identifyAgent(processName: "pi") == .pi) + #expect(identifyAgent(processName: "claude") == .claude) + #expect(identifyAgent(processName: "claude-code") == .claude) + #expect(identifyAgent(processName: "codex") == .codex) + #expect(identifyAgent(processName: "gemini") == .gemini) + #expect(identifyAgent(processName: "cursor") == .cursor) + #expect(identifyAgent(processName: "cline") == .cline) + #expect(identifyAgent(processName: "opencode") == .opencode) + #expect(identifyAgent(processName: "open-code") == .opencode) + #expect(identifyAgent(processName: "github-copilot") == .copilot) + #expect(identifyAgent(processName: "ghcs") == .copilot) + #expect(identifyAgent(processName: "kimi") == .kimi) + #expect(identifyAgent(processName: "droid") == .droid) + #expect(identifyAgent(processName: "amp") == .amp) + #expect(identifyAgent(processName: "amp-local") == .amp) + } + + @Test func ignoresPlainShellsAndUnknownProcesses() { + #expect(identifyAgent(processName: "zsh") == nil) + #expect(identifyAgent(processName: "bash") == nil) + #expect(identifyAgent(processName: "node") == nil) + #expect(identifyAgent(processName: "vim") == nil) + } + + @Test func identifiesWrappedRuntimeCommandLines() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "node", + argv0: "node", + cmdline: "node /opt/homebrew/bin/codex --model gpt-5" + ), + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .codex) + #expect(result.name == "codex") + } + + @Test func prefersDirectAgentProcessOverWrapper() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess(pid: 100, name: "node", argv0: "node", cmdline: "node /tmp/codex"), + ForegroundProcess(pid: 101, name: "claude", argv0: "claude", cmdline: "claude"), + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .claude) + #expect(result.name == "claude") + } +} diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift new file mode 100644 index 00000000..8c2fcccf --- /dev/null +++ b/supacodeTests/PaneAgentStateTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +@testable import supacode + +struct PaneAgentStateTests { + @Test func displayStateDerivesDoneFromUnseenIdle() { + var state = PaneAgentState( + detectedAgent: .codex, + fallbackState: .idle, + state: .idle, + seen: false, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + + #expect(state.displayState == .done) + state.seen = true + #expect(state.displayState == .idle) + } + + @Test func claudeWorkingIsStickyForShortIdleGap() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? + + let working = stabilizeAgentState( + agent: .claude, + previous: .idle, + raw: .working, + now: now, + lastClaudeWorkingAt: &lastWorking + ) + #expect(working == .working) + + let stillWorking = stabilizeAgentState( + agent: .claude, + previous: .working, + raw: .idle, + now: now.addingTimeInterval(0.4), + lastClaudeWorkingAt: &lastWorking + ) + #expect(stillWorking == .working) + } + + @Test func claudeTransitionsToIdleAfterStickyWindow() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? = now + + let idle = stabilizeAgentState( + agent: .claude, + previous: .working, + raw: .idle, + now: now.addingTimeInterval(1.201), + lastClaudeWorkingAt: &lastWorking + ) + + #expect(idle == .idle) + } + + @Test func nonClaudeDoesNotUseStickyWindow() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? = now + + let idle = stabilizeAgentState( + agent: .codex, + previous: .working, + raw: .idle, + now: now, + lastClaudeWorkingAt: &lastWorking + ) + + #expect(idle == .idle) + } + + @Test func presenceRequiresSixMissesBeforeRelease() { + var presence = AgentDetectionPresence(currentAgent: .codex) + + for _ in 0..<5 { + #expect(presence.update(detectedAgent: nil) == .codex) + } + #expect(presence.update(detectedAgent: nil) == nil) + #expect(presence.currentAgent == nil) + } +} diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift new file mode 100644 index 00000000..a141a5d0 --- /dev/null +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -0,0 +1,92 @@ +import Testing + +@testable import supacode + +struct ScreenHeuristicsTests { + @Test func unknownAgentIsUnknown() { + #expect(detectState(agent: nil, screen: "Working...") == .unknown) + } + + @Test func piDetection() { + #expect(detectState(agent: .pi, screen: "Working...") == .working) + #expect(detectState(agent: .pi, screen: "Done") == .idle) + } + + @Test func claudeDetection() { + #expect( + detectState( + agent: .claude, + screen: """ + Reading file + ✽ Tempering… + ───────── + ❯ + ───────── + """ + ) == .working + ) + #expect( + detectState( + agent: .claude, + screen: """ + Do you want to proceed? + ❯ 1. Yes + 2. No + + Esc to cancel · Tab to amend + """ + ) == .blocked + ) + #expect( + detectState( + agent: .claude, + screen: """ + Task complete. + ───────── + ❯ + ───────── + """ + ) == .idle + ) + } + + @Test func codexDetection() { + #expect(detectState(agent: .codex, screen: "press enter to confirm or esc to cancel") == .blocked) + #expect(detectState(agent: .codex, screen: "• Working (12s)\nesc to interrupt") == .working) + #expect(detectState(agent: .codex, screen: "Ready for input") == .idle) + } + + @Test func otherAgentDetectorsCoverStateTriplets() { + #expect(detectState(agent: .gemini, screen: "│ Apply this change") == .blocked) + #expect(detectState(agent: .gemini, screen: "esc to cancel") == .working) + #expect(detectState(agent: .gemini, screen: "done") == .idle) + + #expect(detectState(agent: .cursor, screen: "Run command? (y) (enter)") == .blocked) + #expect(detectState(agent: .cursor, screen: "⬡ indexing") == .working) + #expect(detectState(agent: .cursor, screen: "done") == .idle) + + #expect(detectState(agent: .cline, screen: "Let Cline use this tool? yes") == .blocked) + #expect(detectState(agent: .cline, screen: "Cline is ready for your message") == .idle) + #expect(detectState(agent: .cline, screen: "still processing") == .working) + + #expect(detectState(agent: .opencode, screen: "△ Permission required") == .blocked) + #expect(detectState(agent: .opencode, screen: "esc to interrupt") == .working) + #expect(detectState(agent: .opencode, screen: "done") == .idle) + + #expect(detectState(agent: .copilot, screen: "│ do you want to run this?") == .blocked) + #expect(detectState(agent: .copilot, screen: "esc to cancel") == .working) + #expect(detectState(agent: .copilot, screen: "done") == .idle) + + #expect(detectState(agent: .kimi, screen: "approve? [y/n]") == .blocked) + #expect(detectState(agent: .kimi, screen: "thinking") == .working) + #expect(detectState(agent: .kimi, screen: "done") == .idle) + + #expect(detectState(agent: .droid, screen: "EXECUTE\nenter to select") == .blocked) + #expect(detectState(agent: .droid, screen: "⠋ esc to stop") == .working) + #expect(detectState(agent: .droid, screen: "done") == .idle) + + #expect(detectState(agent: .amp, screen: "waiting for approval\nallow all for this session") == .blocked) + #expect(detectState(agent: .amp, screen: "esc to cancel") == .working) + #expect(detectState(agent: .amp, screen: "done") == .idle) + } +} -- 2.51.2 From 963480cdcf16de211c230e877ec1161118f63938 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 19:28:48 +0900 Subject: [PATCH 079/528] Add patched Ghostty fork for surface PID --- .gitmodules | 2 +- ThirdParty/ghostty | 2 +- doc-onevcat/active-agents-panel-task-log.md | 2 + doc-onevcat/change-list.md | 9 ++++ doc-onevcat/fork-sync-and-release.md | 13 ++++++ doc-onevcat/fork-sync-ghostty.md | 49 +++++++++++++++++++++ 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 doc-onevcat/fork-sync-ghostty.md diff --git a/.gitmodules b/.gitmodules index 4b1ead7c..d7da14ee 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "ThirdParty/ghostty"] path = ThirdParty/ghostty - url = https://github.com/ghostty-org/ghostty + url = git@github.com:onevcat/ghostty.git [submodule "Resources/git-wt"] path = Resources/git-wt url = https://github.com/khoi/git-wt.git diff --git a/ThirdParty/ghostty b/ThirdParty/ghostty index 332b2aef..76dce319 160000 --- a/ThirdParty/ghostty +++ b/ThirdParty/ghostty @@ -1 +1 @@ -Subproject commit 332b2aefc6e72d363aa93ab6ecfc86eeeeb5ed28 +Subproject commit 76dce319f55db097b2b7ae3cad2f6267475936f0 diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 4053a76d..acff1a04 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -17,3 +17,5 @@ ### Decisions And Notes - Pure detection logic is implemented first with tests before wiring, because it is the most important stable contract for later UI iteration. +- Created `onevcat/ghostty` and pushed `release/v1.3.1-patched` with `ghostty_surface_pid`. +- `make sync-ghostty` currently fails before compiling Ghostty sources because Zig 0.15.2 cannot link even a trivial native macOS executable on this host without an explicit lower target/sysroot. Direct `zig build-exe -target aarch64-macos.15.0 --sysroot "$(xcrun --sdk macosx --show-sdk-path)"` succeeds, so this is tracked as a local Zig host toolchain issue. diff --git a/doc-onevcat/change-list.md b/doc-onevcat/change-list.md index e59b7c25..a7210e25 100644 --- a/doc-onevcat/change-list.md +++ b/doc-onevcat/change-list.md @@ -13,6 +13,15 @@ Future upstream checks should only inspect commits **after** this baseline. --- +## 2026-05-09 — Ghostty fork patch + +- Created `onevcat/ghostty` fork branch `release/v1.3.1-patched` from upstream tag `v1.3.1`. +- Added fork-only embedded C API `ghostty_surface_pid(ghostty_surface_t)` for per-pane agent process detection. +- Prowl submodule now tracks the patched fork branch. Upgrade procedure is documented in + `doc-onevcat/fork-sync-ghostty.md`. + +--- + ## 2026-05-08 — Review through post-v0.8.5 ### Upstream changes reviewed diff --git a/doc-onevcat/fork-sync-and-release.md b/doc-onevcat/fork-sync-and-release.md index 2980a937..ec1661dd 100644 --- a/doc-onevcat/fork-sync-and-release.md +++ b/doc-onevcat/fork-sync-and-release.md @@ -56,6 +56,19 @@ Or use the `/sync-upstream` command which automates these steps. If conflicts happen, resolve once, commit, and `rerere` will likely auto-apply next time. +## Ghostty Submodule Sync + +Prowl carries a small Ghostty fork patch for embedded APIs. Before changing or upgrading `ThirdParty/ghostty`, read +`doc-onevcat/fork-sync-ghostty.md`. + +The submodule should point at `onevcat/ghostty` patched branches named `release/v-patched`. After moving +the submodule pointer, run: + +```bash +make sync-ghostty +make build-app +``` + ## Release Workflow ### Full Public Release diff --git a/doc-onevcat/fork-sync-ghostty.md b/doc-onevcat/fork-sync-ghostty.md new file mode 100644 index 00000000..dc7553cd --- /dev/null +++ b/doc-onevcat/fork-sync-ghostty.md @@ -0,0 +1,49 @@ +# Ghostty Fork Sync + +Prowl embeds GhosttyKit from `ThirdParty/ghostty`. The submodule points to the `onevcat/ghostty` fork so Prowl can carry small embedded API patches that are not yet upstream. + +## Branch Model + +- Upstream remote: `https://github.com/ghostty-org/ghostty` +- Fork remote: `git@github.com:onevcat/ghostty.git` +- Per-version patched branches: `release/v-patched` +- Current patched branch: `release/v1.3.1-patched` + +Each patched branch starts at the matching upstream tag and only adds onevcat patches. Do not rewrite an existing patched branch after publishing it. + +## Current Patch + +- Commit: `76dce319f55db097b2b7ae3cad2f6267475936f0` +- Summary: expose `ghostty_surface_pid(ghostty_surface_t)` from the embedded C API. +- Behavior: returns the local surface child process PID, or `0` when unavailable or exited. + +## Upgrade To A New Ghostty Tag + +```bash +cd ThirdParty/ghostty + +git fetch upstream --tags +git fetch onevcat + +PREV=v1.3.1 +NEXT=v1.3.2 + +git checkout -b "release/${NEXT}-patched" "${NEXT}" +git cherry-pick "${PREV}..onevcat/release/${PREV}-patched" +git push -u onevcat "release/${NEXT}-patched" + +cd ../.. +git -C ThirdParty/ghostty checkout "release/${NEXT}-patched" +git add ThirdParty/ghostty +git commit -m "ghostty: bump submodule to ${NEXT}-patched" +make sync-ghostty +make build-app +``` + +## Force Push Policy + +Do not force-push `release/v*-patched` branches. If a cherry-pick needs repair, use a temporary fix branch, validate it, then fast-forward the patched branch. + +## Build Note + +On macOS 26.3.1 with Zig 0.15.2, native `zig build` can fail before running Ghostty's build script because Zig links the build runner with `-platform_version macos 26.3.1 26.4` and fails to resolve libSystem symbols. Direct `zig build-exe -target aarch64-macos.15.0 --sysroot "$(xcrun --sdk macosx --show-sdk-path)"` works, so this is a local Zig host-target/toolchain issue rather than a Ghostty patch syntax error. Re-run `make sync-ghostty` after the Zig toolchain issue is resolved. -- 2.51.2 From 789ae7ecaa8400b93827586205cd21724c6f6148 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 19:35:33 +0900 Subject: [PATCH 080/528] Wire active agents panel --- doc-onevcat/active-agents-panel-task-log.md | 5 + .../Clients/Terminal/TerminalClient.swift | 2 + .../Models/ActiveAgentEntry.swift | 52 ++++++ .../Reducer/ActiveAgentsFeature.swift | 52 ++++++ .../ActiveAgents/Views/ActiveAgentRow.swift | 80 +++++++++ .../Views/ActiveAgentsPanel.swift | 80 +++++++++ .../Features/App/Reducer/AppFeature.swift | 6 + .../Reducer/RepositoriesFeature.swift | 17 ++ .../Views/SidebarFooterView.swift | 11 ++ .../Repositories/Views/SidebarListView.swift | 55 ++++--- .../WorktreeTerminalManager.swift | 6 + .../Models/WorktreeTerminalState.swift | 154 ++++++++++++++++++ .../AgentDetection/ScreenHeuristics.swift | 4 +- .../Ghostty/GhosttySurfaceBridge.swift | 17 ++ supacodeTests/ActiveAgentsFeatureTests.swift | 69 ++++++++ supacodeTests/AgentClassifierTests.swift | 2 +- .../ProcessDetectionSmokeTests.swift | 19 +++ 17 files changed, 608 insertions(+), 23 deletions(-) create mode 100644 supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift create mode 100644 supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift create mode 100644 supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift create mode 100644 supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift create mode 100644 supacodeTests/ActiveAgentsFeatureTests.swift create mode 100644 supacodeTests/ProcessDetectionSmokeTests.swift diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index acff1a04..e6a3d9e4 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -19,3 +19,8 @@ - Pure detection logic is implemented first with tests before wiring, because it is the most important stable contract for later UI iteration. - Created `onevcat/ghostty` and pushed `release/v1.3.1-patched` with `ghostty_surface_pid`. - `make sync-ghostty` currently fails before compiling Ghostty sources because Zig 0.15.2 cannot link even a trivial native macOS executable on this host without an explicit lower target/sysroot. Direct `zig build-exe -target aarch64-macos.15.0 --sysroot "$(xcrun --sdk macosx --show-sdk-path)"` succeeds, so this is tracked as a local Zig host toolchain issue. +- Added Active Agents reducer/UI wiring and terminal detection loop. `GhosttySurfaceBridge.childPID()` uses `dlsym` so the app still compiles before the patched GhosttyKit binary is rebuilt; after rebuild, the exported `ghostty_surface_pid` symbol is used automatically. + +### Verification + +- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 17 tests. One unrelated existing compile warning remains in `GithubCLIClientTests.swift:93`. diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 94187a70..470758c4 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -59,6 +59,8 @@ struct TerminalClient { case tabClosed(worktreeID: Worktree.ID, remainingTabs: Int) case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) + case agentEntryChanged(ActiveAgentEntry) + case agentEntryRemoved(ActiveAgentEntry.ID) case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) case commandPaletteToggleRequested(worktreeID: Worktree.ID) case setupScriptConsumed(worktreeID: Worktree.ID) diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift new file mode 100644 index 00000000..346c410a --- /dev/null +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -0,0 +1,52 @@ +import Foundation + +struct ActiveAgentEntry: Identifiable, Equatable, Sendable { + typealias ID = UUID + + let id: ID + let worktreeID: Worktree.ID + let worktreeName: String + let tabID: TerminalTabID + let tabTitle: String + let surfaceID: UUID + let paneIndex: Int + let agent: DetectedAgent + let rawState: AgentRawState + let displayState: AgentDisplayState + let lastChangedAt: Date + + var subtitle: String { + "\(worktreeName) · tab \(tabTitle) · pane \(paneIndex)" + } +} + +extension ActiveAgentEntry { + static func sorted(_ entries: [ActiveAgentEntry]) -> [ActiveAgentEntry] { + entries.sorted { left, right in + let leftRank = left.displayState.sortRank + let rightRank = right.displayState.sortRank + if leftRank != rightRank { + return leftRank < rightRank + } + if left.lastChangedAt != right.lastChangedAt { + return left.lastChangedAt > right.lastChangedAt + } + return left.id.uuidString < right.id.uuidString + } + } +} + +extension AgentDisplayState { + fileprivate var sortRank: Int { + switch self { + case .blocked: + return 0 + case .working: + return 1 + case .done: + return 2 + case .idle: + return 3 + } + } +} diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift new file mode 100644 index 00000000..edf1b8ae --- /dev/null +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -0,0 +1,52 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Sharing + +@Reducer +struct ActiveAgentsFeature { + @ObservableState + struct State: Equatable { + var entries: IdentifiedArrayOf = [] + @Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false + @Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200 + } + + enum Action: Equatable { + case agentEntryChanged(ActiveAgentEntry) + case agentEntryRemoved(ActiveAgentEntry.ID) + case entryTapped(ActiveAgentEntry.ID) + case togglePanelVisibility + case panelHeightChanged(Double) + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .agentEntryChanged(let entry): + state.entries[id: entry.id] = entry + state.entries = IdentifiedArray(uniqueElements: ActiveAgentEntry.sorted(Array(state.entries))) + return .none + + case .agentEntryRemoved(let id): + state.entries.remove(id: id) + return .none + + case .entryTapped: + return .none + + case .togglePanelVisibility: + state.$isPanelHidden.withLock { $0.toggle() } + return .none + + case .panelHeightChanged(let height): + state.$panelHeight.withLock { $0 = Self.clampedPanelHeight(height) } + return .none + } + } + } + + static func clampedPanelHeight(_ height: Double) -> Double { + min(420, max(120, height)) + } +} diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift new file mode 100644 index 00000000..8a826004 --- /dev/null +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import SwiftUI + +struct ActiveAgentRow: View { + let entry: ActiveAgentEntry + + var body: some View { + HStack(spacing: 8) { + agentIcon + VStack(alignment: .leading, spacing: 2) { + Text(entry.agent.displayName) + .font(.body.monospaced()) + .lineLimit(1) + Text(entry.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + statusPill + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(.rect) + } + + private var agentIcon: some View { + Group { + if let icon = CommandIconMap.iconForFirstToken(entry.agent.iconLookupToken) { + TabIconImage(rawName: icon.storageString, pointSize: 16) + } else { + Image(systemName: "sparkle") + } + } + .frame(width: 20, height: 20) + .accessibilityHidden(true) + } + + private var statusPill: some View { + HStack(spacing: 4) { + if entry.displayState == .working { + ProgressView() + .controlSize(.small) + .frame(width: 10, height: 10) + } + Text(entry.displayState.label) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(entry.displayState.foregroundStyle) + } +} + +extension AgentDisplayState { + fileprivate var label: String { + switch self { + case .working: + return "Working" + case .blocked: + return "Blocked" + case .done: + return "Done" + case .idle: + return "Idle" + } + } + + fileprivate var foregroundStyle: Color { + switch self { + case .working: + return .orange + case .blocked: + return .red + case .done: + return .blue + case .idle: + return .secondary + } + } +} diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift new file mode 100644 index 00000000..1582239d --- /dev/null +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import SwiftUI + +struct ActiveAgentsPanel: View { + @Bindable var store: StoreOf + @State private var dragStartHeight: Double? + + var body: some View { + VStack(spacing: 0) { + resizeHandle + HStack { + Text("Active Agents") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + + if store.entries.isEmpty { + Spacer(minLength: 0) + Text("No active agents") + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.entries) { entry in + Button { + store.send(.entryTapped(entry.id)) + } label: { + ActiveAgentRow(entry: entry) + } + .buttonStyle(.plain) + .help("Focus \(entry.agent.displayName) in \(entry.worktreeName)") + } + } + } + .scrollIndicators(.never) + } + } + .background(.bar) + .overlay(alignment: .top) { + Divider() + } + } + + private var resizeHandle: some View { + Rectangle() + .fill(.separator) + .frame(height: 1) + .frame(maxWidth: .infinity) + .overlay { + Rectangle() + .fill(.clear) + .frame(height: 8) + .contentShape(.rect) + } + .gesture( + DragGesture() + .onChanged { value in + let start = dragStartHeight ?? store.panelHeight + dragStartHeight = start + store.send(.panelHeightChanged(start - value.translation.height)) + } + .onEnded { _ in + dragStartHeight = nil + } + ) + .onHover { hovering in + if hovering { + NSCursor.resizeUpDown.set() + } else { + NSCursor.arrow.set() + } + } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index c09aae74..c62d19e7 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1039,6 +1039,12 @@ struct AppFeature { } return .none + case .terminalEvent(.agentEntryChanged(let entry)): + return .send(.repositories(.activeAgents(.agentEntryChanged(entry)))) + + case .terminalEvent(.agentEntryRemoved(let id)): + return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) + case .terminalEvent(.commandPaletteToggleRequested(let worktreeID)): if state.commandPalette.isPresented { return .send(.commandPalette(.setPresented(false))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index fa03ed64..90d298bd 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -240,6 +240,7 @@ struct RepositoriesFeature { var pendingSidebarReveal: PendingSidebarReveal? var isSidebarDragActive = false var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] + var activeAgents = ActiveAgentsFeature.State() @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var alert: AlertState? @@ -274,6 +275,7 @@ struct RepositoriesFeature { case worktreeOrdering(WorktreeOrderingAction) case githubIntegration(GithubIntegrationAction) case repositoryManagement(RepositoryManagementAction) + case activeAgents(ActiveAgentsFeature.Action) case task case repositorySnapshotLoaded([Repository]?) case setOpenPanelPresented(Bool) @@ -402,6 +404,18 @@ struct RepositoriesFeature { case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement: return .none + case .activeAgents(.entryTapped(let id)): + guard let entry = state.activeAgents.entries[id: id] else { return .none } + return .merge( + .send(.selectWorktree(entry.worktreeID, focusTerminal: false)), + .run { _ in + _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) + } + ) + + case .activeAgents: + return .none + case .task: state.snapshotPersistencePhase = .restoring return .run { send in @@ -1121,6 +1135,9 @@ struct RepositoriesFeature { worktreeOrderingReducer githubIntegrationReducer repositoryManagementReducer + Scope(state: \.activeAgents, action: \.activeAgents) { + ActiveAgentsFeature() + } } .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { WorktreeCreationPromptFeature() diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 38713342..27970066 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -36,6 +36,17 @@ struct SidebarFooterView: View { .menuIndicator(.hidden) .help("Help") Spacer() + Button { + store.send(.activeAgents(.togglePanelVisibility)) + } label: { + Image( + systemName: store.state.activeAgents.isPanelHidden + ? "rectangle.bottomthird.inset" + : "rectangle.bottomthird.inset.filled" + ) + .accessibilityLabel(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") + } + .help(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") Button { store.send(.refreshWorktrees) } label: { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index fa8228cf..508906f4 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -59,34 +59,47 @@ struct SidebarListView: View { let pendingSidebarReveal = state.pendingSidebarReveal ScrollViewReader { scrollProxy in - ScrollView { - LazyVStack(spacing: 0) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: 0) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } - if repositoryItems.isEmpty { - emptyRepositoryHint() - } + if repositoryItems.isEmpty { + emptyRepositoryHint() + } - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + } } + .padding(.vertical, 2) + } + .scrollIndicators(.never) + .frame(maxHeight: .infinity) + + if !state.activeAgents.isPanelHidden { + ActiveAgentsPanel( + store: store.scope(state: \.activeAgents, action: \.activeAgents) + ) + .frame(height: state.activeAgents.panelHeight) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .padding(.vertical, 2) } - .scrollIndicators(.never) .frame(minWidth: 220) .background(.bar) + .clipped() + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) .onDragSessionUpdated { session in if case .ended = session.phase { endSidebarDrag() diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index ae5b1391..b30b5803 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -247,6 +247,12 @@ final class WorktreeTerminalManager { state.onTaskStatusChanged = { [weak self] status in self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) } + state.onAgentEntryChanged = { [weak self] entry in + self?.emit(.agentEntryChanged(entry)) + } + state.onAgentEntryRemoved = { [weak self] id in + self?.emit(.agentEntryRemoved(id)) + } state.onRunScriptStatusChanged = { [weak self] isRunning in self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 55f0338b..8d0c91d9 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -23,6 +23,10 @@ final class WorktreeTerminalState { private var trees: [TerminalTabID: SplitTree] = [:] private var surfaces: [UUID: GhosttySurfaceView] = [:] private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] + private(set) var surfaceAgentStates: [UUID: PaneAgentState] = [:] + private var agentDetectionTasks: [UUID: Task] = [:] + private var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] + private var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] private var runScriptTabId: TerminalTabID? private var pendingSetupScript: Bool @@ -103,6 +107,8 @@ final class WorktreeTerminalState { var onTabClosed: (() -> Void)? var onFocusChanged: ((UUID) -> Void)? var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? + var onAgentEntryChanged: ((ActiveAgentEntry) -> Void)? + var onAgentEntryRemoved: ((ActiveAgentEntry.ID) -> Void)? var onRunScriptStatusChanged: ((Bool) -> Void)? var onCommandPaletteToggle: (() -> Void)? var onSetupScriptConsumed: (() -> Void)? @@ -580,6 +586,7 @@ final class WorktreeTerminalState { newSurface.closeSurface() surfaces.removeValue(forKey: newSurface.id) cleanupCommandDetectorState(forSurfaceId: newSurface.id) + cleanupAgentDetectionState(forSurfaceId: newSurface.id) return nil } } @@ -664,6 +671,7 @@ final class WorktreeTerminalState { newSurface.closeSurface() surfaces.removeValue(forKey: newSurface.id) cleanupCommandDetectorState(forSurfaceId: newSurface.id) + cleanupAgentDetectionState(forSurfaceId: newSurface.id) return false } @@ -760,6 +768,7 @@ final class WorktreeTerminalState { surfaces.removeAll() trees.removeAll() focusedSurfaceIdByTab.removeAll() + cleanupAllAgentDetectionState() tabIsRunningById.removeAll() autoCloseSurfaceIds.removeAll() pendingCustomCommands.removeAll() @@ -1060,6 +1069,7 @@ final class WorktreeTerminalState { configureBridgeCallbacks(for: view, tabId: tabId) configureSurfaceCallbacks(for: view, tabId: tabId) surfaces[view.id] = view + startAgentDetection(for: view, tabId: tabId) return view } @@ -1307,6 +1317,7 @@ final class WorktreeTerminalState { private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { focusedSurfaceIdByTab[tabId] = surface.id + markAgentSeen(surfaceID: surface.id) markNotificationsRead(forSurfaceID: surface.id) updateTabTitle(for: tabId) emitFocusChangedIfNeeded(surface.id) @@ -1538,6 +1549,145 @@ final class WorktreeTerminalState { awaitingIdleTitleLearningBySurface.remove(surfaceId) } + private func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { + agentDetectionTasks[view.id]?.cancel() + surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) + agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in + while !Task.isCancelled { + guard let self, let view, self.surfaces[view.id] != nil else { return } + self.detectAgentState(for: view, tabId: tabId) + let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil + try? await Task.sleep(for: hasAgent ? .milliseconds(300) : .milliseconds(500)) + } + } + } + + private func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) { + let surfaceID = view.id + let now = Date() + let probedAgent: DetectedAgent? + if let childPID = view.bridge.childPID(), + let job = ProcessDetection.foregroundJob(childPID: childPID), + let identified = identifyAgentInJob(job) + { + probedAgent = identified.agent + } else { + probedAgent = nil + } + + var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() + let agent = presence.update(detectedAgent: probedAgent) + agentDetectionPresenceBySurface[surfaceID] = presence + + guard let agent else { + removeAgentEntryIfNeeded(surfaceID: surfaceID) + return + } + + let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) + let raw = detectState(agent: agent, screen: view.bridge.readViewportText() ?? "") + var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] + let stabilized = stabilizeAgentState( + agent: agent, + previous: previous.state, + raw: raw, + now: now, + lastClaudeWorkingAt: &lastClaudeWorkingAt + ) + lastClaudeWorkingAtBySurface[surfaceID] = lastClaudeWorkingAt + + let isForeground = isSelected() && isFocusedSurface(surfaceID) + let becameIdleFromActive = + (previous.state == .working || previous.state == .blocked) + && stabilized == .idle + let seen: Bool + if isForeground || stabilized == .blocked { + seen = true + } else if becameIdleFromActive { + seen = false + } else { + seen = previous.seen + } + let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt + let next = PaneAgentState( + detectedAgent: agent, + fallbackState: raw, + state: stabilized, + seen: seen, + lastChangedAt: lastChangedAt + ) + guard next != previous else { return } + surfaceAgentStates[surfaceID] = next + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) + } + + private func markAgentSeen(surfaceID: UUID) { + guard var state = surfaceAgentStates[surfaceID], !state.seen else { return } + state.seen = true + state.lastChangedAt = Date() + surfaceAgentStates[surfaceID] = state + guard let tabId = tabId(containing: surfaceID) else { return } + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + } + + private func removeAgentEntryIfNeeded(surfaceID: UUID) { + guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } + surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) + lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceID) + onAgentEntryRemoved?(surfaceID) + } + + private func emitAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) { + guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { + onAgentEntryRemoved?(surfaceID) + return + } + onAgentEntryChanged?(entry) + } + + private func activeAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) -> ActiveAgentEntry? { + guard let agent = state.detectedAgent, state.state != .unknown else { return nil } + let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] + let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 + let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" + return ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + tabID: tabId, + tabTitle: tabTitle, + surfaceID: surfaceID, + paneIndex: paneIndex, + agent: agent, + rawState: state.state, + displayState: state.displayState, + lastChangedAt: state.lastChangedAt + ) + } + + private func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { + agentDetectionTasks[surfaceId]?.cancel() + agentDetectionTasks.removeValue(forKey: surfaceId) + surfaceAgentStates.removeValue(forKey: surfaceId) + agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) + lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) + onAgentEntryRemoved?(surfaceId) + } + + private func cleanupAllAgentDetectionState() { + for task in agentDetectionTasks.values { + task.cancel() + } + let removedIDs = Array(surfaceAgentStates.keys) + agentDetectionTasks.removeAll() + surfaceAgentStates.removeAll() + agentDetectionPresenceBySurface.removeAll() + lastClaudeWorkingAtBySurface.removeAll() + for id in removedIDs { + onAgentEntryRemoved?(id) + } + } + /// Heuristic shape-only detection for shell idle prompts. The /// bootstrap filter — before `awaitingIdleTitleLearning` has caught /// the precmd-set prompt at least once on this surface — for two @@ -1605,6 +1755,7 @@ final class WorktreeTerminalState { autoCloseSurfaceIds.remove(surface.id) pendingCustomCommands.removeValue(forKey: surface.id) cleanupCommandDetectorState(forSurfaceId: surface.id) + cleanupAgentDetectionState(forSurfaceId: surface.id) } focusedSurfaceIdByTab.removeValue(forKey: tabId) tabIsRunningById.removeValue(forKey: tabId) @@ -1753,6 +1904,7 @@ final class WorktreeTerminalState { autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) + cleanupAgentDetectionState(forSurfaceId: view.id) return } guard let node = tree.find(id: view.id) else { @@ -1761,6 +1913,7 @@ final class WorktreeTerminalState { autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) + cleanupAgentDetectionState(forSurfaceId: view.id) return } let nextSurface = @@ -1773,6 +1926,7 @@ final class WorktreeTerminalState { autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) + cleanupAgentDetectionState(forSurfaceId: view.id) if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 67c0ade8..69c73489 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -244,7 +244,9 @@ private func hasCodexWorkingHeader(_ content: String) -> Bool { } private func hasSpinnerActivity(_ content: String) -> Bool { - let spinnerScalars: Set = ["✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "·"] + let spinnerScalars: Set = [ + "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "·", + ] return content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 7f1b4ad9..dfc023e4 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -2,6 +2,8 @@ import AppKit import Foundation import GhosttyKit +private typealias GhosttySurfacePIDFunction = @convention(c) (ghostty_surface_t) -> Int32 + @MainActor final class GhosttySurfaceBridge { let state = GhosttySurfaceState() @@ -51,6 +53,21 @@ final class GhosttySurfaceBridge { sendText(finalCommand) } + func readViewportText() -> String? { + surfaceView?.readViewportContentsForCLI() + } + + func childPID() -> pid_t? { + guard let surface, + let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "ghostty_surface_pid") + else { + return nil + } + let function = unsafeBitCast(symbol, to: GhosttySurfacePIDFunction.self) + let pid = function(surface) + return pid > 0 ? pid_t(pid) : nil + } + func closeSurface(processAlive: Bool) { onCloseRequest?(processAlive) } diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift new file mode 100644 index 00000000..a66308be --- /dev/null +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct ActiveAgentsFeatureTests { + @Test func entriesAreSortedByStateThenRecency() async { + let store = TestStore(initialState: ActiveAgentsFeature.State()) { + ActiveAgentsFeature() + } + + let old = Date(timeIntervalSince1970: 10) + let new = Date(timeIntervalSince1970: 20) + let idle = entry(id: UUID(0), state: .idle, changedAt: new) + let blocked = entry(id: UUID(1), state: .blocked, changedAt: old) + let working = entry(id: UUID(2), state: .working, changedAt: new) + let done = entry(id: UUID(3), state: .done, changedAt: new) + + await store.send(.agentEntryChanged(idle)) { + $0.entries = [idle] + } + await store.send(.agentEntryChanged(blocked)) { + $0.entries = [blocked, idle] + } + await store.send(.agentEntryChanged(working)) { + $0.entries = [blocked, working, idle] + } + await store.send(.agentEntryChanged(done)) { + $0.entries = [blocked, working, done, idle] + } + } + + @Test func panelHeightIsClamped() async { + let store = TestStore(initialState: ActiveAgentsFeature.State()) { + ActiveAgentsFeature() + } + + await store.send(.panelHeightChanged(20)) { + $0.$panelHeight.withLock { $0 = 120 } + } + await store.send(.panelHeightChanged(900)) { + $0.$panelHeight.withLock { $0 = 420 } + } + } + + private func entry(id: UUID, state: AgentDisplayState, changedAt: Date) -> ActiveAgentEntry { + ActiveAgentEntry( + id: id, + worktreeID: "/repo/wt", + worktreeName: "wt", + tabID: TerminalTabID(rawValue: UUID()), + tabTitle: "1", + surfaceID: id, + paneIndex: 1, + agent: .codex, + rawState: state == .blocked ? .blocked : state == .working ? .working : .idle, + displayState: state, + lastChangedAt: changedAt + ) + } +} + +extension UUID { + fileprivate init(_ value: UInt8) { + self.init(uuid: (value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + } +} diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index 763f2baa..d0ee7a31 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -37,7 +37,7 @@ struct AgentClassifierTests { name: "node", argv0: "node", cmdline: "node /opt/homebrew/bin/codex --model gpt-5" - ), + ) ] ) diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift new file mode 100644 index 00000000..d505825c --- /dev/null +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -0,0 +1,19 @@ +import Darwin +import Testing + +@testable import supacode + +struct ProcessDetectionSmokeTests { + @Test func readsCurrentProcessArguments() throws { + let pid = getpid() + + let argv0 = try #require(ProcessDetection.processArgv0Name(pid: pid)) + let cmdline = try #require(ProcessDetection.processCommandLine(pid: pid)) + let info = try #require(ProcessDetection.processBSDInfo(pid: pid)) + let comm = try #require(ProcessDetection.comm(from: info)) + + #expect(!argv0.isEmpty) + #expect(!cmdline.isEmpty) + #expect(!comm.isEmpty) + } +} -- 2.51.2 From b8485f4654a93aa7171c1d63559ff80126fc1489 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 19:44:07 +0900 Subject: [PATCH 081/528] Polish active agents panel checks --- doc-onevcat/active-agents-panel-task-log.md | 11 +- .../Domain/AgentDetection/DetectedAgent.swift | 1 + .../Models/ActiveAgentEntry.swift | 4 +- .../Reducer/ActiveAgentsFeature.swift | 10 +- .../Views/ActiveAgentsPanel.swift | 4 +- .../Repositories/Views/SidebarListView.swift | 154 +++++++++--------- .../AgentDetection/AgentClassifier.swift | 10 +- supacodeTests/ActiveAgentsFeatureTests.swift | 6 + 8 files changed, 117 insertions(+), 83 deletions(-) diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index e6a3d9e4..5f951471 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -18,9 +18,16 @@ - Pure detection logic is implemented first with tests before wiring, because it is the most important stable contract for later UI iteration. - Created `onevcat/ghostty` and pushed `release/v1.3.1-patched` with `ghostty_surface_pid`. -- `make sync-ghostty` currently fails before compiling Ghostty sources because Zig 0.15.2 cannot link even a trivial native macOS executable on this host without an explicit lower target/sysroot. Direct `zig build-exe -target aarch64-macos.15.0 --sysroot "$(xcrun --sdk macosx --show-sdk-path)"` succeeds, so this is tracked as a local Zig host toolchain issue. +- `make sync-ghostty` fails under Xcode 26.4.1 before compiling Ghostty sources because Zig 0.15.2 cannot link the native build runner. Re-running with `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer` succeeds. - Added Active Agents reducer/UI wiring and terminal detection loop. `GhosttySurfaceBridge.childPID()` uses `dlsym` so the app still compiles before the patched GhosttyKit binary is rebuilt; after rebuild, the exported `ghostty_surface_pid` symbol is used automatically. +- The Active Agents panel height is persisted globally but visually capped by the sidebar container height, reserving at least 200 pt for the repository list. ### Verification -- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 17 tests. One unrelated existing compile warning remains in `GithubCLIClientTests.swift:93`. +- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 18 tests. +- `make check` passed after keeping the `pi` agent case name and disabling SwiftLint's `identifier_name` rule on that enum case only. +- `xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" 2>&1 | xcsift -f toon -w` passed. +- `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty` completed successfully. +- `nm -gU Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/libghostty.a | rg 'ghostty_surface_(pid|process_exited)'` finds both `_ghostty_surface_pid` and `_ghostty_surface_process_exited`. +- `make build-app` completed successfully after building the app and embedded CLI. +- `make test` passed 1037 tests. It printed the same GhosttyKit preflight Zig link failure under Xcode 26.4.1, then continued to the Xcode test suite. diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index 6a29eccc..1114f2dc 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -1,6 +1,7 @@ import Foundation enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { + // swiftlint:disable:next identifier_name case pi case claude case codex diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index 346c410a..ec75f295 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -1,9 +1,7 @@ import Foundation struct ActiveAgentEntry: Identifiable, Equatable, Sendable { - typealias ID = UUID - - let id: ID + let id: UUID let worktreeID: Worktree.ID let worktreeName: String let tabID: TerminalTabID diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index edf1b8ae..e37584fc 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -5,6 +5,10 @@ import Sharing @Reducer struct ActiveAgentsFeature { + static let minimumPanelHeight = 120.0 + static let maximumPanelHeight = 420.0 + static let reservedSidebarListHeight = 200.0 + @ObservableState struct State: Equatable { var entries: IdentifiedArrayOf = [] @@ -47,6 +51,10 @@ struct ActiveAgentsFeature { } static func clampedPanelHeight(_ height: Double) -> Double { - min(420, max(120, height)) + min(maximumPanelHeight, max(minimumPanelHeight, height)) + } + + static func maximumPanelHeight(forContainerHeight height: Double) -> Double { + max(minimumPanelHeight, min(maximumPanelHeight, height - reservedSidebarListHeight)) } } diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 1582239d..ac0bbb41 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -3,6 +3,7 @@ import SwiftUI struct ActiveAgentsPanel: View { @Bindable var store: StoreOf + let maximumHeight: Double @State private var dragStartHeight: Double? var body: some View { @@ -63,7 +64,8 @@ struct ActiveAgentsPanel: View { .onChanged { value in let start = dragStartHeight ?? store.panelHeight dragStartHeight = start - store.send(.panelHeightChanged(start - value.translation.height)) + let height = ActiveAgentsFeature.clampedPanelHeight(start - value.translation.height) + store.send(.panelHeightChanged(min(maximumHeight, height))) } .onEnded { _ in dragStartHeight = nil diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 508906f4..e88042c0 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -59,88 +59,94 @@ struct SidebarListView: View { let pendingSidebarReveal = state.pendingSidebarReveal ScrollViewReader { scrollProxy in - VStack(spacing: 0) { - ScrollView { - LazyVStack(spacing: 0) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } - - if repositoryItems.isEmpty { - emptyRepositoryHint() - } - - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) + GeometryReader { proxy in + let maximumPanelHeight = ActiveAgentsFeature.maximumPanelHeight( + forContainerHeight: Double(proxy.size.height) + ) + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: 0) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } + + if repositoryItems.isEmpty { + emptyRepositoryHint() + } + + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + } } + .padding(.vertical, 2) + } + .scrollIndicators(.never) + .frame(maxHeight: .infinity) + + if !state.activeAgents.isPanelHidden { + ActiveAgentsPanel( + store: store.scope(state: \.activeAgents, action: \.activeAgents), + maximumHeight: maximumPanelHeight + ) + .frame(height: min(state.activeAgents.panelHeight, maximumPanelHeight)) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .padding(.vertical, 2) } - .scrollIndicators(.never) - .frame(maxHeight: .infinity) - - if !state.activeAgents.isPanelHidden { - ActiveAgentsPanel( - store: store.scope(state: \.activeAgents, action: \.activeAgents) - ) - .frame(height: state.activeAgents.panelHeight) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .frame(minWidth: 220) + .background(.bar) + .clipped() + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) + .onDragSessionUpdated { session in + if case .ended = session.phase { + endSidebarDrag() + return + } + if case .dataTransferCompleted = session.phase { + endSidebarDrag() + } } - } - .frame(minWidth: 220) - .background(.bar) - .clipped() - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) - .onDragSessionUpdated { session in - if case .ended = session.phase { - endSidebarDrag() - return + .safeAreaInset(edge: .top) { + HStack(spacing: 4) { + CanvasSidebarButton( + store: store, + isSelected: state.isShowingCanvas + ) + ShelfSidebarButton( + store: store, + isSelected: state.isShowingShelf + ) + } + .padding(.top, 4) + .padding(.horizontal, 4) + .background(.bar) + .overlay(alignment: .bottom) { + Divider() + } } - if case .dataTransferCompleted = session.phase { - endSidebarDrag() + .safeAreaInset(edge: .bottom) { + SidebarFooterView(store: store) } - } - .safeAreaInset(edge: .top) { - HStack(spacing: 4) { - CanvasSidebarButton( - store: store, - isSelected: state.isShowingCanvas - ) - ShelfSidebarButton( - store: store, - isSelected: state.isShowingShelf - ) + .dropDestination(for: URL.self) { urls, _ in + let fileURLs = urls.filter(\.isFileURL) + guard !fileURLs.isEmpty else { return false } + store.send(.repositoryManagement(.openRepositories(fileURLs))) + return true } - .padding(.top, 4) - .padding(.horizontal, 4) - .background(.bar) - .overlay(alignment: .bottom) { - Divider() + .onAppear { + resetSidebarDrag() + } + .task(id: pendingSidebarReveal?.id) { + await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } - } - .safeAreaInset(edge: .bottom) { - SidebarFooterView(store: store) - } - .dropDestination(for: URL.self) { urls, _ in - let fileURLs = urls.filter(\.isFileURL) - guard !fileURLs.isEmpty else { return false } - store.send(.repositoryManagement(.openRepositories(fileURLs))) - return true - } - .onAppear { - resetSidebarDrag() - } - .task(id: pendingSidebarReveal?.id) { - await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } } // ScrollViewReader } diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index c24c3e70..ff9d8dde 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -30,13 +30,13 @@ func identifyAgent(processName: String) -> DetectedAgent? { } func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: String)? { - var best: (score: Int, agent: DetectedAgent, name: String)? + var best: AgentCandidate? for process in job.processes { for candidate in agentCandidates(for: process) { guard let agent = identifyAgent(processName: candidate.name) else { continue } if best == nil || candidate.score > best!.score { - best = (candidate.score, agent, candidate.name) + best = AgentCandidate(score: candidate.score, agent: agent, name: candidate.name) } } } @@ -65,6 +65,12 @@ private func agentCandidates(for process: ForegroundProcess) -> [(name: String, return candidates } +private struct AgentCandidate { + let score: Int + let agent: DetectedAgent + let name: String +} + private func normalizedProcessName(_ raw: String) -> String? { guard let basename = ProcessDetection.basename(raw) else { return nil } let lower = basename.lowercased() diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index a66308be..0a38f895 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -45,6 +45,12 @@ struct ActiveAgentsFeatureTests { } } + @Test func maximumPanelHeightKeepsRepositoryListVisible() { + #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 900) == 420) + #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 500) == 300) + #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) + } + private func entry(id: UUID, state: AgentDisplayState, changedAt: Date) -> ActiveAgentEntry { ActiveAgentEntry( id: id, -- 2.51.2 From 94c2f9fcb911e569d1a14e744d18e7c79304ca99 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:01:10 +0900 Subject: [PATCH 082/528] Refine agent display and state detection API --- doc-onevcat/active-agents-panel-task-log.md | 2 + .../Domain/AgentDetection/DetectedAgent.swift | 25 +----- .../Models/WorktreeTerminalState.swift | 2 +- .../AgentDetection/ScreenHeuristics.swift | 51 +++++++------ supacodeTests/DetectedAgentTests.swift | 11 +++ supacodeTests/ScreenHeuristicsTests.swift | 76 +++++++++---------- 6 files changed, 78 insertions(+), 89 deletions(-) create mode 100644 supacodeTests/DetectedAgentTests.swift diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 5f951471..138f8103 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -21,6 +21,8 @@ - `make sync-ghostty` fails under Xcode 26.4.1 before compiling Ghostty sources because Zig 0.15.2 cannot link the native build runner. Re-running with `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer` succeeds. - Added Active Agents reducer/UI wiring and terminal detection loop. `GhosttySurfaceBridge.childPID()` uses `dlsym` so the app still compiles before the patched GhosttyKit binary is rebuilt; after rebuild, the exported `ghostty_surface_pid` symbol is used automatically. - The Active Agents panel height is persisted globally but visually capped by the sidebar container height, reserving at least 200 pt for the repository list. +- Agent display names intentionally use short command-style lowercase tokens (`pi`, `claude`, `codex`, `kimi`, etc.) because the panel is a compact terminal-status surface, not product branding. +- Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. ### Verification diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index 1114f2dc..ccc9015c 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -17,30 +17,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { var id: String { rawValue } var displayName: String { - switch self { - case .pi: - return "Pi" - case .claude: - return "Claude" - case .codex: - return "Codex" - case .gemini: - return "Gemini" - case .cursor: - return "Cursor" - case .cline: - return "Cline" - case .opencode: - return "OpenCode" - case .copilot: - return "Copilot" - case .kimi: - return "Kimi" - case .droid: - return "Droid" - case .amp: - return "Amp" - } + rawValue } var iconLookupToken: String { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8d0c91d9..476b3de2 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1585,7 +1585,7 @@ final class WorktreeTerminalState { } let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) - let raw = detectState(agent: agent, screen: view.bridge.readViewportText() ?? "") + let raw = agent.detectState(in: view.bridge.readViewportText() ?? "") var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( agent: agent, diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 69c73489..32188859 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -1,30 +1,31 @@ import Foundation -func detectState(agent: DetectedAgent?, screen: String) -> AgentRawState { - guard let agent else { return .unknown } - switch agent { - case .pi: - return detectPi(screen) - case .claude: - return detectClaude(screen) - case .codex: - return detectCodex(screen) - case .gemini: - return detectGemini(screen) - case .cursor: - return detectCursor(screen) - case .cline: - return detectCline(screen) - case .opencode: - return detectOpenCode(screen) - case .copilot: - return detectCopilot(screen) - case .kimi: - return detectKimi(screen) - case .droid: - return detectDroid(screen) - case .amp: - return detectAmp(screen) +extension DetectedAgent { + func detectState(in screen: String) -> AgentRawState { + switch self { + case .pi: + return detectPi(screen) + case .claude: + return detectClaude(screen) + case .codex: + return detectCodex(screen) + case .gemini: + return detectGemini(screen) + case .cursor: + return detectCursor(screen) + case .cline: + return detectCline(screen) + case .opencode: + return detectOpenCode(screen) + case .copilot: + return detectCopilot(screen) + case .kimi: + return detectKimi(screen) + case .droid: + return detectDroid(screen) + case .amp: + return detectAmp(screen) + } } } diff --git a/supacodeTests/DetectedAgentTests.swift b/supacodeTests/DetectedAgentTests.swift new file mode 100644 index 00000000..c448abe5 --- /dev/null +++ b/supacodeTests/DetectedAgentTests.swift @@ -0,0 +1,11 @@ +import Testing + +@testable import supacode + +struct DetectedAgentTests { + @Test func displayNamesUseCommandStyleTokens() { + for agent in DetectedAgent.allCases { + #expect(agent.displayName == agent.rawValue) + } + } +} diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index a141a5d0..cc0cb138 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -4,19 +4,19 @@ import Testing struct ScreenHeuristicsTests { @Test func unknownAgentIsUnknown() { - #expect(detectState(agent: nil, screen: "Working...") == .unknown) + let agent: DetectedAgent? = nil + #expect(agent?.detectState(in: "Working...") ?? .unknown == .unknown) } @Test func piDetection() { - #expect(detectState(agent: .pi, screen: "Working...") == .working) - #expect(detectState(agent: .pi, screen: "Done") == .idle) + #expect(DetectedAgent.pi.detectState(in: "Working...") == .working) + #expect(DetectedAgent.pi.detectState(in: "Done") == .idle) } @Test func claudeDetection() { #expect( - detectState( - agent: .claude, - screen: """ + DetectedAgent.claude.detectState( + in: """ Reading file ✽ Tempering… ───────── @@ -26,9 +26,8 @@ struct ScreenHeuristicsTests { ) == .working ) #expect( - detectState( - agent: .claude, - screen: """ + DetectedAgent.claude.detectState( + in: """ Do you want to proceed? ❯ 1. Yes 2. No @@ -38,9 +37,8 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect( - detectState( - agent: .claude, - screen: """ + DetectedAgent.claude.detectState( + in: """ Task complete. ───────── ❯ @@ -51,42 +49,42 @@ struct ScreenHeuristicsTests { } @Test func codexDetection() { - #expect(detectState(agent: .codex, screen: "press enter to confirm or esc to cancel") == .blocked) - #expect(detectState(agent: .codex, screen: "• Working (12s)\nesc to interrupt") == .working) - #expect(detectState(agent: .codex, screen: "Ready for input") == .idle) + #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) + #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working) + #expect(DetectedAgent.codex.detectState(in: "Ready for input") == .idle) } @Test func otherAgentDetectorsCoverStateTriplets() { - #expect(detectState(agent: .gemini, screen: "│ Apply this change") == .blocked) - #expect(detectState(agent: .gemini, screen: "esc to cancel") == .working) - #expect(detectState(agent: .gemini, screen: "done") == .idle) + #expect(DetectedAgent.gemini.detectState(in: "│ Apply this change") == .blocked) + #expect(DetectedAgent.gemini.detectState(in: "esc to cancel") == .working) + #expect(DetectedAgent.gemini.detectState(in: "done") == .idle) - #expect(detectState(agent: .cursor, screen: "Run command? (y) (enter)") == .blocked) - #expect(detectState(agent: .cursor, screen: "⬡ indexing") == .working) - #expect(detectState(agent: .cursor, screen: "done") == .idle) + #expect(DetectedAgent.cursor.detectState(in: "Run command? (y) (enter)") == .blocked) + #expect(DetectedAgent.cursor.detectState(in: "⬡ indexing") == .working) + #expect(DetectedAgent.cursor.detectState(in: "done") == .idle) - #expect(detectState(agent: .cline, screen: "Let Cline use this tool? yes") == .blocked) - #expect(detectState(agent: .cline, screen: "Cline is ready for your message") == .idle) - #expect(detectState(agent: .cline, screen: "still processing") == .working) + #expect(DetectedAgent.cline.detectState(in: "Let Cline use this tool? yes") == .blocked) + #expect(DetectedAgent.cline.detectState(in: "Cline is ready for your message") == .idle) + #expect(DetectedAgent.cline.detectState(in: "still processing") == .working) - #expect(detectState(agent: .opencode, screen: "△ Permission required") == .blocked) - #expect(detectState(agent: .opencode, screen: "esc to interrupt") == .working) - #expect(detectState(agent: .opencode, screen: "done") == .idle) + #expect(DetectedAgent.opencode.detectState(in: "△ Permission required") == .blocked) + #expect(DetectedAgent.opencode.detectState(in: "esc to interrupt") == .working) + #expect(DetectedAgent.opencode.detectState(in: "done") == .idle) - #expect(detectState(agent: .copilot, screen: "│ do you want to run this?") == .blocked) - #expect(detectState(agent: .copilot, screen: "esc to cancel") == .working) - #expect(detectState(agent: .copilot, screen: "done") == .idle) + #expect(DetectedAgent.copilot.detectState(in: "│ do you want to run this?") == .blocked) + #expect(DetectedAgent.copilot.detectState(in: "esc to cancel") == .working) + #expect(DetectedAgent.copilot.detectState(in: "done") == .idle) - #expect(detectState(agent: .kimi, screen: "approve? [y/n]") == .blocked) - #expect(detectState(agent: .kimi, screen: "thinking") == .working) - #expect(detectState(agent: .kimi, screen: "done") == .idle) + #expect(DetectedAgent.kimi.detectState(in: "approve? [y/n]") == .blocked) + #expect(DetectedAgent.kimi.detectState(in: "thinking") == .working) + #expect(DetectedAgent.kimi.detectState(in: "done") == .idle) - #expect(detectState(agent: .droid, screen: "EXECUTE\nenter to select") == .blocked) - #expect(detectState(agent: .droid, screen: "⠋ esc to stop") == .working) - #expect(detectState(agent: .droid, screen: "done") == .idle) + #expect(DetectedAgent.droid.detectState(in: "EXECUTE\nenter to select") == .blocked) + #expect(DetectedAgent.droid.detectState(in: "⠋ esc to stop") == .working) + #expect(DetectedAgent.droid.detectState(in: "done") == .idle) - #expect(detectState(agent: .amp, screen: "waiting for approval\nallow all for this session") == .blocked) - #expect(detectState(agent: .amp, screen: "esc to cancel") == .working) - #expect(detectState(agent: .amp, screen: "done") == .idle) + #expect(DetectedAgent.amp.detectState(in: "waiting for approval\nallow all for this session") == .blocked) + #expect(DetectedAgent.amp.detectState(in: "esc to cancel") == .working) + #expect(DetectedAgent.amp.detectState(in: "done") == .idle) } } -- 2.51.2 From 02601437c09c4efbe95b668f126b7e20ab7af4c5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:02:54 +0900 Subject: [PATCH 083/528] Update active agents task log --- doc-onevcat/active-agents-panel-task-log.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 138f8103..923f551a 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -26,10 +26,10 @@ ### Verification -- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 18 tests. +- `xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/DetectedAgentTests -only-testing:supacodeTests/AgentClassifierTests -only-testing:supacodeTests/ScreenHeuristicsTests -only-testing:supacodeTests/PaneAgentStateTests -only-testing:supacodeTests/ActiveAgentsFeatureTests -only-testing:supacodeTests/ProcessDetectionSmokeTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation 2>&1 | xcsift -f toon -w` passed 19 tests. - `make check` passed after keeping the `pi` agent case name and disabling SwiftLint's `identifier_name` rule on that enum case only. - `xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" 2>&1 | xcsift -f toon -w` passed. - `DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty` completed successfully. - `nm -gU Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/libghostty.a | rg 'ghostty_surface_(pid|process_exited)'` finds both `_ghostty_surface_pid` and `_ghostty_surface_process_exited`. - `make build-app` completed successfully after building the app and embedded CLI. -- `make test` passed 1037 tests. It printed the same GhosttyKit preflight Zig link failure under Xcode 26.4.1, then continued to the Xcode test suite. +- `make test` passed 1038 tests with GhosttyKit already up-to-date. -- 2.51.2 From a7e28ee9b2de363306d1559a28741b9674c4e934 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:10:27 +0900 Subject: [PATCH 084/528] Fix active agents panel toggle icon --- doc-onevcat/active-agents-panel-task-log.md | 1 + .../Repositories/Views/SidebarFooterView.swift | 12 ++++++------ supacodeTests/SidebarFooterViewTests.swift | 10 ++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 supacodeTests/SidebarFooterViewTests.swift diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 923f551a..a7f49346 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -23,6 +23,7 @@ - The Active Agents panel height is persisted globally but visually capped by the sidebar container height, reserving at least 200 pt for the repository list. - Agent display names intentionally use short command-style lowercase tokens (`pi`, `claude`, `codex`, `kimi`, etc.) because the panel is a compact terminal-status surface, not product branding. - Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. +- The Active Agents footer toggle uses stable `eye` / `eye.slash` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. ### Verification diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 27970066..a7521a8f 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -39,12 +39,8 @@ struct SidebarFooterView: View { Button { store.send(.activeAgents(.togglePanelVisibility)) } label: { - Image( - systemName: store.state.activeAgents.isPanelHidden - ? "rectangle.bottomthird.inset" - : "rectangle.bottomthird.inset.filled" - ) - .accessibilityLabel(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") + Image(systemName: Self.activeAgentsPanelIconName(isPanelHidden: store.state.activeAgents.isPanelHidden)) + .accessibilityLabel(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") } .help(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") Button { @@ -101,4 +97,8 @@ struct SidebarFooterView: View { Divider() } } + + static func activeAgentsPanelIconName(isPanelHidden: Bool) -> String { + isPanelHidden ? "eye" : "eye.slash" + } } diff --git a/supacodeTests/SidebarFooterViewTests.swift b/supacodeTests/SidebarFooterViewTests.swift new file mode 100644 index 00000000..760d45d6 --- /dev/null +++ b/supacodeTests/SidebarFooterViewTests.swift @@ -0,0 +1,10 @@ +import Testing + +@testable import supacode + +struct SidebarFooterViewTests { + @Test func activeAgentsPanelToggleUsesStableSymbols() { + #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: true) == "eye") + #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: false) == "eye.slash") + } +} -- 2.51.2 From 85115416398a447662e9178b97f7b947e8dd6878 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:23:00 +0900 Subject: [PATCH 085/528] Add active agent detection diagnostics --- doc-onevcat/active-agents-panel-task-log.md | 1 + .../Models/WorktreeTerminalState.swift | 82 +++++++++++++++++-- .../Ghostty/GhosttySurfaceBridge.swift | 11 +-- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index a7f49346..172c9349 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -24,6 +24,7 @@ - Agent display names intentionally use short command-style lowercase tokens (`pi`, `claude`, `codex`, `kimi`, etc.) because the panel is a compact terminal-status surface, not product branding. - Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. - The Active Agents footer toggle uses stable `eye` / `eye.slash` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. +- Added DEBUG-only agent detection diagnostics for child PID lookup, foreground process group, candidate processes, identified/retained agent, raw screen state, and stabilized state after manual testing showed no agents appearing in the panel. ### Verification diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 476b3de2..4ef9c25c 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -7,6 +7,16 @@ import Sharing private let terminalStateLogger = SupaLogger("TerminalState") +private struct AgentDetectionDiagnostic { + let tabId: TerminalTabID + let childPID: pid_t? + let job: ForegroundJob? + let identified: (agent: DetectedAgent, name: String)? + let retainedAgent: DetectedAgent? + let raw: AgentRawState? + let stabilized: AgentRawState? +} + @MainActor @Observable final class WorktreeTerminalState { @@ -27,6 +37,7 @@ final class WorktreeTerminalState { private var agentDetectionTasks: [UUID: Task] = [:] private var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] private var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] + private var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] private var runScriptTabId: TerminalTabID? private var pendingSetupScript: Bool @@ -1552,6 +1563,10 @@ final class WorktreeTerminalState { private func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { agentDetectionTasks[view.id]?.cancel() surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) + terminalStateLogger.debug( + "agent detection started worktree=\(worktree.name) " + + "surface=\(view.id.uuidString.prefix(8)) tab=\(tabId.rawValue.uuidString.prefix(8))" + ) agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } @@ -1565,21 +1580,28 @@ final class WorktreeTerminalState { private func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) { let surfaceID = view.id let now = Date() - let probedAgent: DetectedAgent? - if let childPID = view.bridge.childPID(), - let job = ProcessDetection.foregroundJob(childPID: childPID), - let identified = identifyAgentInJob(job) - { - probedAgent = identified.agent - } else { - probedAgent = nil - } + let childPID = view.bridge.childPID() + let job = childPID.flatMap { ProcessDetection.foregroundJob(childPID: $0) } + let identified = job.flatMap { identifyAgentInJob($0) } + let probedAgent = identified?.agent var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() let agent = presence.update(detectedAgent: probedAgent) agentDetectionPresenceBySurface[surfaceID] = presence guard let agent else { + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + job: job, + identified: identified, + retainedAgent: nil, + raw: nil, + stabilized: nil + ) + ) removeAgentEntryIfNeeded(surfaceID: surfaceID) return } @@ -1616,6 +1638,18 @@ final class WorktreeTerminalState { seen: seen, lastChangedAt: lastChangedAt ) + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + job: job, + identified: identified, + retainedAgent: agent, + raw: raw, + stabilized: stabilized + ) + ) guard next != previous else { return } surfaceAgentStates[surfaceID] = next emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) @@ -1671,6 +1705,7 @@ final class WorktreeTerminalState { surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) + lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) onAgentEntryRemoved?(surfaceId) } @@ -1683,11 +1718,40 @@ final class WorktreeTerminalState { surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() lastClaudeWorkingAtBySurface.removeAll() + lastAgentDetectionDiagnosticsBySurface.removeAll() for id in removedIDs { onAgentEntryRemoved?(id) } } + private func agentDetectionDiagnosticMessage(_ diagnostic: AgentDetectionDiagnostic) -> String { + let processSummary = + diagnostic.job?.processes + .map { "\($0.pid):\($0.argv0 ?? $0.name)" } + .joined(separator: ",") ?? "none" + return [ + "tab=\(diagnostic.tabId.rawValue.uuidString.prefix(8))", + "childPID=\(diagnostic.childPID.map(String.init) ?? "nil")", + "fgPGID=\(diagnostic.job.map { String($0.processGroupID) } ?? "nil")", + "processes=\(processSummary)", + "identified=\(diagnostic.identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", + "retained=\(diagnostic.retainedAgent?.rawValue ?? "nil")", + "raw=\(diagnostic.raw?.rawValue ?? "nil")", + "state=\(diagnostic.stabilized?.rawValue ?? "nil")", + ].joined(separator: " ") + } + + private func logAgentDetectionDiagnostic(surfaceID: UUID, diagnostic: AgentDetectionDiagnostic) { + #if DEBUG + let message = agentDetectionDiagnosticMessage(diagnostic) + guard lastAgentDetectionDiagnosticsBySurface[surfaceID] != message else { return } + lastAgentDetectionDiagnosticsBySurface[surfaceID] = message + terminalStateLogger.debug( + "agent detection worktree=\(worktree.name) surface=\(surfaceID.uuidString.prefix(8)) \(message)" + ) + #endif + } + /// Heuristic shape-only detection for shell idle prompts. The /// bootstrap filter — before `awaitingIdleTitleLearning` has caught /// the precmd-set prompt at least once on this surface — for two diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index dfc023e4..481f6fd6 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -2,8 +2,6 @@ import AppKit import Foundation import GhosttyKit -private typealias GhosttySurfacePIDFunction = @convention(c) (ghostty_surface_t) -> Int32 - @MainActor final class GhosttySurfaceBridge { let state = GhosttySurfaceState() @@ -58,13 +56,8 @@ final class GhosttySurfaceBridge { } func childPID() -> pid_t? { - guard let surface, - let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "ghostty_surface_pid") - else { - return nil - } - let function = unsafeBitCast(symbol, to: GhosttySurfacePIDFunction.self) - let pid = function(surface) + guard let surface else { return nil } + let pid = ghostty_surface_pid(surface) return pid > 0 ? pid_t(pid) : nil } -- 2.51.2 From 46c5bf9da88330ab7779d8b09756aaa2e9089492 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:39:41 +0900 Subject: [PATCH 086/528] Use Ghostty foreground process group for agent detection --- ThirdParty/ghostty | 2 +- doc-onevcat/active-agents-panel-task-log.md | 1 + .../Features/Terminal/Models/WorktreeTerminalState.swift | 9 ++++++++- .../Infrastructure/AgentDetection/ProcessDetection.swift | 4 ++++ .../Infrastructure/Ghostty/GhosttySurfaceBridge.swift | 6 ++++++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ThirdParty/ghostty b/ThirdParty/ghostty index 76dce319..fe714860 160000 --- a/ThirdParty/ghostty +++ b/ThirdParty/ghostty @@ -1 +1 @@ -Subproject commit 76dce319f55db097b2b7ae3cad2f6267475936f0 +Subproject commit fe714860c12da41442b63135d09ba80e293b66ad diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 172c9349..6dcc65ce 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -25,6 +25,7 @@ - Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. - The Active Agents footer toggle uses stable `eye` / `eye.slash` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. - Added DEBUG-only agent detection diagnostics for child PID lookup, foreground process group, candidate processes, identified/retained agent, raw screen state, and stabilized state after manual testing showed no agents appearing in the panel. +- Added `ghostty_surface_foreground_process_group` to the Ghostty fork and switched Swift detection to prefer Ghostty's pty foreground process group over `proc_bsdinfo.e_tpgid`, which was nil for the shell PID during manual testing. ### Verification diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 4ef9c25c..2b0ef715 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -10,6 +10,7 @@ private let terminalStateLogger = SupaLogger("TerminalState") private struct AgentDetectionDiagnostic { let tabId: TerminalTabID let childPID: pid_t? + let processGroupID: pid_t? let job: ForegroundJob? let identified: (agent: DetectedAgent, name: String)? let retainedAgent: DetectedAgent? @@ -1581,7 +1582,10 @@ final class WorktreeTerminalState { let surfaceID = view.id let now = Date() let childPID = view.bridge.childPID() - let job = childPID.flatMap { ProcessDetection.foregroundJob(childPID: $0) } + let processGroupID = view.bridge.foregroundProcessGroupID() + let job = + processGroupID.flatMap { ProcessDetection.foregroundJob(processGroupID: $0) } + ?? childPID.flatMap { ProcessDetection.foregroundJob(childPID: $0) } let identified = job.flatMap { identifyAgentInJob($0) } let probedAgent = identified?.agent @@ -1595,6 +1599,7 @@ final class WorktreeTerminalState { diagnostic: AgentDetectionDiagnostic( tabId: tabId, childPID: childPID, + processGroupID: processGroupID, job: job, identified: identified, retainedAgent: nil, @@ -1643,6 +1648,7 @@ final class WorktreeTerminalState { diagnostic: AgentDetectionDiagnostic( tabId: tabId, childPID: childPID, + processGroupID: processGroupID, job: job, identified: identified, retainedAgent: agent, @@ -1732,6 +1738,7 @@ final class WorktreeTerminalState { return [ "tab=\(diagnostic.tabId.rawValue.uuidString.prefix(8))", "childPID=\(diagnostic.childPID.map(String.init) ?? "nil")", + "ptyPGID=\(diagnostic.processGroupID.map(String.init) ?? "nil")", "fgPGID=\(diagnostic.job.map { String($0.processGroupID) } ?? "nil")", "processes=\(processSummary)", "identified=\(diagnostic.identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift index aaeec75c..f816ff0a 100644 --- a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -18,7 +18,11 @@ enum ProcessDetection { guard childPID > 0, let processGroupID = foregroundProcessGroupID(pid: childPID) else { return nil } + return foregroundJob(processGroupID: processGroupID) + } + static func foregroundJob(processGroupID: pid_t) -> ForegroundJob? { + guard processGroupID > 0 else { return nil } var pids = [pid_t](repeating: 0, count: 4096) let bytes = pids.withUnsafeMutableBufferPointer { buffer in proc_listallpids(buffer.baseAddress, Int32(buffer.count * MemoryLayout.size)) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 481f6fd6..0905855d 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -61,6 +61,12 @@ final class GhosttySurfaceBridge { return pid > 0 ? pid_t(pid) : nil } + func foregroundProcessGroupID() -> pid_t? { + guard let surface else { return nil } + let processGroupID = ghostty_surface_foreground_process_group(surface) + return processGroupID > 0 ? pid_t(processGroupID) : nil + } + func closeSurface(processAlive: Bool) { onCloseRequest?(processAlive) } -- 2.51.2 From 5d797596269e3969576b4f028190175508430be9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 20:52:14 +0900 Subject: [PATCH 087/528] Limit agent state heuristics to recent terminal output --- .../AgentDetection/ScreenHeuristics.swift | 23 +++++- supacodeTests/ScreenHeuristicsTests.swift | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 32188859..0fee11e7 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -1,7 +1,10 @@ import Foundation +private let agentDetectionRecentLineLimit = 24 + extension DetectedAgent { func detectState(in screen: String) -> AgentRawState { + let screen = recentLines(screen, limit: agentDetectionRecentLineLimit) switch self { case .pi: return detectPi(screen) @@ -29,6 +32,12 @@ extension DetectedAgent { } } +private func recentLines(_ content: String, limit: Int) -> String { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard lines.count > limit else { return content } + return lines.suffix(limit).joined(separator: "\n") +} + private func detectPi(_ content: String) -> AgentRawState { content.contains("Working...") ? .working : .idle } @@ -39,7 +48,8 @@ private func detectClaude(_ content: String) -> AgentRawState { if content.contains("⌕ Search…") || lower.contains("ctrl+r to toggle") { return .idle } - if hasClaudeBlockedPrompt(content: content, lower: lower) { + let currentInteraction = claudeCurrentInteractionRegion(content) + if hasClaudeBlockedPrompt(content: currentInteraction, lower: currentInteraction.lowercased()) { return .blocked } @@ -207,6 +217,17 @@ private func isBoxBorderLine(_ line: String) -> Bool { return trimmed.allSatisfy { $0 == "─" || $0 == "-" } } +private func claudeCurrentInteractionRegion(_ content: String) -> String { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { + return lines.suffix(18).joined(separator: "\n") + } + + let lowerBound = max(lines.startIndex, promptIndex - 10) + let upperBound = min(lines.endIndex, promptIndex + 11) + return lines[lowerBound.. Bool { if lower.contains("do you want to proceed?") || lower.contains("waiting for permission") diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index cc0cb138..4271fcd8 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -48,6 +48,76 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeIgnoresStalePermissionPromptNearCurrentIdlePrompt() { + #expect( + DetectedAgent.claude.detectState( + in: """ + Do you want to proceed? + ❯ 1. Yes + 2. No + + Completed line 1 + Completed line 2 + Completed line 3 + Completed line 4 + Completed line 5 + Completed line 6 + Completed line 7 + Completed line 8 + Completed line 9 + Completed line 10 + Completed line 11 + Completed line 12 + Task complete. + ───────── + ❯ + ───────── + """ + ) == .idle + ) + } + + @Test func claudeIgnoresStalePermissionPromptOutsideRecentTail() { + #expect( + DetectedAgent.claude.detectState( + in: """ + Do you want to proceed? + ❯ 1. Yes + 2. No + + Completed line 1 + Completed line 2 + Completed line 3 + Completed line 4 + Completed line 5 + Completed line 6 + Completed line 7 + Completed line 8 + Completed line 9 + Completed line 10 + Completed line 11 + Completed line 12 + Completed line 13 + Completed line 14 + Completed line 15 + Completed line 16 + Completed line 17 + Completed line 18 + Completed line 19 + Completed line 20 + Completed line 21 + Completed line 22 + Completed line 23 + Completed line 24 + Task complete. + ───────── + ❯ + ───────── + """ + ) == .idle + ) + } + @Test func codexDetection() { #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working) -- 2.51.2 From ccae09516abfca50b9e18882d912832d5d61e4ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 21:06:12 +0900 Subject: [PATCH 088/528] Detect Kimi Code process name --- supacode/Infrastructure/AgentDetection/AgentClassifier.swift | 2 +- supacodeTests/AgentClassifierTests.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index ff9d8dde..90b6cde5 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -18,7 +18,7 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .opencode case "copilot", "github-copilot", "ghcs": return .copilot - case "kimi": + case "kimi", "kimi code": return .kimi case "droid": return .droid diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index d0ee7a31..f1090cb8 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -16,6 +16,7 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "github-copilot") == .copilot) #expect(identifyAgent(processName: "ghcs") == .copilot) #expect(identifyAgent(processName: "kimi") == .kimi) + #expect(identifyAgent(processName: "Kimi Code") == .kimi) #expect(identifyAgent(processName: "droid") == .droid) #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) -- 2.51.2 From fafdc11957cc856a7393db2249c203c7593fed6d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 21:16:53 +0900 Subject: [PATCH 089/528] Detect Kimi working and approval states --- .../AgentDetection/ScreenHeuristics.swift | 30 +++++++++++++++++-- supacodeTests/ScreenHeuristicsTests.swift | 19 ++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 0fee11e7..bdc851cb 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -160,14 +160,20 @@ private func detectKimi(_ content: String) -> AgentRawState { let blockedPatterns = [ "allow?", "confirm?", "approve?", "proceed?", "[y/n]", "(y/n)", ] - if blockedPatterns.contains(where: lower.contains) || hasConfirmationPrompt(lower) { + if blockedPatterns.contains(where: lower.contains) + || hasConfirmationPrompt(lower) + || hasKimiApprovalPanel(content: content, lower: lower) + { return .blocked } let workingPatterns = [ "thinking", "processing", "generating", "waiting for response", "ctrl+c to cancel", ] - if workingPatterns.contains(where: lower.contains) { + if workingPatterns.contains(where: lower.contains) + || hasKimiMoonSpinner(content) + || hasKimiToolSpinner(content: content, lower: lower) + { return .working } return .idle @@ -250,6 +256,26 @@ private func hasSelectionPrompt(_ content: String) -> Bool { } } +private func hasKimiApprovalPanel(content: String, lower: String) -> Bool { + lower.contains("requesting approval") + || (lower.contains("approve once") && lower.contains("approve for this session") && lower.contains("reject")) + || (content.contains("─ approval") && content.contains("↵ confirm")) +} + +private func hasKimiMoonSpinner(_ content: String) -> Bool { + let moonSpinners: Set = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] + return content.contains { moonSpinners.contains($0) } +} + +private func hasKimiToolSpinner(content: String, lower: String) -> Bool { + guard lower.contains("using ") else { return false } + return content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.unicodeScalars.first else { return false } + return (0x2800...0x28FF).contains(Int(first.value)) + } +} + private func hasConfirmationPrompt(_ lower: String) -> Bool { (lower.contains("allow") || lower.contains("approve") || lower.contains("confirm") || lower.contains("proceed")) && (lower.contains("[y/n]") || lower.contains("(y/n)") || lower.contains(" yes") || lower.contains(" yes (y)")) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 4271fcd8..4856366a 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -147,6 +147,25 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.kimi.detectState(in: "approve? [y/n]") == .blocked) #expect(DetectedAgent.kimi.detectState(in: "thinking") == .working) + #expect(DetectedAgent.kimi.detectState(in: "🌘") == .working) + #expect(DetectedAgent.kimi.detectState(in: "⠸ Using Shell (git status)") == .working) + #expect( + DetectedAgent.kimi.detectState( + in: """ + ⠸ Using Shell (git remote -v && echo "--..." && git log --oneline -3) + ╭─ approval ─────────────────────────────────────────────────────────╮ + │ Shell is requesting approval to run command: │ + │ │ + │ → [1] Approve once │ + │ [2] Approve for this session │ + │ [3] Reject │ + │ [4] Reject, tell the model what to do instead │ + │ │ + │ ▲/▼ select 1/2/3/4 choose ↵ confirm │ + ╰────────────────────────────────────────────────────────────────────╯ + """ + ) == .blocked + ) #expect(DetectedAgent.kimi.detectState(in: "done") == .idle) #expect(DetectedAgent.droid.detectState(in: "EXECUTE\nenter to select") == .blocked) -- 2.51.2 From 217f620603573920b36a318d067e1249758af278 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 22:04:13 +0900 Subject: [PATCH 090/528] Ignore terminal padding in agent state heuristics --- .../Infrastructure/AgentDetection/ScreenHeuristics.swift | 7 +++++-- supacodeTests/ScreenHeuristicsTests.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index bdc851cb..c5faf0d1 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -33,8 +33,11 @@ extension DetectedAgent { } private func recentLines(_ content: String, limit: Int) -> String { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard lines.count > limit else { return content } + var lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + while lines.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { + lines.removeLast() + } + guard lines.count > limit else { return lines.joined(separator: "\n") } return lines.suffix(limit).joined(separator: "\n") } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 4856366a..e7b06ba2 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -149,6 +149,14 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.kimi.detectState(in: "thinking") == .working) #expect(DetectedAgent.kimi.detectState(in: "🌘") == .working) #expect(DetectedAgent.kimi.detectState(in: "⠸ Using Shell (git status)") == .working) + #expect( + DetectedAgent.kimi.detectState( + in: """ + ⠋ Using Shell (git remote -v) + \(String(repeating: "\n", count: 40)) + """ + ) == .working + ) #expect( DetectedAgent.kimi.detectState( in: """ @@ -163,6 +171,7 @@ struct ScreenHeuristicsTests { │ │ │ ▲/▼ select 1/2/3/4 choose ↵ confirm │ ╰────────────────────────────────────────────────────────────────────╯ + \(String(repeating: "\n", count: 40)) """ ) == .blocked ) -- 2.51.2 From 0867993dfcb0189e9cdcbea30f7b2ad83b8ec3dc Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 22:12:22 +0900 Subject: [PATCH 091/528] Count nonblank lines in agent state snapshots --- .../AgentDetection/ScreenHeuristics.swift | 20 ++++++++++++++----- supacodeTests/ScreenHeuristicsTests.swift | 17 ++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index c5faf0d1..0c8d2f9a 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -33,12 +33,22 @@ extension DetectedAgent { } private func recentLines(_ content: String, limit: Int) -> String { - var lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - while lines.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { - lines.removeLast() + let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var remainingNonBlankLines = limit + var startIndex = lines.startIndex + + for index in lines.indices.reversed() { + guard !lines[index].trimmingCharacters(in: .whitespaces).isEmpty else { + continue + } + remainingNonBlankLines -= 1 + if remainingNonBlankLines == 0 { + startIndex = index + break + } } - guard lines.count > limit else { return lines.joined(separator: "\n") } - return lines.suffix(limit).joined(separator: "\n") + + return lines[startIndex...].joined(separator: "\n") } private func detectPi(_ content: String) -> AgentRawState { diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index e7b06ba2..d9bfd038 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -154,9 +154,23 @@ struct ScreenHeuristicsTests { in: """ ⠋ Using Shell (git remote -v) \(String(repeating: "\n", count: 40)) + ───────────────────────────────────────────────────────────────────── + agent (kimi-k2.5 ●) ~/Sync/github/Prowl feat/active-agents-pa… ctrl-o: editor + context: 6.5% (17k/262.1k) """ ) == .working ) + #expect( + DetectedAgent.kimi.detectState( + in: """ + ── input ──────────────────────────────────────────────────────────── + \(String(repeating: "\n", count: 40)) + ───────────────────────────────────────────────────────────────────── + agent (kimi-k2.5 ●) ~/Sync/github/Prowl feat/active-agents-pa… ctrl-o: editor + context: 6.5% (17k/262.1k) + """ + ) == .idle + ) #expect( DetectedAgent.kimi.detectState( in: """ @@ -172,6 +186,9 @@ struct ScreenHeuristicsTests { │ ▲/▼ select 1/2/3/4 choose ↵ confirm │ ╰────────────────────────────────────────────────────────────────────╯ \(String(repeating: "\n", count: 40)) + ───────────────────────────────────────────────────────────────────── + agent (kimi-k2.5 ●) ~/Sync/github/Prowl feat/active-agents-pa… ctrl-o: editor + context: 6.5% (17k/262.1k) """ ) == .blocked ) -- 2.51.2 From c240e427364a80348d64c049cab187de823407e4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 22:21:13 +0900 Subject: [PATCH 092/528] Keep sidebar safe area chrome stable --- .../Repositories/Views/SidebarListView.swift | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index e88042c0..a5100671 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -38,6 +38,7 @@ struct SidebarListView: View { @State private var isDragActive = false @State private var draggingRepositoryID: Repository.ID? @State private var targetedRepositoryDropDestination: Int? + @State private var sidebarHeight = 0.0 var body: some View { let state = store.state @@ -58,96 +59,98 @@ struct SidebarListView: View { let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let pendingSidebarReveal = state.pendingSidebarReveal + let maximumPanelHeight = ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: sidebarHeight) + ScrollViewReader { scrollProxy in - GeometryReader { proxy in - let maximumPanelHeight = ActiveAgentsFeature.maximumPanelHeight( - forContainerHeight: Double(proxy.size.height) - ) - VStack(spacing: 0) { - ScrollView { - LazyVStack(spacing: 0) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } - - if repositoryItems.isEmpty { - emptyRepositoryHint() - } - - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) - } + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: 0) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } + + if repositoryItems.isEmpty { + emptyRepositoryHint() + } + + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) } - .padding(.vertical, 2) - } - .scrollIndicators(.never) - .frame(maxHeight: .infinity) - - if !state.activeAgents.isPanelHidden { - ActiveAgentsPanel( - store: store.scope(state: \.activeAgents, action: \.activeAgents), - maximumHeight: maximumPanelHeight - ) - .frame(height: min(state.activeAgents.panelHeight, maximumPanelHeight)) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .frame(minWidth: 220) - .background(.bar) - .clipped() - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) - .onDragSessionUpdated { session in - if case .ended = session.phase { - endSidebarDrag() - return - } - if case .dataTransferCompleted = session.phase { - endSidebarDrag() } + .padding(.vertical, 2) } - .safeAreaInset(edge: .top) { - HStack(spacing: 4) { - CanvasSidebarButton( - store: store, - isSelected: state.isShowingCanvas - ) - ShelfSidebarButton( - store: store, - isSelected: state.isShowingShelf - ) - } - .padding(.top, 4) - .padding(.horizontal, 4) - .background(.bar) - .overlay(alignment: .bottom) { - Divider() - } + .scrollIndicators(.never) + .frame(maxHeight: .infinity) + + if !state.activeAgents.isPanelHidden { + ActiveAgentsPanel( + store: store.scope(state: \.activeAgents, action: \.activeAgents), + maximumHeight: maximumPanelHeight + ) + .frame(height: min(state.activeAgents.panelHeight, maximumPanelHeight)) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .safeAreaInset(edge: .bottom) { - SidebarFooterView(store: store) + } + .frame(minWidth: 220) + .background(.bar) + .clipped() + .onGeometryChange(for: Double.self) { proxy in + Double(proxy.size.height) + } action: { newHeight in + sidebarHeight = newHeight + } + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) + .onDragSessionUpdated { session in + if case .ended = session.phase { + endSidebarDrag() + return } - .dropDestination(for: URL.self) { urls, _ in - let fileURLs = urls.filter(\.isFileURL) - guard !fileURLs.isEmpty else { return false } - store.send(.repositoryManagement(.openRepositories(fileURLs))) - return true + if case .dataTransferCompleted = session.phase { + endSidebarDrag() } - .onAppear { - resetSidebarDrag() + } + .safeAreaInset(edge: .top) { + HStack(spacing: 4) { + CanvasSidebarButton( + store: store, + isSelected: state.isShowingCanvas + ) + ShelfSidebarButton( + store: store, + isSelected: state.isShowingShelf + ) } - .task(id: pendingSidebarReveal?.id) { - await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) + .padding(.top, 4) + .padding(.horizontal, 4) + .background(.bar) + .overlay(alignment: .bottom) { + Divider() } } + .safeAreaInset(edge: .bottom) { + SidebarFooterView(store: store) + } + .dropDestination(for: URL.self) { urls, _ in + let fileURLs = urls.filter(\.isFileURL) + guard !fileURLs.isEmpty else { return false } + store.send(.repositoryManagement(.openRepositories(fileURLs))) + return true + } + .onAppear { + resetSidebarDrag() + } + .task(id: pendingSidebarReveal?.id) { + await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) + } } // ScrollViewReader } -- 2.51.2 From 8bc67fce543c859146df5aa34c47dbe081d3290d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 22:46:34 +0900 Subject: [PATCH 093/528] Fix active agents panel motion --- .../Views/ActiveAgentsPanel.swift | 19 +++- .../Views/SidebarFooterView.swift | 4 +- .../Repositories/Views/SidebarListView.swift | 98 ++++++++++++------- 3 files changed, 78 insertions(+), 43 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index ac0bbb41..9fa36e18 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -3,7 +3,10 @@ import SwiftUI struct ActiveAgentsPanel: View { @Bindable var store: StoreOf + let height: Double let maximumHeight: Double + let onHeightChanged: (Double) -> Void + let onHeightChangeEnded: (Double) -> Void @State private var dragStartHeight: Double? var body: some View { @@ -60,15 +63,17 @@ struct ActiveAgentsPanel: View { .contentShape(.rect) } .gesture( - DragGesture() + DragGesture(coordinateSpace: .global) .onChanged { value in - let start = dragStartHeight ?? store.panelHeight + let start = dragStartHeight ?? height dragStartHeight = start - let height = ActiveAgentsFeature.clampedPanelHeight(start - value.translation.height) - store.send(.panelHeightChanged(min(maximumHeight, height))) + onHeightChanged(clampedHeight(start - value.translation.height)) } - .onEnded { _ in + .onEnded { value in + let start = dragStartHeight ?? height + let height = clampedHeight(start - value.translation.height) dragStartHeight = nil + onHeightChangeEnded(height) } ) .onHover { hovering in @@ -79,4 +84,8 @@ struct ActiveAgentsPanel: View { } } } + + private func clampedHeight(_ height: Double) -> Double { + min(maximumHeight, max(ActiveAgentsFeature.minimumPanelHeight, height)) + } } diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index a7521a8f..ba69d95f 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -37,7 +37,9 @@ struct SidebarFooterView: View { .help("Help") Spacer() Button { - store.send(.activeAgents(.togglePanelVisibility)) + withAnimation(.easeOut(duration: 0.18)) { + _ = store.send(.activeAgents(.togglePanelVisibility)) + } } label: { Image(systemName: Self.activeAgentsPanelIconName(isPanelHidden: store.state.activeAgents.isPanelHidden)) .accessibilityLabel(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index a5100671..489f4928 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -39,6 +39,8 @@ struct SidebarListView: View { @State private var draggingRepositoryID: Repository.ID? @State private var targetedRepositoryDropDestination: Int? @State private var sidebarHeight = 0.0 + @State private var sidebarFooterHeight = 0.0 + @State private var resizingPanelHeight: Double? var body: some View { let state = store.state @@ -59,47 +61,42 @@ struct SidebarListView: View { let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let pendingSidebarReveal = state.pendingSidebarReveal - let maximumPanelHeight = ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: sidebarHeight) + let maximumPanelHeight = + sidebarHeight > 0 + ? ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: sidebarHeight) + : ActiveAgentsFeature.maximumPanelHeight + let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) + let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 + let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight ScrollViewReader { scrollProxy in - VStack(spacing: 0) { - ScrollView { - LazyVStack(spacing: 0) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } - - if repositoryItems.isEmpty { - emptyRepositoryHint() - } + ScrollView { + LazyVStack(spacing: 0) { + if showsRepositoryListHeader { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) - } + if repositoryItems.isEmpty { + emptyRepositoryHint() } - .padding(.vertical, 2) - } - .scrollIndicators(.never) - .frame(maxHeight: .infinity) - if !state.activeAgents.isPanelHidden { - ActiveAgentsPanel( - store: store.scope(state: \.activeAgents, action: \.activeAgents), - maximumHeight: maximumPanelHeight - ) - .frame(height: min(state.activeAgents.panelHeight, maximumPanelHeight)) - .transition(.move(edge: .bottom).combined(with: .opacity)) + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + } } + .padding(.vertical, 2) + .padding(.bottom, listBottomPadding) } + .scrollIndicators(.never) .frame(minWidth: 220) .background(.bar) .clipped() @@ -108,7 +105,6 @@ struct SidebarListView: View { } action: { newHeight in sidebarHeight = newHeight } - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: state.activeAgents.isPanelHidden) .onDragSessionUpdated { session in if case .ended = session.phase { endSidebarDrag() @@ -118,7 +114,7 @@ struct SidebarListView: View { endSidebarDrag() } } - .safeAreaInset(edge: .top) { + .safeAreaInset(edge: .top, spacing: 0) { HStack(spacing: 4) { CanvasSidebarButton( store: store, @@ -136,8 +132,36 @@ struct SidebarListView: View { Divider() } } - .safeAreaInset(edge: .bottom) { + .safeAreaInset(edge: .bottom, spacing: 0) { SidebarFooterView(store: store) + .onGeometryChange(for: Double.self) { proxy in + Double(proxy.size.height) + } action: { newHeight in + sidebarFooterHeight = newHeight + } + } + .overlay(alignment: .bottom) { + ZStack(alignment: .bottom) { + ActiveAgentsPanel( + store: store.scope(state: \.activeAgents, action: \.activeAgents), + height: panelHeight, + maximumHeight: maximumPanelHeight, + onHeightChanged: { height in + resizingPanelHeight = height + }, + onHeightChangeEnded: { height in + resizingPanelHeight = nil + store.send(.activeAgents(.panelHeightChanged(height))) + } + ) + .frame(height: panelHeight) + .offset(y: panelOffset) + } + .frame(height: panelHeight) + .clipped() + .padding(.bottom, sidebarFooterHeight) + .allowsHitTesting(!state.activeAgents.isPanelHidden) + .animation(.easeOut(duration: 0.18), value: state.activeAgents.isPanelHidden) } .dropDestination(for: URL.self) { urls, _ in let fileURLs = urls.filter(\.isFileURL) -- 2.51.2 From 4d277b033fbecd06aec470af56f9cd9e59c399e1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 22:50:52 +0900 Subject: [PATCH 094/528] Polish active agents panel background --- .../Views/ActiveAgentsPanel.swift | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 9fa36e18..3fd01a69 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -45,17 +45,28 @@ struct ActiveAgentsPanel: View { .scrollIndicators(.never) } } - .background(.bar) - .overlay(alignment: .top) { - Divider() + .background { + panelBackgroundShape + .fill(.thinMaterial) + } + .clipShape(panelBackgroundShape) + .overlay { + panelBackgroundShape + .stroke(.separator.opacity(0.7), lineWidth: 1) } } private var resizeHandle: some View { Rectangle() - .fill(.separator) + .fill(.clear) .frame(height: 1) .frame(maxWidth: .infinity) + .overlay(alignment: .top) { + Rectangle() + .fill(.separator.opacity(0.7)) + .frame(height: 1) + .padding(.horizontal, 8) + } .overlay { Rectangle() .fill(.clear) @@ -88,4 +99,16 @@ struct ActiveAgentsPanel: View { private func clampedHeight(_ height: Double) -> Double { min(maximumHeight, max(ActiveAgentsFeature.minimumPanelHeight, height)) } + + private var panelBackgroundShape: UnevenRoundedRectangle { + UnevenRoundedRectangle( + cornerRadii: .init( + topLeading: 8, + bottomLeading: 0, + bottomTrailing: 0, + topTrailing: 8 + ), + style: .continuous + ) + } } -- 2.51.2 From 1438549cff9b015115739361bdfa065d26833412 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 23:21:52 +0900 Subject: [PATCH 095/528] Update active agent row metadata --- .../Models/ActiveAgentEntry.swift | 4 -- .../ActiveAgents/Views/ActiveAgentRow.swift | 6 ++- .../Views/ActiveAgentsPanel.swift | 18 +++++++- .../Repositories/Views/SidebarListView.swift | 31 +++++++++++++ .../RepositorySectionViewTests.swift | 44 +++++++++++++++++++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index ec75f295..e823c875 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -12,10 +12,6 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { let rawState: AgentRawState let displayState: AgentDisplayState let lastChangedAt: Date - - var subtitle: String { - "\(worktreeName) · tab \(tabTitle) · pane \(paneIndex)" - } } extension ActiveAgentEntry { diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 8a826004..87d70df2 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -3,15 +3,17 @@ import SwiftUI struct ActiveAgentRow: View { let entry: ActiveAgentEntry + let repositoryName: String + let branchName: String var body: some View { HStack(spacing: 8) { agentIcon VStack(alignment: .leading, spacing: 2) { - Text(entry.agent.displayName) + Text("\(entry.agent.displayName) · \(repositoryName)") .font(.body.monospaced()) .lineLimit(1) - Text(entry.subtitle) + Text(branchName) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 3fd01a69..b0a95d28 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -3,6 +3,8 @@ import SwiftUI struct ActiveAgentsPanel: View { @Bindable var store: StoreOf + let repositoryNamesByWorktreeID: [Worktree.ID: String] + let branchNamesByWorktreeID: [Worktree.ID: String] let height: Double let maximumHeight: Double let onHeightChanged: (Double) -> Void @@ -35,10 +37,14 @@ struct ActiveAgentsPanel: View { Button { store.send(.entryTapped(entry.id)) } label: { - ActiveAgentRow(entry: entry) + ActiveAgentRow( + entry: entry, + repositoryName: repositoryName(for: entry), + branchName: branchName(for: entry) + ) } .buttonStyle(.plain) - .help("Focus \(entry.agent.displayName) in \(entry.worktreeName)") + .help("Focus \(entry.agent.displayName) in \(repositoryName(for: entry))") } } } @@ -100,6 +106,14 @@ struct ActiveAgentsPanel: View { min(maximumHeight, max(ActiveAgentsFeature.minimumPanelHeight, height)) } + private func repositoryName(for entry: ActiveAgentEntry) -> String { + repositoryNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName + } + + private func branchName(for entry: ActiveAgentEntry) -> String { + branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName + } + private var panelBackgroundShape: UnevenRoundedRectangle { UnevenRoundedRectangle( cornerRadii: .init( diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 489f4928..16cb13fd 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -65,6 +65,10 @@ struct SidebarListView: View { sidebarHeight > 0 ? ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: sidebarHeight) : ActiveAgentsFeature.maximumPanelHeight + let agentWorktreeMetadata = Self.activeAgentWorktreeMetadata( + repositories: state.repositories, + customTitles: state.repositoryCustomTitles + ) let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight @@ -144,6 +148,8 @@ struct SidebarListView: View { ZStack(alignment: .bottom) { ActiveAgentsPanel( store: store.scope(state: \.activeAgents, action: \.activeAgents), + repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, + branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, height: panelHeight, maximumHeight: maximumPanelHeight, onHeightChanged: { height in @@ -426,6 +432,31 @@ struct SidebarListView: View { } return selectedWorktreeIDs } + + static func activeAgentWorktreeMetadata( + repositories: IdentifiedArrayOf, + customTitles: [Repository.ID: String] + ) -> ( + repositoryNamesByWorktreeID: [Worktree.ID: String], + branchNamesByWorktreeID: [Worktree.ID: String] + ) { + var repositoryNamesByWorktreeID: [Worktree.ID: String] = [:] + var branchNamesByWorktreeID: [Worktree.ID: String] = [:] + + for repository in repositories { + let repositoryName = customTitles[repository.id] ?? repository.name + if repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees { + repositoryNamesByWorktreeID[repository.id] = repositoryName + branchNamesByWorktreeID[repository.id] = repository.name + } + for worktree in repository.worktrees { + repositoryNamesByWorktreeID[worktree.id] = repositoryName + branchNamesByWorktreeID[worktree.id] = worktree.name + } + } + + return (repositoryNamesByWorktreeID, branchNamesByWorktreeID) + } } extension SidebarItem { diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index cbdd7032..720fb0db 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -91,6 +91,50 @@ struct RepositorySectionViewTests { #expect(SidebarListView.selectedWorktreeIDs(in: state) == [worktree.id]) } + @Test func activeAgentWorktreeMetadataUsesCustomRepositoryTitleAndCurrentBranchName() { + let repositoryRootURL = URL(fileURLWithPath: "/tmp/prowl") + let worktree = Worktree( + id: "/tmp/prowl/worktrees/active-agents", + name: "feat/active-agents-panel", + detail: "active-agents", + workingDirectory: URL(fileURLWithPath: "/tmp/prowl/worktrees/active-agents"), + repositoryRootURL: repositoryRootURL + ) + let repository = Repository( + id: "/tmp/prowl", + rootURL: repositoryRootURL, + name: "supacode", + kind: .git, + worktrees: [worktree] + ) + + let metadata = SidebarListView.activeAgentWorktreeMetadata( + repositories: [repository], + customTitles: [repository.id: "Prowl"] + ) + + #expect(metadata.repositoryNamesByWorktreeID[worktree.id] == "Prowl") + #expect(metadata.branchNamesByWorktreeID[worktree.id] == "feat/active-agents-panel") + } + + @Test func activeAgentWorktreeMetadataFallsBackToRepositoryNameForPlainFolders() { + let repository = Repository( + id: "/tmp/plain", + rootURL: URL(fileURLWithPath: "/tmp/plain"), + name: "plain", + kind: .plain, + worktrees: [] + ) + + let metadata = SidebarListView.activeAgentWorktreeMetadata( + repositories: [repository], + customTitles: [:] + ) + + #expect(metadata.repositoryNamesByWorktreeID[repository.id] == "plain") + #expect(metadata.branchNamesByWorktreeID[repository.id] == "plain") + } + @Test func openTabCountForGitRepositorySumsAllWorktrees() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo") -- 2.51.2 From 4ea721f46e9d585bd009580b556b3589ccbf7afc Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 23:47:25 +0900 Subject: [PATCH 096/528] Align Claude blocked detection with herdr --- .../AgentDetection/ScreenHeuristics.swift | 41 ++++++++++++++----- supacodeTests/ScreenHeuristicsTests.swift | 31 ++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 0c8d2f9a..28d795eb 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -249,24 +249,45 @@ private func claudeCurrentInteractionRegion(_ content: String) -> String { private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { if lower.contains("do you want to proceed?") + || lower.contains("would you like to proceed?") || lower.contains("waiting for permission") || lower.contains("do you want to allow this connection?") + || lower.contains("tab to amend") + || lower.contains("ctrl+e to explain") + || lower.contains("chat about this") + || lower.contains("review your answers") + || lower.contains("skip interview and plan immediately") { return true } - if (lower.contains("do you want") || lower.contains("would you like")) - && (lower.contains("yes") || content.contains("❯")) - { - return true + return hasConfirmationPrompt(lower) + || (hasClaudeSelectionPrompt(content) && hasClaudeYesNoChoice(content)) +} + +private func hasClaudeSelectionPrompt(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("❯") + && trimmed.contains(".") + && trimmed.contains(where: \.isNumber) } - return hasSelectionPrompt(content) && (lower.contains("yes") || lower.contains("no")) } -private func hasSelectionPrompt(_ content: String) -> Bool { - content.contains("❯") - || content.split(separator: "\n").contains { line in - line.trimmingCharacters(in: .whitespaces).hasPrefix("1.") - } +private func hasClaudeYesNoChoice(_ content: String) -> Bool { + content.split(separator: "\n").contains { line in + let line = line.trimmingCharacters(in: .whitespaces) + let option = + line.hasPrefix("❯") + ? String(line.dropFirst()).trimmingCharacters(in: .whitespaces) + : line + let trimmed = option.lowercased() + return trimmed == "yes" + || trimmed == "no" + || trimmed.hasPrefix("1. yes") + || trimmed.hasPrefix("2. no") + || trimmed.hasPrefix("yes, and ") + || trimmed.hasPrefix("no, and tell claude") + } } private func hasKimiApprovalPanel(content: String, lower: String) -> Bool { diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index d9bfd038..360375b8 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -118,6 +118,37 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { + #expect( + DetectedAgent.claude.detectState( + in: """ + ✻ Crunched for 10s + + ❯ 切一下fix/nocilla-thread-safe-stubs + + ⏺ Bash(git checkout fix/nocilla-thread-safe-stubs) + ⎿ 切换到分支 'fix/nocilla-thread-safe-stubs' + 您的分支基于 'origin/fix/nocilla-thread-safe-stubs',但此上游分支已经不存在。 + (使用 "git branch --unset-upstream" 来修复) + + ⏺ 喵~切过来了!不过有个小提醒喵: + + - 当前分支:fix/nocilla-thread-safe-stubs ✅ + - ⚠️ 上游 origin/fix/nocilla-thread-safe-stubs 已经不存在了喵 + + 要不要喵帮忙处理一下?可选: + 1. git branch --unset-upstream —— 解除失效的上游绑定喵 + 2. 看一下这个分支跟 master 的差异,确认是否还需要保留 + 3. 如果确认没用了,可以切回 master 后删除喵 + + 主子想怎么处理喵? + + ✻ Churned for 8s + """ + ) == .idle + ) + } + @Test func codexDetection() { #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working) -- 2.51.2 From d494d3fb3f9a4da294b0290d27e4c1c1b84c8afd Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 9 May 2026 23:53:31 +0900 Subject: [PATCH 097/528] Align agent heuristics with herdr --- .../AgentDetection/ScreenHeuristics.swift | 85 ++++++++++++++----- supacodeTests/ScreenHeuristicsTests.swift | 24 +++++- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 28d795eb..dd5df635 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -139,16 +139,12 @@ private func detectCline(_ content: String) -> AgentRawState { } private func detectOpenCode(_ content: String) -> AgentRawState { - let lower = content.lowercased() if content.contains("△ Permission required") - || (lower.contains("↑↓ select") - && (lower.contains("enter confirm") || lower.contains("enter submit") || lower.contains("enter toggle")) - && lower.contains("esc dismiss")) - || hasConfirmationPrompt(lower) + || hasOpenCodeQuestionPrompt(content) { return .blocked } - if lower.contains("esc to interrupt") { + if hasInterruptPattern(content.lowercased()) { return .working } return .idle @@ -158,7 +154,6 @@ private func detectCopilot(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("│ do you want") || (lower.contains("confirm with") && lower.contains("enter")) - || hasConfirmationPrompt(lower) { return .blocked } @@ -181,7 +176,7 @@ private func detectKimi(_ content: String) -> AgentRawState { } let workingPatterns = [ - "thinking", "processing", "generating", "waiting for response", "ctrl+c to cancel", + "thinking", "processing", "generating", "waiting for response", "ctrl+c to cancel", "ctrl-c to cancel", ] if workingPatterns.contains(where: lower.contains) || hasKimiMoonSpinner(content) @@ -194,12 +189,22 @@ private func detectKimi(_ content: String) -> AgentRawState { private func detectDroid(_ content: String) -> AgentRawState { let lower = content.lowercased() - if content.contains("EXECUTE") - && (lower.contains("enter to select") || lower.contains("↑↓ to navigate")) - { + let hasExecute = content.contains("EXECUTE") + let hasSelectionChrome = + lower.contains("enter to select") + || lower.contains("↑↓ to navigate") + || lower.contains("esc to cancel") + let hasSelectionOptions = + lower.contains("> yes, allow") + || lower.contains("> no, cancel") + + if hasExecute && (hasSelectionChrome || hasSelectionOptions) { + return .blocked + } + if hasSelectionChrome && hasSelectionOptions { return .blocked } - if hasDroidSpinner(content) { + if hasDroidSpinner(content) || lower.contains("esc to stop") { return .working } return .idle @@ -207,11 +212,21 @@ private func detectDroid(_ content: String) -> AgentRawState { private func detectAmp(_ content: String) -> AgentRawState { let lower = content.lowercased() - if (lower.contains("waiting for approval") - || lower.contains("invoke tool") - || lower.contains("run this command?")) - && (lower.contains("approve") || lower.contains("allow all for this session")) - { + let hasWaitingForApproval = lower.contains("waiting for approval") + let hasApprovalHeader = + lower.contains("invoke tool") + || lower.contains("run this command?") + || lower.contains("allow editing file:") + || lower.contains("allow creating file:") + || lower.contains("confirm tool call") + let hasApprovalActions = + lower.contains("approve") + && (lower.contains("allow all for this session") + || lower.contains("allow all for every session") + || lower.contains("allow file for every session") + || lower.contains("deny with feedback")) + + if hasApprovalActions && (hasWaitingForApproval || hasApprovalHeader) { return .blocked } if lower.contains("esc to cancel") { @@ -311,12 +326,19 @@ private func hasKimiToolSpinner(content: String, lower: String) -> Bool { } private func hasConfirmationPrompt(_ lower: String) -> Bool { - (lower.contains("allow") || lower.contains("approve") || lower.contains("confirm") || lower.contains("proceed")) - && (lower.contains("[y/n]") || lower.contains("(y/n)") || lower.contains(" yes") || lower.contains(" yes (y)")) + guard + let range = lower.range(of: "do you want") ?? lower.range(of: "would you like") + else { + return false + } + let after = lower[range.lowerBound...] + return after.contains("yes") || after.contains("❯") } private func hasInterruptPattern(_ lower: String) -> Bool { - lower.contains("esc to interrupt") || lower.contains("ctrl+c to interrupt") + lower.contains("esc to interrupt") + || lower.contains("ctrl+c to interrupt") + || (lower.contains("esc") && lower.contains("interrupt")) } private func hasCodexWorkingHeader(_ content: String) -> Bool { @@ -327,12 +349,18 @@ private func hasCodexWorkingHeader(_ content: String) -> Bool { private func hasSpinnerActivity(_ content: String) -> Bool { let spinnerScalars: Set = [ - "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "·", + "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", + "❀", "❁", "❂", "❃", "❇", "❈", "❉", "❊", "❋", "✢", "✣", "✤", "✥", "✦", "✧", "✨", + "⊛", "⊕", "⊙", "◉", "◎", "◍", "⁂", "⁕", "※", "⍟", "☼", "★", "☆", ] return content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let first = trimmed.unicodeScalars.first else { return false } - return spinnerScalars.contains(first) && trimmed.contains("…") + let rest = String(trimmed.unicodeScalars.dropFirst()) + return spinnerScalars.contains(first) + && rest.hasPrefix(" ") + && rest.contains("…") + && rest.contains(where: \.isLetter) } } @@ -350,3 +378,16 @@ private func hasDroidSpinner(_ content: String) -> Bool { return (0x2800...0x28FF).contains(Int(first.value)) && trimmed.contains("esc to stop") } } + +private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { + let lower = content.lowercased() + let hasEnterAction = + lower.contains("enter confirm") + || lower.contains("enter submit") + || lower.contains("enter toggle") + let hasQuestionNavigation = + content.contains("↑↓ select") + || content.contains("⇆ tab") + + return lower.contains("esc dismiss") && hasEnterAction && hasQuestionNavigation +} diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 360375b8..f5edeb90 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -169,15 +169,26 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.cline.detectState(in: "still processing") == .working) #expect(DetectedAgent.opencode.detectState(in: "△ Permission required") == .blocked) + #expect( + DetectedAgent.opencode.detectState( + in: """ + Run command? + ↑↓ select ⇆ tab enter confirm esc dismiss + """ + ) == .blocked + ) #expect(DetectedAgent.opencode.detectState(in: "esc to interrupt") == .working) + #expect(DetectedAgent.opencode.detectState(in: "Do you want to continue?\nYes") == .idle) #expect(DetectedAgent.opencode.detectState(in: "done") == .idle) #expect(DetectedAgent.copilot.detectState(in: "│ do you want to run this?") == .blocked) #expect(DetectedAgent.copilot.detectState(in: "esc to cancel") == .working) + #expect(DetectedAgent.copilot.detectState(in: "Do you want to continue?\nYes") == .idle) #expect(DetectedAgent.copilot.detectState(in: "done") == .idle) #expect(DetectedAgent.kimi.detectState(in: "approve? [y/n]") == .blocked) #expect(DetectedAgent.kimi.detectState(in: "thinking") == .working) + #expect(DetectedAgent.kimi.detectState(in: "ctrl-c to cancel") == .working) #expect(DetectedAgent.kimi.detectState(in: "🌘") == .working) #expect(DetectedAgent.kimi.detectState(in: "⠸ Using Shell (git status)") == .working) #expect( @@ -226,10 +237,21 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.kimi.detectState(in: "done") == .idle) #expect(DetectedAgent.droid.detectState(in: "EXECUTE\nenter to select") == .blocked) + #expect(DetectedAgent.droid.detectState(in: "> Yes, allow\n> No, cancel\nUse ↑↓ to navigate") == .blocked) #expect(DetectedAgent.droid.detectState(in: "⠋ esc to stop") == .working) + #expect(DetectedAgent.droid.detectState(in: "esc to stop") == .working) #expect(DetectedAgent.droid.detectState(in: "done") == .idle) - #expect(DetectedAgent.amp.detectState(in: "waiting for approval\nallow all for this session") == .blocked) + #expect( + DetectedAgent.amp.detectState( + in: """ + Waiting for approval + Approve + Allow All for This Session + """ + ) == .blocked + ) + #expect(DetectedAgent.amp.detectState(in: "waiting for approval\nallow all for this session") == .idle) #expect(DetectedAgent.amp.detectState(in: "esc to cancel") == .working) #expect(DetectedAgent.amp.detectState(in: "done") == .idle) } -- 2.51.2 From a8655a6a5b08feaf5c9f5e0a7ee3aa91c20e1297 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 00:05:01 +0900 Subject: [PATCH 098/528] Polish active agent row presentation --- .../Models/ActiveAgentEntry.swift | 31 ---------------- .../Reducer/ActiveAgentsFeature.swift | 1 - .../ActiveAgents/Views/ActiveAgentRow.swift | 22 ++++++++++-- .../Views/ActiveAgentsPanel.swift | 14 +++++++- .../Repositories/Views/SidebarListView.swift | 35 +++++++++++++++---- supacodeTests/ActiveAgentsFeatureTests.swift | 12 ++++--- .../RepositorySectionViewTests.swift | 5 ++- 7 files changed, 72 insertions(+), 48 deletions(-) diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index e823c875..fa2d332e 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -13,34 +13,3 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { let displayState: AgentDisplayState let lastChangedAt: Date } - -extension ActiveAgentEntry { - static func sorted(_ entries: [ActiveAgentEntry]) -> [ActiveAgentEntry] { - entries.sorted { left, right in - let leftRank = left.displayState.sortRank - let rightRank = right.displayState.sortRank - if leftRank != rightRank { - return leftRank < rightRank - } - if left.lastChangedAt != right.lastChangedAt { - return left.lastChangedAt > right.lastChangedAt - } - return left.id.uuidString < right.id.uuidString - } - } -} - -extension AgentDisplayState { - fileprivate var sortRank: Int { - switch self { - case .blocked: - return 0 - case .working: - return 1 - case .done: - return 2 - case .idle: - return 3 - } - } -} diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index e37584fc..5a352ec9 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -29,7 +29,6 @@ struct ActiveAgentsFeature { switch action { case .agentEntryChanged(let entry): state.entries[id: entry.id] = entry - state.entries = IdentifiedArray(uniqueElements: ActiveAgentEntry.sorted(Array(state.entries))) return .none case .agentEntryRemoved(let id): diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 87d70df2..270d658f 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -5,14 +5,14 @@ struct ActiveAgentRow: View { let entry: ActiveAgentEntry let repositoryName: String let branchName: String + let repositoryColor: RepositoryColorChoice? + let isDimmed: Bool var body: some View { HStack(spacing: 8) { agentIcon VStack(alignment: .leading, spacing: 2) { - Text("\(entry.agent.displayName) · \(repositoryName)") - .font(.body.monospaced()) - .lineLimit(1) + title Text(branchName) .font(.caption) .foregroundStyle(.secondary) @@ -24,6 +24,22 @@ struct ActiveAgentRow: View { .padding(.horizontal, 10) .padding(.vertical, 7) .contentShape(.rect) + .opacity(isDimmed ? 0.7 : 1) + } + + private var title: some View { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(entry.agent.displayName) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + Text("·") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + Text(repositoryName) + .font(.callout.weight(.medium)) + .foregroundStyle(repositoryColor?.color ?? .secondary) + } + .lineLimit(1) } private var agentIcon: some View { diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index b0a95d28..ad63e118 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -5,6 +5,8 @@ struct ActiveAgentsPanel: View { @Bindable var store: StoreOf let repositoryNamesByWorktreeID: [Worktree.ID: String] let branchNamesByWorktreeID: [Worktree.ID: String] + let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] + let selectedWorktreeIDs: Set let height: Double let maximumHeight: Double let onHeightChanged: (Double) -> Void @@ -40,7 +42,9 @@ struct ActiveAgentsPanel: View { ActiveAgentRow( entry: entry, repositoryName: repositoryName(for: entry), - branchName: branchName(for: entry) + branchName: branchName(for: entry), + repositoryColor: repositoryColor(for: entry), + isDimmed: isDimmed(entry) ) } .buttonStyle(.plain) @@ -114,6 +118,14 @@ struct ActiveAgentsPanel: View { branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName } + private func repositoryColor(for entry: ActiveAgentEntry) -> RepositoryColorChoice? { + repositoryColorsByWorktreeID[entry.worktreeID] + } + + private func isDimmed(_ entry: ActiveAgentEntry) -> Bool { + !selectedWorktreeIDs.isEmpty && !selectedWorktreeIDs.contains(entry.worktreeID) + } + private var panelBackgroundShape: UnevenRoundedRectangle { UnevenRoundedRectangle( cornerRadii: .init( diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 16cb13fd..d0ff5567 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import Sharing import SwiftUI // Uses LazyVStack rather than List for repository drag precision; keyboard @@ -41,6 +42,7 @@ struct SidebarListView: View { @State private var sidebarHeight = 0.0 @State private var sidebarFooterHeight = 0.0 @State private var resizingPanelHeight: Double? + @Shared(.repositoryAppearances) private var repositoryAppearances var body: some View { let state = store.state @@ -67,7 +69,8 @@ struct SidebarListView: View { : ActiveAgentsFeature.maximumPanelHeight let agentWorktreeMetadata = Self.activeAgentWorktreeMetadata( repositories: state.repositories, - customTitles: state.repositoryCustomTitles + customTitles: state.repositoryCustomTitles, + repositoryAppearances: repositoryAppearances ) let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 @@ -150,6 +153,8 @@ struct SidebarListView: View { store: store.scope(state: \.activeAgents, action: \.activeAgents), repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, + repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, + selectedWorktreeIDs: selectedWorktreeIDs, height: panelHeight, maximumHeight: maximumPanelHeight, onHeightChanged: { height in @@ -435,30 +440,46 @@ struct SidebarListView: View { static func activeAgentWorktreeMetadata( repositories: IdentifiedArrayOf, - customTitles: [Repository.ID: String] - ) -> ( - repositoryNamesByWorktreeID: [Worktree.ID: String], - branchNamesByWorktreeID: [Worktree.ID: String] - ) { + customTitles: [Repository.ID: String], + repositoryAppearances: [Repository.ID: RepositoryAppearance] = [:] + ) -> ActiveAgentWorktreeMetadata { var repositoryNamesByWorktreeID: [Worktree.ID: String] = [:] var branchNamesByWorktreeID: [Worktree.ID: String] = [:] + var repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] = [:] for repository in repositories { let repositoryName = customTitles[repository.id] ?? repository.name + let repositoryColor = repositoryAppearances[repository.id]?.color if repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees { repositoryNamesByWorktreeID[repository.id] = repositoryName branchNamesByWorktreeID[repository.id] = repository.name + if let repositoryColor { + repositoryColorsByWorktreeID[repository.id] = repositoryColor + } } for worktree in repository.worktrees { repositoryNamesByWorktreeID[worktree.id] = repositoryName branchNamesByWorktreeID[worktree.id] = worktree.name + if let repositoryColor { + repositoryColorsByWorktreeID[worktree.id] = repositoryColor + } } } - return (repositoryNamesByWorktreeID, branchNamesByWorktreeID) + return ActiveAgentWorktreeMetadata( + repositoryNamesByWorktreeID: repositoryNamesByWorktreeID, + branchNamesByWorktreeID: branchNamesByWorktreeID, + repositoryColorsByWorktreeID: repositoryColorsByWorktreeID + ) } } +struct ActiveAgentWorktreeMetadata: Equatable { + let repositoryNamesByWorktreeID: [Worktree.ID: String] + let branchNamesByWorktreeID: [Worktree.ID: String] + let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] +} + extension SidebarItem { fileprivate var isRepositoryOrderItem: Bool { repositoryOrderID != nil diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 0a38f895..a6547525 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct ActiveAgentsFeatureTests { - @Test func entriesAreSortedByStateThenRecency() async { + @Test func entriesKeepInsertionOrder() async { let store = TestStore(initialState: ActiveAgentsFeature.State()) { ActiveAgentsFeature() } @@ -17,18 +17,22 @@ struct ActiveAgentsFeatureTests { let blocked = entry(id: UUID(1), state: .blocked, changedAt: old) let working = entry(id: UUID(2), state: .working, changedAt: new) let done = entry(id: UUID(3), state: .done, changedAt: new) + let updatedIdle = entry(id: UUID(0), state: .blocked, changedAt: Date(timeIntervalSince1970: 30)) await store.send(.agentEntryChanged(idle)) { $0.entries = [idle] } await store.send(.agentEntryChanged(blocked)) { - $0.entries = [blocked, idle] + $0.entries = [idle, blocked] } await store.send(.agentEntryChanged(working)) { - $0.entries = [blocked, working, idle] + $0.entries = [idle, blocked, working] } await store.send(.agentEntryChanged(done)) { - $0.entries = [blocked, working, done, idle] + $0.entries = [idle, blocked, working, done] + } + await store.send(.agentEntryChanged(updatedIdle)) { + $0.entries = [updatedIdle, blocked, working, done] } } diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index 720fb0db..c3b68796 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -110,11 +110,13 @@ struct RepositorySectionViewTests { let metadata = SidebarListView.activeAgentWorktreeMetadata( repositories: [repository], - customTitles: [repository.id: "Prowl"] + customTitles: [repository.id: "Prowl"], + repositoryAppearances: [repository.id: RepositoryAppearance(color: .blue)] ) #expect(metadata.repositoryNamesByWorktreeID[worktree.id] == "Prowl") #expect(metadata.branchNamesByWorktreeID[worktree.id] == "feat/active-agents-panel") + #expect(metadata.repositoryColorsByWorktreeID[worktree.id] == .blue) } @Test func activeAgentWorktreeMetadataFallsBackToRepositoryNameForPlainFolders() { @@ -133,6 +135,7 @@ struct RepositorySectionViewTests { #expect(metadata.repositoryNamesByWorktreeID[repository.id] == "plain") #expect(metadata.branchNamesByWorktreeID[repository.id] == "plain") + #expect(metadata.repositoryColorsByWorktreeID[repository.id] == nil) } @Test func openTabCountForGitRepositorySumsAllWorktrees() { -- 2.51.2 From 767d53517bf7714fd3f52b927658fdd82ce641f2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 00:11:19 +0900 Subject: [PATCH 099/528] Highlight only focused active agent pane --- .../Features/ActiveAgents/Views/ActiveAgentsPanel.swift | 7 +++++-- supacode/Features/Repositories/Views/SidebarListView.swift | 5 ++++- .../Features/Terminal/Models/WorktreeTerminalState.swift | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index ad63e118..c778fabf 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -6,7 +6,7 @@ struct ActiveAgentsPanel: View { let repositoryNamesByWorktreeID: [Worktree.ID: String] let branchNamesByWorktreeID: [Worktree.ID: String] let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] - let selectedWorktreeIDs: Set + let selectedSurfaceID: UUID? let height: Double let maximumHeight: Double let onHeightChanged: (Double) -> Void @@ -123,7 +123,10 @@ struct ActiveAgentsPanel: View { } private func isDimmed(_ entry: ActiveAgentEntry) -> Bool { - !selectedWorktreeIDs.isEmpty && !selectedWorktreeIDs.contains(entry.worktreeID) + if let selectedSurfaceID { + return entry.surfaceID != selectedSurfaceID + } + return false } private var panelBackgroundShape: UnevenRoundedRectangle { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index d0ff5567..b2562eca 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -61,6 +61,9 @@ struct SidebarListView: View { return false } let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) + let selectedSurfaceID = state.selectedWorktreeID.flatMap { worktreeID in + terminalManager.stateIfExists(for: worktreeID)?.activeSurfaceID + } let pendingSidebarReveal = state.pendingSidebarReveal let maximumPanelHeight = @@ -154,7 +157,7 @@ struct SidebarListView: View { repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, - selectedWorktreeIDs: selectedWorktreeIDs, + selectedSurfaceID: selectedSurfaceID, height: panelHeight, maximumHeight: maximumPanelHeight, onHeightChanged: { height in diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2b0ef715..6115a194 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -159,6 +159,10 @@ final class WorktreeTerminalState { return surfaces[surfaceId] } + var activeSurfaceID: UUID? { + currentFocusedSurfaceId() + } + func surfaceView(for tabId: TerminalTabID) -> GhosttySurfaceView? { guard let surfaceId = focusedSurfaceIdByTab[tabId] else { return nil } return surfaces[surfaceId] -- 2.51.2 From b736549fd4527a95f7cbb736b5b49567d526fd02 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 00:14:46 +0900 Subject: [PATCH 100/528] Increase active agents panel max height --- .../Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift | 2 +- supacodeTests/ActiveAgentsFeatureTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 5a352ec9..899f0e58 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -6,7 +6,7 @@ import Sharing @Reducer struct ActiveAgentsFeature { static let minimumPanelHeight = 120.0 - static let maximumPanelHeight = 420.0 + static let maximumPanelHeight = 560.0 static let reservedSidebarListHeight = 200.0 @ObservableState diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index a6547525..a3e7d7b0 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -45,12 +45,12 @@ struct ActiveAgentsFeatureTests { $0.$panelHeight.withLock { $0 = 120 } } await store.send(.panelHeightChanged(900)) { - $0.$panelHeight.withLock { $0 = 420 } + $0.$panelHeight.withLock { $0 = 560 } } } @Test func maximumPanelHeightKeepsRepositoryListVisible() { - #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 900) == 420) + #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 900) == 560) #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 500) == 300) #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) } -- 2.51.2 From bfaa9ac24ac8869c3666f8659d5cc1e12a76a9ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 00:36:43 +0900 Subject: [PATCH 101/528] Refine active agents empty state copy --- supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index c778fabf..32cf56f1 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -28,8 +28,8 @@ struct ActiveAgentsPanel: View { if store.entries.isEmpty { Spacer(minLength: 0) - Text("No active agents") - .font(.caption) + Text("New agents will appear here") + .font(.callout) .foregroundStyle(.secondary) Spacer(minLength: 0) } else { -- 2.51.2 From ecb8a147b20501fe769c953e45443bec474bcab9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 01:05:11 +0900 Subject: [PATCH 102/528] Polish active agents panel controls --- supacode/App/AppShortcuts.swift | 10 +++ supacode/Commands/SidebarCommands.swift | 7 ++ .../Reducer/ActiveAgentsFeature.swift | 7 +- .../ActiveAgents/Views/ActiveAgentRow.swift | 50 ++++++++++++-- .../Views/ActiveAgentsPanel.swift | 7 +- .../Features/App/Reducer/AppFeature.swift | 8 ++- .../Views/SidebarFooterView.swift | 10 ++- .../Repositories/Views/SidebarListView.swift | 3 +- .../Settings/Models/GlobalSettings.swift | 13 +++- .../Settings/Reducer/SettingsFeature.swift | 6 +- .../Views/AppearanceSettingsView.swift | 10 +++ supacodeTests/ActiveAgentsFeatureTests.swift | 24 +++++-- .../AppFeatureSettingsChangedTests.swift | 67 +++++++++++++++++++ supacodeTests/AppShortcutsTests.swift | 7 ++ .../BaguaWorkingIndicatorTests.swift | 21 ++++++ supacodeTests/SettingsFeatureTests.swift | 18 +++++ supacodeTests/SidebarFooterViewTests.swift | 4 +- 17 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 supacodeTests/BaguaWorkingIndicatorTests.swift diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index e6b31873..f654be1c 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -93,6 +93,7 @@ enum AppShortcuts { static let openRepository = "open_repository" static let openPullRequest = "open_pull_request" static let toggleLeftSidebar = "toggle_left_sidebar" + static let toggleActiveAgentsPanel = "toggle_active_agents_panel" static let refreshWorktrees = "refresh_worktrees" static let jumpToLatestUnread = "jump_to_latest_unread" static let runScript = "run_script" @@ -182,6 +183,7 @@ enum AppShortcuts { static let openRepository = AppShortcut(key: "o", modifiers: [.command, .shift]) static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) static let toggleLeftSidebar = AppShortcut(key: "s", modifiers: [.command, .control]) + static let toggleActiveAgentsPanel = AppShortcut(key: "p", modifiers: [.command, .option]) static let refreshWorktrees = AppShortcut(key: "r", modifiers: [.command, .shift]) static let jumpToLatestUnread = AppShortcut(key: "u", modifiers: [.command, .option]) static let runScript = AppShortcut(key: "r", modifiers: .command) @@ -329,6 +331,7 @@ enum AppShortcuts { private static let reservedCustomCommandBindings: [ReservedCustomCommandBinding] = [ .init(actionTitle: "Open Settings", shortcut: openSettings), .init(actionTitle: "Toggle Left Sidebar", shortcut: toggleLeftSidebar), + .init(actionTitle: "Toggle Active Agents Panel", shortcut: toggleActiveAgentsPanel), .init(actionTitle: "Jump to Latest Unread", shortcut: jumpToLatestUnread), .init(actionTitle: "Run Script", shortcut: runScript), .init(actionTitle: "Stop Script", shortcut: stopRunScript), @@ -398,6 +401,12 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: toggleLeftSidebar ), + .init( + id: CommandID.toggleActiveAgentsPanel, + title: "Toggle Active Agents Panel", + scope: .configurableAppAction, + shortcut: toggleActiveAgentsPanel + ), .init( id: CommandID.refreshWorktrees, title: "Refresh Worktrees", @@ -863,6 +872,7 @@ enum AppShortcuts { openRepository, openPullRequest, toggleLeftSidebar, + toggleActiveAgentsPanel, revealInSidebar, refreshWorktrees, jumpToLatestUnread, diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 2cdf6cc7..1b531a91 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -21,6 +21,13 @@ struct SidebarCommands: Commands { .help(helpText(title: "Reveal in Sidebar", commandID: AppShortcuts.CommandID.revealInSidebar)) .disabled(revealInSidebarAction == nil) Divider() + Button("Active Agents") { + store.send(.repositories(.activeAgents(.togglePanelVisibility))) + } + .modifier( + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleActiveAgentsPanel)) + ) + .help(helpText(title: "Active Agents", commandID: AppShortcuts.CommandID.toggleActiveAgentsPanel)) Button("Canvas") { store.send(.repositories(.toggleCanvas)) } diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 899f0e58..3095752c 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -17,7 +17,7 @@ struct ActiveAgentsFeature { } enum Action: Equatable { - case agentEntryChanged(ActiveAgentEntry) + case agentEntryChanged(ActiveAgentEntry, autoShowPanel: Bool) case agentEntryRemoved(ActiveAgentEntry.ID) case entryTapped(ActiveAgentEntry.ID) case togglePanelVisibility @@ -27,8 +27,11 @@ struct ActiveAgentsFeature { var body: some Reducer { Reduce { state, action in switch action { - case .agentEntryChanged(let entry): + case .agentEntryChanged(let entry, let autoShowPanel): state.entries[id: entry.id] = entry + if autoShowPanel, state.isPanelHidden { + state.$isPanelHidden.withLock { $0 = false } + } return .none case .agentEntryRemoved(let id): diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 270d658f..20889cbd 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -7,6 +7,7 @@ struct ActiveAgentRow: View { let branchName: String let repositoryColor: RepositoryColorChoice? let isDimmed: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { HStack(spacing: 8) { @@ -57,16 +58,53 @@ struct ActiveAgentRow: View { private var statusPill: some View { HStack(spacing: 4) { if entry.displayState == .working { - ProgressView() - .controlSize(.small) - .frame(width: 10, height: 10) + if reduceMotion { + statusText + } else { + BaguaWorkingIndicator() + } + } else { + statusText } - Text(entry.displayState.label) - .font(.caption2.weight(.semibold)) - .lineLimit(1) } .foregroundStyle(entry.displayState.foregroundStyle) } + + private var statusText: some View { + Text(entry.displayState.label) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } +} + +struct BaguaWorkingIndicator: View { + static let frames = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"] + static let frameDuration = 0.12 + + var body: some View { + TimelineView(.periodic(from: .now, by: Self.frameDuration)) { context in + frameText(Self.frame(at: context.date)) + } + } + + static func frame(at date: Date) -> String { + frames[frameIndex(at: date)] + } + + static func frameIndex(at date: Date) -> Int { + let tick = Int(date.timeIntervalSinceReferenceDate / frameDuration) + let cycleLength = (frames.count * 2) - 2 + let cycleIndex = ((tick % cycleLength) + cycleLength) % cycleLength + return cycleIndex < frames.count ? cycleIndex : cycleLength - cycleIndex + } + + private func frameText(_ frame: String) -> some View { + Text(frame) + .font(.system(size: 17, weight: .bold, design: .monospaced)) + .lineLimit(1) + .frame(width: 20, height: 18) + .accessibilityHidden(true) + } } extension AgentDisplayState { diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 32cf56f1..b460a40f 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -48,7 +48,7 @@ struct ActiveAgentsPanel: View { ) } .buttonStyle(.plain) - .help("Focus \(entry.agent.displayName) in \(repositoryName(for: entry))") + .help(helpText(for: entry)) } } } @@ -129,6 +129,11 @@ struct ActiveAgentsPanel: View { return false } + private func helpText(for entry: ActiveAgentEntry) -> String { + let trimmed = entry.tabTitle.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Untitled tab" : trimmed + } + private var panelBackgroundShape: UnevenRoundedRectangle { UnevenRoundedRectangle( cornerRadii: .init( diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index c62d19e7..02cbe69c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1040,7 +1040,13 @@ struct AppFeature { return .none case .terminalEvent(.agentEntryChanged(let entry)): - return .send(.repositories(.activeAgents(.agentEntryChanged(entry)))) + return .send( + .repositories( + .activeAgents( + .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) + ) + ) + ) case .terminalEvent(.agentEntryRemoved(let id)): return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index ba69d95f..e141e0c6 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -44,7 +44,13 @@ struct SidebarFooterView: View { Image(systemName: Self.activeAgentsPanelIconName(isPanelHidden: store.state.activeAgents.isPanelHidden)) .accessibilityLabel(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") } - .help(store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents") + .help( + AppShortcuts.helpText( + title: store.state.activeAgents.isPanelHidden ? "Show Active Agents" : "Hide Active Agents", + commandID: AppShortcuts.CommandID.toggleActiveAgentsPanel, + in: resolvedKeybindings + ) + ) Button { store.send(.refreshWorktrees) } label: { @@ -101,6 +107,6 @@ struct SidebarFooterView: View { } static func activeAgentsPanelIconName(isPanelHidden: Bool) -> String { - isPanelHidden ? "eye" : "eye.slash" + isPanelHidden ? "person.crop.rectangle.stack" : "person.crop.rectangle.stack.fill" } } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index b2562eca..27b692a6 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -77,7 +77,8 @@ struct SidebarListView: View { ) let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 - let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight + let activeAgentsPanelTopGap = 4.0 + let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap ScrollViewReader { scrollProxy in ScrollView { diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 9a43b675..533b6517 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -28,6 +28,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var keybindingUserOverrides: KeybindingUserOverrideStore var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool + var autoShowActiveAgentsPanel: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -58,7 +59,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminalFontSize: nil, keybindingUserOverrides: .empty, defaultViewMode: .normal, - dimUnfocusedSplits: true + dimUnfocusedSplits: true, + autoShowActiveAgentsPanel: false ) init( @@ -90,7 +92,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminalFontSize: Float32? = nil, keybindingUserOverrides: KeybindingUserOverrideStore = .empty, defaultViewMode: DefaultViewMode = .normal, - dimUnfocusedSplits: Bool = true + dimUnfocusedSplits: Bool = true, + autoShowActiveAgentsPanel: Bool = false ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -121,6 +124,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.keybindingUserOverrides = keybindingUserOverrides self.defaultViewMode = defaultViewMode self.dimUnfocusedSplits = dimUnfocusedSplits + self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel } func encode(to encoder: any Encoder) throws { @@ -154,6 +158,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(keybindingUserOverrides, forKey: .keybindingUserOverrides) try container.encode(defaultViewMode, forKey: .defaultViewMode) try container.encode(dimUnfocusedSplits, forKey: .dimUnfocusedSplits) + try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) } private enum CodingKeys: String, CodingKey { @@ -186,6 +191,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case keybindingUserOverrides case defaultViewMode case dimUnfocusedSplits + case autoShowActiveAgentsPanel // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -281,5 +287,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits = try container.decodeIfPresent(Bool.self, forKey: .dimUnfocusedSplits) ?? Self.default.dimUnfocusedSplits + autoShowActiveAgentsPanel = + try container.decodeIfPresent(Bool.self, forKey: .autoShowActiveAgentsPanel) + ?? Self.default.autoShowActiveAgentsPanel } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index f12d22e1..f8e9a31c 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -34,6 +34,7 @@ struct SettingsFeature { var keybindingUserOverrides: KeybindingUserOverrideStore var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool + var autoShowActiveAgentsPanel: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true var selection: SettingsSection? = .general @@ -72,6 +73,7 @@ struct SettingsFeature { keybindingUserOverrides = settings.keybindingUserOverrides defaultViewMode = settings.defaultViewMode dimUnfocusedSplits = settings.dimUnfocusedSplits + autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel } var globalSettings: GlobalSettings { @@ -106,7 +108,8 @@ struct SettingsFeature { terminalFontSize: terminalFontSize, keybindingUserOverrides: keybindingUserOverrides, defaultViewMode: defaultViewMode, - dimUnfocusedSplits: dimUnfocusedSplits + dimUnfocusedSplits: dimUnfocusedSplits, + autoShowActiveAgentsPanel: autoShowActiveAgentsPanel ) } } @@ -208,6 +211,7 @@ struct SettingsFeature { state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides state.defaultViewMode = normalizedSettings.defaultViewMode state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits + state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index fadb84b5..bc255217 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -53,6 +53,16 @@ struct AppearanceSettingsView: View { ) .help("Fade split panes that aren't focused so the active one stands out.") } + Section("Active Agents") { + Toggle( + "Show Active Agents panel automatically", + isOn: $store.autoShowActiveAgentsPanel + ) + .help("Open the Active Agents panel when an agent is detected.") + Text("When enabled, hidden panels reopen as soon as an agent starts or updates.") + .foregroundStyle(.secondary) + .font(.callout) + } Section("Default View") { Picker("Launch in", selection: $store.defaultViewMode) { ForEach(DefaultViewMode.allCases) { mode in diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index a3e7d7b0..7a9058ee 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -19,23 +19,37 @@ struct ActiveAgentsFeatureTests { let done = entry(id: UUID(3), state: .done, changedAt: new) let updatedIdle = entry(id: UUID(0), state: .blocked, changedAt: Date(timeIntervalSince1970: 30)) - await store.send(.agentEntryChanged(idle)) { + await store.send(.agentEntryChanged(idle, autoShowPanel: false)) { $0.entries = [idle] } - await store.send(.agentEntryChanged(blocked)) { + await store.send(.agentEntryChanged(blocked, autoShowPanel: false)) { $0.entries = [idle, blocked] } - await store.send(.agentEntryChanged(working)) { + await store.send(.agentEntryChanged(working, autoShowPanel: false)) { $0.entries = [idle, blocked, working] } - await store.send(.agentEntryChanged(done)) { + await store.send(.agentEntryChanged(done, autoShowPanel: false)) { $0.entries = [idle, blocked, working, done] } - await store.send(.agentEntryChanged(updatedIdle)) { + await store.send(.agentEntryChanged(updatedIdle, autoShowPanel: false)) { $0.entries = [updatedIdle, blocked, working, done] } } + @Test func autoShowRevealsHiddenPanelOnAgentEntry() async { + var state = ActiveAgentsFeature.State() + state.$isPanelHidden.withLock { $0 = true } + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + let agent = entry(id: UUID(0), state: .working, changedAt: Date(timeIntervalSince1970: 10)) + + await store.send(.agentEntryChanged(agent, autoShowPanel: true)) { + $0.entries = [agent] + $0.$isPanelHidden.withLock { $0 = false } + } + } + @Test func panelHeightIsClamped() async { let store = TestStore(initialState: ActiveAgentsFeature.State()) { ActiveAgentsFeature() diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 387e726c..6c7d782d 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -1,11 +1,16 @@ import ComposableArchitecture import CustomDump import DependenciesTestSupport +import Foundation import Testing @testable import supacode @MainActor +@Suite( + .serialized, + .dependency(\.defaultAppStorage, .appFeatureSettingsChangedTests) +) struct AppFeatureSettingsChangedTests { @Test(.dependencies) func settingsChangedPropagatesRepositorySettings() async { var settings = GlobalSettings.default @@ -58,6 +63,42 @@ struct AppFeatureSettingsChangedTests { #expect(watcherCommands.value.isEmpty) } + @Test(.dependencies) func agentEntryAutoShowsActiveAgentsPanelWhenEnabled() async { + var settings = SettingsFeature.State() + settings.autoShowActiveAgentsPanel = true + UserDefaults.appFeatureSettingsChangedTests.set(true, forKey: "activeAgentsPanelHidden") + let state = AppFeature.State(settings: settings) + let entry = activeAgentEntry() + + let store = TestStore(initialState: state) { + AppFeature() + } + + await store.send(.terminalEvent(.agentEntryChanged(entry))) { + $0.repositories.activeAgents.$isPanelHidden.withLock { $0 = false } + } + await store.receive(\.repositories.activeAgents.agentEntryChanged) { + $0.repositories.activeAgents.entries = [entry] + } + } + + @Test(.dependencies) func agentEntryKeepsActiveAgentsPanelHiddenWhenAutoShowDisabled() async { + var settings = SettingsFeature.State() + settings.autoShowActiveAgentsPanel = false + UserDefaults.appFeatureSettingsChangedTests.set(true, forKey: "activeAgentsPanelHidden") + let state = AppFeature.State(settings: settings) + let entry = activeAgentEntry() + + let store = TestStore(initialState: state) { + AppFeature() + } + + await store.send(.terminalEvent(.agentEntryChanged(entry))) + await store.receive(\.repositories.activeAgents.agentEntryChanged) { + $0.repositories.activeAgents.entries = [entry] + } + } + @Test(.dependencies) func settingsChangedRecomputesResolvedKeybindings() async { var settings = GlobalSettings.default settings.keybindingUserOverrides = KeybindingUserOverrideStore( @@ -114,4 +155,30 @@ struct AppFeatureSettingsChangedTests { $0.repositories.statusToast = .success("Saved terminal layout cleared") } } + + private func activeAgentEntry() -> ActiveAgentEntry { + ActiveAgentEntry( + id: fixedUUID(0), + worktreeID: "/repo/wt", + worktreeName: "wt", + tabID: TerminalTabID(rawValue: fixedUUID(1)), + tabTitle: "codex", + surfaceID: fixedUUID(0), + paneIndex: 1, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 10) + ) + } + + private func fixedUUID(_ value: UInt8) -> UUID { + UUID(uuid: (value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + } +} + +extension UserDefaults { + fileprivate nonisolated(unsafe) static let appFeatureSettingsChangedTests = UserDefaults( + suiteName: "com.onevcat.Prowl.AppFeatureSettingsChangedTests" + )! } diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 011afa1b..00063691 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -92,6 +92,7 @@ struct AppShortcutsTests { [ "openSettings=\(AppShortcuts.openSettings.display)", "toggleLeftSidebar=\(AppShortcuts.toggleLeftSidebar.display)", + "toggleActiveAgentsPanel=\(AppShortcuts.toggleActiveAgentsPanel.display)", "runScript=\(AppShortcuts.runScript.display)", "stopRunScript=\(AppShortcuts.stopRunScript.display)", "checkForUpdates=\(AppShortcuts.checkForUpdates.display)", @@ -119,6 +120,7 @@ struct AppShortcutsTests { [ "openSettings=⌘,", "toggleLeftSidebar=⌘⌃S", + "toggleActiveAgentsPanel=⌘⌥P", "runScript=⌘R", "stopRunScript=⌘.", "checkForUpdates=⌘⇧U", @@ -154,6 +156,10 @@ struct AppShortcutsTests { idToDisplay["command_palette"], AppShortcuts.commandPalette.display ) + expectNoDifference( + idToDisplay["toggle_active_agents_panel"], + AppShortcuts.toggleActiveAgentsPanel.display + ) expectNoDifference( idToDisplay["quit_application"], AppShortcuts.quitApplication.display @@ -168,6 +174,7 @@ struct AppShortcutsTests { ) #expect(idToScope["command_palette"] == .configurableAppAction) + #expect(idToScope["toggle_active_agents_panel"] == .configurableAppAction) #expect(idToScope["quit_application"] == .systemFixedAppAction) #expect(idToScope["rename_branch"] == .localInteraction) #expect(idToScope["select_all_canvas_cards"] == .localInteraction) diff --git a/supacodeTests/BaguaWorkingIndicatorTests.swift b/supacodeTests/BaguaWorkingIndicatorTests.swift new file mode 100644 index 00000000..e221d2bd --- /dev/null +++ b/supacodeTests/BaguaWorkingIndicatorTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing + +@testable import supacode + +struct BaguaWorkingIndicatorTests { + @Test func framesUseFullTrigramSequence() { + #expect(BaguaWorkingIndicator.frames == ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"]) + } + + @Test func frameSelectionLoopsByDuration() { + let duration = BaguaWorkingIndicator.frameDuration + + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: 0)) == "☰") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration)) == "☱") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 7)) == "☷") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 8)) == "☶") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 13)) == "☱") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 14)) == "☰") + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index e689a3e6..a71ed446 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -364,6 +364,24 @@ struct SettingsFeatureTests { #expect(settingsFile.global.keybindingUserOverrides == overrides) } + @Test(.dependencies) func autoShowActiveAgentsPanelPersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.autoShowActiveAgentsPanel = false + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.autoShowActiveAgentsPanel, true))) { + $0.autoShowActiveAgentsPanel = true + } + await store.receive(\.delegate.settingsChanged) + + #expect(settingsFile.global.autoShowActiveAgentsPanel == true) + } + @Test(.dependencies) func disablingAnalyticsResetsClient() async { var initialSettings = GlobalSettings.default initialSettings.analyticsEnabled = true diff --git a/supacodeTests/SidebarFooterViewTests.swift b/supacodeTests/SidebarFooterViewTests.swift index 760d45d6..95e4318d 100644 --- a/supacodeTests/SidebarFooterViewTests.swift +++ b/supacodeTests/SidebarFooterViewTests.swift @@ -4,7 +4,7 @@ import Testing struct SidebarFooterViewTests { @Test func activeAgentsPanelToggleUsesStableSymbols() { - #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: true) == "eye") - #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: false) == "eye.slash") + #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: true) == "person.crop.rectangle.stack") + #expect(SidebarFooterView.activeAgentsPanelIconName(isPanelHidden: false) == "person.crop.rectangle.stack.fill") } } -- 2.51.2 From 318c75eee2934a6a14c7a7e8410ae4992f521ea0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 11:03:56 +0900 Subject: [PATCH 103/528] Map all detected agents to icons --- .../ClaudeCode.imageset/ClaudeCode.svg | 2 +- .../CommandIcons/Cline.imageset/Cline.svg | 1 + .../CommandIcons/Cline.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/Cursor.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/Cursor.imageset/Cursor.svg | 1 + .../CommandIcons/Droid.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/Droid.imageset/Droid.svg | 1 + .../CommandIcons/Pi.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/Pi.imageset/Pi.svg | 1 + .../Terminal/Models/CommandIconMap.swift | 13 ++++++++----- supacodeTests/CommandIconMapTests.swift | 13 +++++++++++-- 11 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 supacode/Assets.xcassets/CommandIcons/Cline.imageset/Cline.svg create mode 100644 supacode/Assets.xcassets/CommandIcons/Cline.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Cursor.svg create mode 100644 supacode/Assets.xcassets/CommandIcons/Droid.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/Droid.imageset/Droid.svg create mode 100644 supacode/Assets.xcassets/CommandIcons/Pi.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/Pi.imageset/Pi.svg diff --git a/supacode/Assets.xcassets/CommandIcons/ClaudeCode.imageset/ClaudeCode.svg b/supacode/Assets.xcassets/CommandIcons/ClaudeCode.imageset/ClaudeCode.svg index e29f3282..978cf86f 100644 --- a/supacode/Assets.xcassets/CommandIcons/ClaudeCode.imageset/ClaudeCode.svg +++ b/supacode/Assets.xcassets/CommandIcons/ClaudeCode.imageset/ClaudeCode.svg @@ -1 +1 @@ -Claude \ No newline at end of file +Claude Code diff --git a/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Cline.svg b/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Cline.svg new file mode 100644 index 00000000..d7fb67b5 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Cline.svg @@ -0,0 +1 @@ +Cline diff --git a/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Contents.json new file mode 100644 index 00000000..a7240703 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Cline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Cline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Contents.json new file mode 100644 index 00000000..2e79280d --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Cursor.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Cursor.svg b/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Cursor.svg new file mode 100644 index 00000000..79b44c5e --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Cursor.imageset/Cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Contents.json new file mode 100644 index 00000000..6cb357b6 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Droid.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Droid.svg b/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Droid.svg new file mode 100644 index 00000000..93a8d058 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Droid.imageset/Droid.svg @@ -0,0 +1 @@ +Droid diff --git a/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Contents.json new file mode 100644 index 00000000..32659d00 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Pi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Pi.svg b/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Pi.svg new file mode 100644 index 00000000..e0b69b31 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Pi.imageset/Pi.svg @@ -0,0 +1 @@ +pi diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index 6e0ac257..abb17f37 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -31,15 +31,18 @@ enum CommandIconMap { /// artwork should add `assetName:` and let renderers prefer it. private static let firstTokenMapping: [String: TabIconSource] = [ // Coding agents + "aider": TabIconSource(systemSymbol: "sparkle"), + "amp": TabIconSource(systemSymbol: "sparkle", assetName: "Amp"), "claude": TabIconSource(systemSymbol: "sparkle", assetName: "ClaudeCode"), + "cline": TabIconSource(systemSymbol: "sparkle", assetName: "Cline"), "codex": TabIconSource(systemSymbol: "sparkle", assetName: "Codex"), + "copilot": TabIconSource(systemSymbol: "sparkle", assetName: "GitHubCopilot"), + "cursor": TabIconSource(systemSymbol: "sparkle", assetName: "Cursor"), + "droid": TabIconSource(systemSymbol: "sparkle", assetName: "Droid"), "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), - "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), - "amp": TabIconSource(systemSymbol: "sparkle", assetName: "Amp"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), - "copilot": TabIconSource(systemSymbol: "sparkle", assetName: "GitHubCopilot"), - "aider": TabIconSource(systemSymbol: "sparkle"), - "droid": TabIconSource(systemSymbol: "sparkle"), + "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), + "pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), // Editors / IDEs / pagers "vim": TabIconSource(systemSymbol: "pencil.and.scribble", assetName: "Vim"), diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index beac311a..4597e03d 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -69,10 +69,19 @@ struct CommandIconMapTests { #expect(CommandIconMap.iconForFirstToken("codex")?.assetName == "Codex") #expect(CommandIconMap.iconForFirstToken("gemini")?.assetName == "Gemini") #expect(CommandIconMap.iconForFirstToken("copilot")?.assetName == "GitHubCopilot") - // aider/droid have no brand asset — sparkle fallback only. + #expect(CommandIconMap.iconForFirstToken("pi")?.assetName == "Pi") + #expect(CommandIconMap.iconForFirstToken("cursor")?.assetName == "Cursor") + #expect(CommandIconMap.iconForFirstToken("cline")?.assetName == "Cline") + #expect(CommandIconMap.iconForFirstToken("droid")?.assetName == "Droid") + // Aider has no bundled brand asset — sparkle fallback only. #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle") #expect(CommandIconMap.iconForFirstToken("aider")?.assetName == nil) - #expect(CommandIconMap.iconForFirstToken("droid")?.systemSymbol == "sparkle") + } + + @Test func allDetectedAgentsResolveToIcons() { + for agent in DetectedAgent.allCases { + #expect(CommandIconMap.iconForFirstToken(agent.iconLookupToken) != nil) + } } // MARK: - Debug catalog -- 2.51.2 From f685f2efd0c8bd618c64ee2036e562eb43f5038b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 18:02:48 +0900 Subject: [PATCH 104/528] Support Cursor agent detection aliases --- .../Domain/AgentDetection/DetectedAgent.swift | 11 +++++- .../Terminal/Models/CommandIconMap.swift | 2 + .../AgentDetection/AgentClassifier.swift | 2 +- .../AgentDetection/ScreenHeuristics.swift | 6 ++- supacodeTests/AgentClassifierTests.swift | 38 +++++++++++++++++++ supacodeTests/CommandIconMapTests.swift | 2 + supacodeTests/DetectedAgentTests.swift | 3 +- supacodeTests/ScreenHeuristicsTests.swift | 11 ++++++ 8 files changed, 69 insertions(+), 6 deletions(-) diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index ccc9015c..1872ca58 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -6,7 +6,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { case claude case codex case gemini - case cursor + case cursor = "cursor-agent" case cline case opencode case copilot @@ -17,7 +17,12 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { var id: String { rawValue } var displayName: String { - rawValue + switch self { + case .cursor: + return "cursor" + default: + return rawValue + } } var iconLookupToken: String { @@ -26,6 +31,8 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { return "claude" case .copilot: return "copilot" + case .cursor: + return "cursor" default: return rawValue } diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index abb17f37..a9360ac5 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -32,12 +32,14 @@ enum CommandIconMap { private static let firstTokenMapping: [String: TabIconSource] = [ // Coding agents "aider": TabIconSource(systemSymbol: "sparkle"), + "agent": TabIconSource(systemSymbol: "sparkle", assetName: "Cursor"), "amp": TabIconSource(systemSymbol: "sparkle", assetName: "Amp"), "claude": TabIconSource(systemSymbol: "sparkle", assetName: "ClaudeCode"), "cline": TabIconSource(systemSymbol: "sparkle", assetName: "Cline"), "codex": TabIconSource(systemSymbol: "sparkle", assetName: "Codex"), "copilot": TabIconSource(systemSymbol: "sparkle", assetName: "GitHubCopilot"), "cursor": TabIconSource(systemSymbol: "sparkle", assetName: "Cursor"), + "cursor-agent": TabIconSource(systemSymbol: "sparkle", assetName: "Cursor"), "droid": TabIconSource(systemSymbol: "sparkle", assetName: "Droid"), "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 90b6cde5..7170b8aa 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -10,7 +10,7 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .codex case "gemini": return .gemini - case "cursor": + case "agent", "cursor", "cursor-agent": return .cursor case "cline": return .cline diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index dd5df635..f94a6bc2 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -112,14 +112,16 @@ private func detectGemini(_ content: String) -> AgentRawState { private func detectCursor(_ content: String) -> AgentRawState { let lower = content.lowercased() - if lower.contains("(y) (enter)") + if lower.contains("workspace trust required") + || lower.contains("trust this workspace") + || lower.contains("(y) (enter)") || lower.contains("keep (n)") || lower.contains("skip (esc or n)") || (lower.contains("(y)") && (lower.contains("allow") || lower.contains("run"))) { return .blocked } - if lower.contains("ctrl+c to stop") || hasCursorSpinner(content) { + if lower.contains("trusting workspace") || lower.contains("ctrl+c to stop") || hasCursorSpinner(content) { return .working } return .idle diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index f1090cb8..34e45c76 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -9,7 +9,9 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "claude-code") == .claude) #expect(identifyAgent(processName: "codex") == .codex) #expect(identifyAgent(processName: "gemini") == .gemini) + #expect(identifyAgent(processName: "agent") == .cursor) #expect(identifyAgent(processName: "cursor") == .cursor) + #expect(identifyAgent(processName: "cursor-agent") == .cursor) #expect(identifyAgent(processName: "cline") == .cline) #expect(identifyAgent(processName: "opencode") == .opencode) #expect(identifyAgent(processName: "open-code") == .opencode) @@ -22,6 +24,42 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "amp-local") == .amp) } + @Test func identifiesCursorAgentAliasCommandLines() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "agent", + argv0: "agent", + cmdline: "agent" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .cursor) + #expect(result.name == "agent") + } + + @Test func identifiesCursorAgentCommandLines() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "node", + argv0: "node", + cmdline: "node /opt/homebrew/bin/cursor-agent" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .cursor) + #expect(result.name == "cursor-agent") + } + @Test func ignoresPlainShellsAndUnknownProcesses() { #expect(identifyAgent(processName: "zsh") == nil) #expect(identifyAgent(processName: "bash") == nil) diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index 4597e03d..ad4b0f5d 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -65,12 +65,14 @@ struct CommandIconMapTests { @Test func codingAgentsResolved() { // Sample of the coding-agent set — they all share the sparkle SF // Symbol fallback, asset names match the imageset folders. + #expect(CommandIconMap.iconForFirstToken("agent")?.assetName == "Cursor") #expect(CommandIconMap.iconForFirstToken("claude")?.assetName == "ClaudeCode") #expect(CommandIconMap.iconForFirstToken("codex")?.assetName == "Codex") #expect(CommandIconMap.iconForFirstToken("gemini")?.assetName == "Gemini") #expect(CommandIconMap.iconForFirstToken("copilot")?.assetName == "GitHubCopilot") #expect(CommandIconMap.iconForFirstToken("pi")?.assetName == "Pi") #expect(CommandIconMap.iconForFirstToken("cursor")?.assetName == "Cursor") + #expect(CommandIconMap.iconForFirstToken("cursor-agent")?.assetName == "Cursor") #expect(CommandIconMap.iconForFirstToken("cline")?.assetName == "Cline") #expect(CommandIconMap.iconForFirstToken("droid")?.assetName == "Droid") // Aider has no bundled brand asset — sparkle fallback only. diff --git a/supacodeTests/DetectedAgentTests.swift b/supacodeTests/DetectedAgentTests.swift index c448abe5..96fc13c7 100644 --- a/supacodeTests/DetectedAgentTests.swift +++ b/supacodeTests/DetectedAgentTests.swift @@ -5,7 +5,8 @@ import Testing struct DetectedAgentTests { @Test func displayNamesUseCommandStyleTokens() { for agent in DetectedAgent.allCases { - #expect(agent.displayName == agent.rawValue) + let expectedDisplayName = agent == .cursor ? "cursor" : agent.rawValue + #expect(agent.displayName == expectedDisplayName) } } } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index f5edeb90..dbcb1ac6 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -161,6 +161,17 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.gemini.detectState(in: "done") == .idle) #expect(DetectedAgent.cursor.detectState(in: "Run command? (y) (enter)") == .blocked) + #expect( + DetectedAgent.cursor.detectState( + in: """ + ⚠ Workspace Trust Required + Cursor Agent can execute code and access files in this directory. + [a] Trust this workspace + [q] Quit + """ + ) == .blocked + ) + #expect(DetectedAgent.cursor.detectState(in: "⏳ Trusting workspace...") == .working) #expect(DetectedAgent.cursor.detectState(in: "⬡ indexing") == .working) #expect(DetectedAgent.cursor.detectState(in: "done") == .idle) -- 2.51.2 From 0c6dcb6deb8139c706fb58191903e222e0b04f73 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 18:13:43 +0900 Subject: [PATCH 105/528] Use HTTPS URL for ghostty submodule --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index d7da14ee..4744c193 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "ThirdParty/ghostty"] path = ThirdParty/ghostty - url = git@github.com:onevcat/ghostty.git + url = https://github.com/onevcat/ghostty.git [submodule "Resources/git-wt"] path = Resources/git-wt url = https://github.com/khoi/git-wt.git -- 2.51.2 From 1736302d8ae579fb8f3c277049e5c4b5f4b8189d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 18:31:22 +0900 Subject: [PATCH 106/528] Split screen heuristic tests --- supacodeTests/ScreenHeuristicsTests.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index dbcb1ac6..079615f6 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -155,11 +155,13 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.codex.detectState(in: "Ready for input") == .idle) } - @Test func otherAgentDetectorsCoverStateTriplets() { + @Test func geminiDetection() { #expect(DetectedAgent.gemini.detectState(in: "│ Apply this change") == .blocked) #expect(DetectedAgent.gemini.detectState(in: "esc to cancel") == .working) #expect(DetectedAgent.gemini.detectState(in: "done") == .idle) + } + @Test func cursorDetection() { #expect(DetectedAgent.cursor.detectState(in: "Run command? (y) (enter)") == .blocked) #expect( DetectedAgent.cursor.detectState( @@ -174,11 +176,15 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.cursor.detectState(in: "⏳ Trusting workspace...") == .working) #expect(DetectedAgent.cursor.detectState(in: "⬡ indexing") == .working) #expect(DetectedAgent.cursor.detectState(in: "done") == .idle) + } + @Test func clineDetection() { #expect(DetectedAgent.cline.detectState(in: "Let Cline use this tool? yes") == .blocked) #expect(DetectedAgent.cline.detectState(in: "Cline is ready for your message") == .idle) #expect(DetectedAgent.cline.detectState(in: "still processing") == .working) + } + @Test func opencodeDetection() { #expect(DetectedAgent.opencode.detectState(in: "△ Permission required") == .blocked) #expect( DetectedAgent.opencode.detectState( @@ -191,12 +197,16 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.opencode.detectState(in: "esc to interrupt") == .working) #expect(DetectedAgent.opencode.detectState(in: "Do you want to continue?\nYes") == .idle) #expect(DetectedAgent.opencode.detectState(in: "done") == .idle) + } + @Test func copilotDetection() { #expect(DetectedAgent.copilot.detectState(in: "│ do you want to run this?") == .blocked) #expect(DetectedAgent.copilot.detectState(in: "esc to cancel") == .working) #expect(DetectedAgent.copilot.detectState(in: "Do you want to continue?\nYes") == .idle) #expect(DetectedAgent.copilot.detectState(in: "done") == .idle) + } + @Test func kimiDetection() { #expect(DetectedAgent.kimi.detectState(in: "approve? [y/n]") == .blocked) #expect(DetectedAgent.kimi.detectState(in: "thinking") == .working) #expect(DetectedAgent.kimi.detectState(in: "ctrl-c to cancel") == .working) @@ -246,13 +256,17 @@ struct ScreenHeuristicsTests { ) == .blocked ) #expect(DetectedAgent.kimi.detectState(in: "done") == .idle) + } + @Test func droidDetection() { #expect(DetectedAgent.droid.detectState(in: "EXECUTE\nenter to select") == .blocked) #expect(DetectedAgent.droid.detectState(in: "> Yes, allow\n> No, cancel\nUse ↑↓ to navigate") == .blocked) #expect(DetectedAgent.droid.detectState(in: "⠋ esc to stop") == .working) #expect(DetectedAgent.droid.detectState(in: "esc to stop") == .working) #expect(DetectedAgent.droid.detectState(in: "done") == .idle) + } + @Test func ampDetection() { #expect( DetectedAgent.amp.detectState( in: """ -- 2.51.2 From 9abc8584a10f7fda3925bf46b8459b33d51874be Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:09:26 +0900 Subject: [PATCH 107/528] Optimize active agent detection --- .../Clients/Terminal/TerminalClient.swift | 1 + .../Reducer/ActiveAgentsFeature.swift | 4 + .../Features/App/Reducer/AppFeature.swift | 24 ++++ .../WorktreeTerminalManager.swift | 12 ++ .../Models/WorktreeTerminalState.swift | 35 ++++-- .../AgentDetection/ProcessDetection.swift | 108 +++++++++++++++--- .../AgentDetection/ScreenHeuristics.swift | 77 +++++++------ supacodeTests/ActiveAgentsFeatureTests.swift | 2 +- .../ProcessDetectionSmokeTests.swift | 6 + supacodeTests/ScreenHeuristicsTests.swift | 41 ++++++- 10 files changed, 251 insertions(+), 59 deletions(-) diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 470758c4..232e70d9 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -44,6 +44,7 @@ struct TerminalClient { case prune(Set) case setNotificationsEnabled(Bool) case setCommandFinishedNotification(enabled: Bool, threshold: Int) + case setAgentDetectionEnabled(Bool) case setCanvasMode(Bool) case setSelectedWorktreeID(Worktree.ID?) case saveLayoutSnapshot diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 3095752c..c1bdd77b 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -59,4 +59,8 @@ struct ActiveAgentsFeature { static func maximumPanelHeight(forContainerHeight height: Double) -> Double { max(minimumPanelHeight, min(maximumPanelHeight, height - reservedSidebarListHeight)) } + + static func detectionEnabled(isPanelHidden: Bool, autoShowPanel: Bool) -> Bool { + !isPanelHidden || autoShowPanel + } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 02cbe69c..bbc66d8b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -170,11 +170,18 @@ struct AppFeature { appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") state.launchedAt = now state.repositories.launchRestoreMode = state.launchRestoreMode + let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( + isPanelHidden: state.repositories.activeAgents.isPanelHidden, + autoShowPanel: state.settings.autoShowActiveAgentsPanel + ) analyticsClient.capture("app_launched", nil) return .merge( .send(.repositories(.task)), .send(.settings(.task)), .send(.updates(.task)), + .run { _ in + await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) + }, .run { _ in await MainActor.run { NSApplication.shared.dockTile.badgeLabel = nil @@ -420,6 +427,10 @@ struct AppFeature { settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides + let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( + isPanelHidden: state.repositories.activeAgents.isPanelHidden, + autoShowPanel: settings.autoShowActiveAgentsPanel + ) if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -479,6 +490,9 @@ struct AppFeature { ) ) }, + .run { _ in + await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) + }, .run { _ in await worktreeInfoWatcher.send( .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) @@ -899,6 +913,16 @@ struct AppFeature { case .alert: return .none + case .repositories(.activeAgents(.togglePanelVisibility)): + let nextIsPanelHidden = !state.repositories.activeAgents.isPanelHidden + let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( + isPanelHidden: nextIsPanelHidden, + autoShowPanel: state.settings.autoShowActiveAgentsPanel + ) + return .run { _ in + await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) + } + case .repositories: return .none diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index b30b5803..8a7ed2b1 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -15,6 +15,7 @@ final class WorktreeTerminalManager { private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true private var commandFinishedNotificationThreshold = 10 + private var agentDetectionEnabled = true private var preferredFontSize: Float32? private let baselineFontSize: Float32 private var lastNotificationIndicatorCount: Int? @@ -146,6 +147,8 @@ final class WorktreeTerminalManager { setNotificationsEnabled(enabled) case .setCommandFinishedNotification(let enabled, let threshold): setCommandFinishedNotification(enabled: enabled, threshold: threshold) + case .setAgentDetectionEnabled(let enabled): + setAgentDetectionEnabled(enabled) case .setCanvasMode(let enabled): if enabled { terminalLogger.info("[CanvasExit] enteringCanvas previousSelectedWorktree=\(selectedWorktreeID ?? "nil")") @@ -224,6 +227,7 @@ final class WorktreeTerminalManager { enabled: commandFinishedNotificationEnabled, threshold: commandFinishedNotificationThreshold ) + state.setAgentDetectionEnabled(agentDetectionEnabled) state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } @@ -436,6 +440,14 @@ final class WorktreeTerminalManager { } } + func setAgentDetectionEnabled(_ enabled: Bool) { + guard agentDetectionEnabled != enabled else { return } + agentDetectionEnabled = enabled + for state in states.values { + state.setAgentDetectionEnabled(enabled) + } + } + func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.hasUnseenNotification == true } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 6115a194..64ea574a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -39,6 +39,7 @@ final class WorktreeTerminalState { private var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] private var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] private var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] + private var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] private var runScriptTabId: TerminalTabID? private var pendingSetupScript: Bool @@ -967,6 +968,20 @@ final class WorktreeTerminalState { commandFinishedNotificationThreshold = threshold } + func setAgentDetectionEnabled(_ enabled: Bool) { + guard agentDetectionEnabled != enabled else { return } + agentDetectionEnabled = enabled + + if enabled { + for (surfaceID, view) in surfaces { + guard let tabId = tabId(containing: surfaceID) else { continue } + startAgentDetection(for: view, tabId: tabId) + } + } else { + cleanupAllAgentDetectionState() + } + } + func clearNotificationIndicator() { markAllNotificationsRead() } @@ -1566,6 +1581,7 @@ final class WorktreeTerminalState { } private func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { + guard agentDetectionEnabled else { return } agentDetectionTasks[view.id]?.cancel() surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) terminalStateLogger.debug( @@ -1575,21 +1591,20 @@ final class WorktreeTerminalState { agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } - self.detectAgentState(for: view, tabId: tabId) + await self.detectAgentState(for: view, tabId: tabId) let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil try? await Task.sleep(for: hasAgent ? .milliseconds(300) : .milliseconds(500)) } } } - private func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) { + private func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async { let surfaceID = view.id - let now = Date() let childPID = view.bridge.childPID() let processGroupID = view.bridge.foregroundProcessGroupID() - let job = - processGroupID.flatMap { ProcessDetection.foregroundJob(processGroupID: $0) } - ?? childPID.flatMap { ProcessDetection.foregroundJob(childPID: $0) } + let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) + guard surfaces[surfaceID] != nil else { return } + let identified = job.flatMap { identifyAgentInJob($0) } let probedAgent = identified?.agent @@ -1615,8 +1630,14 @@ final class WorktreeTerminalState { return } + let now = Date() let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) - let raw = agent.detectState(in: view.bridge.readViewportText() ?? "") + let viewportText = view.bridge.readViewportText() ?? "" + let raw = await Task.detached(priority: .utility) { + agent.detectState(in: viewportText) + }.value + guard surfaces[surfaceID] != nil else { return } + var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( agent: agent, diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift index f816ff0a..34cf424b 100644 --- a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -13,7 +13,57 @@ struct ForegroundJob: Equatable, Sendable { let processes: [ForegroundProcess] } -enum ProcessDetection { +actor AgentProcessProbe { + static let shared = AgentProcessProbe() + + private struct CachedJob { + let capturedAt: Date + let job: ForegroundJob? + } + + private let cacheLifetime: TimeInterval + private var jobsByProcessGroupID: [pid_t: CachedJob] = [:] + + init(cacheLifetime: TimeInterval = 0.75) { + self.cacheLifetime = cacheLifetime + } + + func foregroundJob(processGroupID: pid_t?, childPID: pid_t?) -> ForegroundJob? { + let resolvedProcessGroupID: pid_t? + if let processGroupID, processGroupID > 0 { + resolvedProcessGroupID = processGroupID + } else if let childPID, childPID > 0 { + resolvedProcessGroupID = ProcessDetection.foregroundProcessGroupID(pid: childPID) + } else { + resolvedProcessGroupID = nil + } + + guard let resolvedProcessGroupID else { return nil } + return cachedForegroundJob(processGroupID: resolvedProcessGroupID, now: Date()) + } + + private func cachedForegroundJob(processGroupID: pid_t, now: Date) -> ForegroundJob? { + if let cached = jobsByProcessGroupID[processGroupID], + now.timeIntervalSince(cached.capturedAt) < cacheLifetime + { + return cached.job + } + + let job = ProcessDetection.foregroundJob(processGroupID: processGroupID) + jobsByProcessGroupID[processGroupID] = CachedJob(capturedAt: now, job: job) + removeExpiredJobs(now: now) + return job + } + + private func removeExpiredJobs(now: Date) { + guard jobsByProcessGroupID.count > 64 else { return } + jobsByProcessGroupID = jobsByProcessGroupID.filter { _, cached in + now.timeIntervalSince(cached.capturedAt) < cacheLifetime + } + } +} + +nonisolated enum ProcessDetection { static func foregroundJob(childPID: pid_t) -> ForegroundJob? { guard childPID > 0, let processGroupID = foregroundProcessGroupID(pid: childPID) else { return nil @@ -23,26 +73,19 @@ enum ProcessDetection { static func foregroundJob(processGroupID: pid_t) -> ForegroundJob? { guard processGroupID > 0 else { return nil } - var pids = [pid_t](repeating: 0, count: 4096) - let bytes = pids.withUnsafeMutableBufferPointer { buffer in - proc_listallpids(buffer.baseAddress, Int32(buffer.count * MemoryLayout.size)) - } - guard bytes > 0 else { return nil } - - let count = Int(bytes) / MemoryLayout.size - let processes = pids.prefix(count).compactMap { pid -> ForegroundProcess? in + let processes = processGroupPIDs(processGroupID).compactMap { pid -> ForegroundProcess? in guard pid > 0, let info = processBSDInfo(pid: pid), - pid_t(info.pbi_pgid) == processGroupID, let name = comm(from: info) else { return nil } + let argv = processArguments(pid: pid) return ForegroundProcess( pid: pid, name: name, - argv0: processArgv0Name(pid: pid), - cmdline: processCommandLine(pid: pid) + argv0: argv?.first.flatMap(basename), + cmdline: argv?.joined(separator: " ") ) } @@ -50,6 +93,33 @@ enum ProcessDetection { return ForegroundJob(processGroupID: processGroupID, processes: processes) } + static func processGroupPIDs(_ processGroupID: pid_t) -> [pid_t] { + guard processGroupID > 0 else { return [] } + var capacity = 16 + + while capacity <= 4096 { + var pids = [pid_t](repeating: 0, count: capacity) + let bytes = pids.withUnsafeMutableBufferPointer { buffer in + proc_listpids( + UInt32(PROC_PGRP_ONLY), + UInt32(processGroupID), + buffer.baseAddress, + Int32(buffer.count * MemoryLayout.size) + ) + } + guard bytes > 0 else { return [] } + + let count = Int(bytes) / MemoryLayout.size + let result = pids.prefix(count).filter { $0 > 0 } + if count < capacity { + return Array(result) + } + capacity *= 2 + } + + return [] + } + static func foregroundProcessGroupID(pid: pid_t) -> pid_t? { guard let info = processBSDInfo(pid: pid), info.e_tpgid > 0 else { return nil @@ -58,19 +128,23 @@ enum ProcessDetection { } static func processCommandLine(pid: pid_t) -> String? { - guard let buffer = kernProcargs2(pid: pid), let argv = procargs2Argv(buffer), !argv.isEmpty else { - return nil - } - return argv.joined(separator: " ") + processArguments(pid: pid)?.joined(separator: " ") } static func processArgv0Name(pid: pid_t) -> String? { - guard let buffer = kernProcargs2(pid: pid), let argv0 = procargs2Argv(buffer)?.first else { + guard let argv0 = processArguments(pid: pid)?.first else { return nil } return basename(argv0) } + static func processArguments(pid: pid_t) -> [String]? { + guard let buffer = kernProcargs2(pid: pid) else { + return nil + } + return procargs2Argv(buffer) + } + static func processBSDInfo(pid: pid_t) -> proc_bsdinfo? { var info = proc_bsdinfo() let size = MemoryLayout.size diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index f94a6bc2..7af5d408 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -1,9 +1,9 @@ import Foundation -private let agentDetectionRecentLineLimit = 24 +nonisolated private let agentDetectionRecentLineLimit = 24 extension DetectedAgent { - func detectState(in screen: String) -> AgentRawState { + nonisolated func detectState(in screen: String) -> AgentRawState { let screen = recentLines(screen, limit: agentDetectionRecentLineLimit) switch self { case .pi: @@ -32,7 +32,7 @@ extension DetectedAgent { } } -private func recentLines(_ content: String, limit: Int) -> String { +nonisolated private func recentLines(_ content: String, limit: Int) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var remainingNonBlankLines = limit var startIndex = lines.startIndex @@ -51,11 +51,11 @@ private func recentLines(_ content: String, limit: Int) -> String { return lines[startIndex...].joined(separator: "\n") } -private func detectPi(_ content: String) -> AgentRawState { +nonisolated private func detectPi(_ content: String) -> AgentRawState { content.contains("Working...") ? .working : .idle } -private func detectClaude(_ content: String) -> AgentRawState { +nonisolated private func detectClaude(_ content: String) -> AgentRawState { let lower = content.lowercased() if content.contains("⌕ Search…") || lower.contains("ctrl+r to toggle") { @@ -77,7 +77,7 @@ private func detectClaude(_ content: String) -> AgentRawState { return .idle } -private func detectCodex(_ content: String) -> AgentRawState { +nonisolated private func detectCodex(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("press enter to confirm or esc to cancel") || lower.contains("enter to submit answer") @@ -94,7 +94,7 @@ private func detectCodex(_ content: String) -> AgentRawState { return .idle } -private func detectGemini(_ content: String) -> AgentRawState { +nonisolated private func detectGemini(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("waiting for user confirmation") || content.contains("│ Apply this change") @@ -110,7 +110,7 @@ private func detectGemini(_ content: String) -> AgentRawState { return .idle } -private func detectCursor(_ content: String) -> AgentRawState { +nonisolated private func detectCursor(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("workspace trust required") || lower.contains("trust this workspace") @@ -127,20 +127,21 @@ private func detectCursor(_ content: String) -> AgentRawState { return .idle } -private func detectCline(_ content: String) -> AgentRawState { +nonisolated private func detectCline(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("let cline use this tool") || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes")) + || hasClineNumberedChoicePrompt(content) { return .blocked } - if lower.contains("cline is ready for your message") { - return .idle + if hasInterruptPattern(lower) { + return .working } - return .working + return .idle } -private func detectOpenCode(_ content: String) -> AgentRawState { +nonisolated private func detectOpenCode(_ content: String) -> AgentRawState { if content.contains("△ Permission required") || hasOpenCodeQuestionPrompt(content) { @@ -152,7 +153,7 @@ private func detectOpenCode(_ content: String) -> AgentRawState { return .idle } -private func detectCopilot(_ content: String) -> AgentRawState { +nonisolated private func detectCopilot(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("│ do you want") || (lower.contains("confirm with") && lower.contains("enter")) @@ -165,7 +166,7 @@ private func detectCopilot(_ content: String) -> AgentRawState { return .idle } -private func detectKimi(_ content: String) -> AgentRawState { +nonisolated private func detectKimi(_ content: String) -> AgentRawState { let lower = content.lowercased() let blockedPatterns = [ "allow?", "confirm?", "approve?", "proceed?", "[y/n]", "(y/n)", @@ -189,7 +190,7 @@ private func detectKimi(_ content: String) -> AgentRawState { return .idle } -private func detectDroid(_ content: String) -> AgentRawState { +nonisolated private func detectDroid(_ content: String) -> AgentRawState { let lower = content.lowercased() let hasExecute = content.contains("EXECUTE") let hasSelectionChrome = @@ -212,7 +213,7 @@ private func detectDroid(_ content: String) -> AgentRawState { return .idle } -private func detectAmp(_ content: String) -> AgentRawState { +nonisolated private func detectAmp(_ content: String) -> AgentRawState { let lower = content.lowercased() let hasWaitingForApproval = lower.contains("waiting for approval") let hasApprovalHeader = @@ -237,7 +238,7 @@ private func detectAmp(_ content: String) -> AgentRawState { return .idle } -private func contentAbovePromptBox(_ content: String) -> String { +nonisolated private func contentAbovePromptBox(_ content: String) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { return content @@ -247,13 +248,13 @@ private func contentAbovePromptBox(_ content: String) -> String { return lines[.. Bool { +nonisolated private func isBoxBorderLine(_ line: String) -> Bool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.count >= 3 else { return false } return trimmed.allSatisfy { $0 == "─" || $0 == "-" } } -private func claudeCurrentInteractionRegion(_ content: String) -> String { +nonisolated private func claudeCurrentInteractionRegion(_ content: String) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { return lines.suffix(18).joined(separator: "\n") @@ -264,7 +265,7 @@ private func claudeCurrentInteractionRegion(_ content: String) -> String { return lines[lowerBound.. Bool { +nonisolated private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { if lower.contains("do you want to proceed?") || lower.contains("would you like to proceed?") || lower.contains("waiting for permission") @@ -281,7 +282,7 @@ private func hasClaudeBlockedPrompt(content: String, lower: String) -> Bool { || (hasClaudeSelectionPrompt(content) && hasClaudeYesNoChoice(content)) } -private func hasClaudeSelectionPrompt(_ content: String) -> Bool { +nonisolated private func hasClaudeSelectionPrompt(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) return trimmed.hasPrefix("❯") @@ -290,7 +291,7 @@ private func hasClaudeSelectionPrompt(_ content: String) -> Bool { } } -private func hasClaudeYesNoChoice(_ content: String) -> Bool { +nonisolated private func hasClaudeYesNoChoice(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let line = line.trimmingCharacters(in: .whitespaces) let option = @@ -307,18 +308,28 @@ private func hasClaudeYesNoChoice(_ content: String) -> Bool { } } -private func hasKimiApprovalPanel(content: String, lower: String) -> Bool { +nonisolated private func hasClineNumberedChoicePrompt(_ content: String) -> Bool { + content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in + guard let suffix = line.range(of: " or type)") else { return false } + let prefix = line[.. Bool { lower.contains("requesting approval") || (lower.contains("approve once") && lower.contains("approve for this session") && lower.contains("reject")) || (content.contains("─ approval") && content.contains("↵ confirm")) } -private func hasKimiMoonSpinner(_ content: String) -> Bool { +nonisolated private func hasKimiMoonSpinner(_ content: String) -> Bool { let moonSpinners: Set = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] return content.contains { moonSpinners.contains($0) } } -private func hasKimiToolSpinner(content: String, lower: String) -> Bool { +nonisolated private func hasKimiToolSpinner(content: String, lower: String) -> Bool { guard lower.contains("using ") else { return false } return content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -327,7 +338,7 @@ private func hasKimiToolSpinner(content: String, lower: String) -> Bool { } } -private func hasConfirmationPrompt(_ lower: String) -> Bool { +nonisolated private func hasConfirmationPrompt(_ lower: String) -> Bool { guard let range = lower.range(of: "do you want") ?? lower.range(of: "would you like") else { @@ -337,19 +348,19 @@ private func hasConfirmationPrompt(_ lower: String) -> Bool { return after.contains("yes") || after.contains("❯") } -private func hasInterruptPattern(_ lower: String) -> Bool { +nonisolated private func hasInterruptPattern(_ lower: String) -> Bool { lower.contains("esc to interrupt") || lower.contains("ctrl+c to interrupt") || (lower.contains("esc") && lower.contains("interrupt")) } -private func hasCodexWorkingHeader(_ content: String) -> Bool { +nonisolated private func hasCodexWorkingHeader(_ content: String) -> Bool { content.split(separator: "\n").contains { line in line.trimmingCharacters(in: .whitespaces).hasPrefix("• Working (") } } -private func hasSpinnerActivity(_ content: String) -> Bool { +nonisolated private func hasSpinnerActivity(_ content: String) -> Bool { let spinnerScalars: Set = [ "·", "✱", "✲", "✳", "✴", "✵", "✶", "✷", "✸", "✹", "✺", "✻", "✼", "✽", "✾", "✿", "❀", "❁", "❂", "❃", "❇", "❈", "❉", "❊", "❋", "✢", "✣", "✤", "✥", "✦", "✧", "✨", @@ -366,14 +377,14 @@ private func hasSpinnerActivity(_ content: String) -> Bool { } } -private func hasCursorSpinner(_ content: String) -> Bool { +nonisolated private func hasCursorSpinner(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() return (trimmed.hasPrefix("⬡") || trimmed.hasPrefix("⬢")) && trimmed.contains("ing") } } -private func hasDroidSpinner(_ content: String) -> Bool { +nonisolated private func hasDroidSpinner(_ content: String) -> Bool { content.split(separator: "\n").contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() guard let first = trimmed.unicodeScalars.first else { return false } @@ -381,7 +392,7 @@ private func hasDroidSpinner(_ content: String) -> Bool { } } -private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { +nonisolated private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { let lower = content.lowercased() let hasEnterAction = lower.contains("enter confirm") diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 7a9058ee..e08c9eaf 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -37,7 +37,7 @@ struct ActiveAgentsFeatureTests { } @Test func autoShowRevealsHiddenPanelOnAgentEntry() async { - var state = ActiveAgentsFeature.State() + let state = ActiveAgentsFeature.State() state.$isPanelHidden.withLock { $0 = true } let store = TestStore(initialState: state) { ActiveAgentsFeature() diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift index d505825c..cf5b8b17 100644 --- a/supacodeTests/ProcessDetectionSmokeTests.swift +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -16,4 +16,10 @@ struct ProcessDetectionSmokeTests { #expect(!cmdline.isEmpty) #expect(!comm.isEmpty) } + + @Test func listsCurrentProcessGroupWithoutScanningAllProcesses() { + let pids = ProcessDetection.processGroupPIDs(getpgrp()) + + #expect(pids.contains(getpid())) + } } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 079615f6..06b0ba73 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -180,8 +180,47 @@ struct ScreenHeuristicsTests { @Test func clineDetection() { #expect(DetectedAgent.cline.detectState(in: "Let Cline use this tool? yes") == .blocked) + #expect( + DetectedAgent.cline.detectState( + in: """ + ⏺ 我已准备好开始。你现在希望我帮你做什么? + 1. 实现一个新功能 + 2. 排查/修复一个 bug + ╭───╮ + │ (1-5 or type) │ + ╰───╯ + / for commands · @ for files + """ + ) == .blocked + ) + #expect( + DetectedAgent.cline.detectState( + in: """ + ⠋ Acting... (3s · esc to interrupt) + 💡 Tip: Use /skills to browse and attach reusable skill files. + ╭───╮ + │ │ + ╰───╯ + / for commands · @ for files + """ + ) == .working + ) + #expect( + DetectedAgent.cline.detectState( + in: """ + ⏺ Task completed + 你好!👋 + + Start New Task (1) Exit (2) + ╭───╮ + │ │ + ╰───╯ + / for commands · @ for files + """ + ) == .idle + ) #expect(DetectedAgent.cline.detectState(in: "Cline is ready for your message") == .idle) - #expect(DetectedAgent.cline.detectState(in: "still processing") == .working) + #expect(DetectedAgent.cline.detectState(in: "Start New Task (1)") == .idle) } @Test func opencodeDetection() { -- 2.51.2 From 2d5a0e27247a0c9e9047552893357a986705c8ad Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:16:23 +0900 Subject: [PATCH 108/528] Tighten Cursor permission detection --- .../AgentDetection/ScreenHeuristics.swift | 30 ++++++++++++++++--- supacodeTests/ScreenHeuristicsTests.swift | 21 +++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 7af5d408..c1abea83 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -114,10 +114,7 @@ nonisolated private func detectCursor(_ content: String) -> AgentRawState { let lower = content.lowercased() if lower.contains("workspace trust required") || lower.contains("trust this workspace") - || lower.contains("(y) (enter)") - || lower.contains("keep (n)") - || lower.contains("skip (esc or n)") - || (lower.contains("(y)") && (lower.contains("allow") || lower.contains("run"))) + || hasCursorPermissionPrompt(content: content, lower: lower) { return .blocked } @@ -308,6 +305,31 @@ nonisolated private func hasClaudeYesNoChoice(_ content: String) -> Bool { } } +nonisolated private func hasCursorPermissionPrompt(content: String, lower: String) -> Bool { + if lower.contains("(y) (enter)") { + return true + } + + let hasPermissionHeader = + lower.contains("run this command?") + || lower.contains("run command?") + || lower.contains("not in allowlist") + || lower.contains("to allowlist?") + || lower.contains("allow execution") + guard hasPermissionHeader else { return false } + + let hasConfirmAction = content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces).lowercased() + guard trimmed.contains("(y)") else { return false } + return trimmed.contains("run") || trimmed.contains("allow") + } + let hasCancelAction = + lower.contains("skip (esc or n)") + || lower.contains("keep (n)") + + return hasConfirmAction || hasCancelAction +} + nonisolated private func hasClineNumberedChoicePrompt(_ content: String) -> Bool { content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in guard let suffix = line.range(of: " or type)") else { return false } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 06b0ba73..a716ac4c 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -163,6 +163,18 @@ struct ScreenHeuristicsTests { @Test func cursorDetection() { #expect(DetectedAgent.cursor.detectState(in: "Run command? (y) (enter)") == .blocked) + #expect( + DetectedAgent.cursor.detectState( + in: """ + Run this command? + Not in allowlist: git log --oneline --decorate -n 8 + → Run (once) (y) + Add Shell(git log) to allowlist? (tab) + Auto-run everything (shift+tab) + Skip (esc or n) + """ + ) == .blocked + ) #expect( DetectedAgent.cursor.detectState( in: """ @@ -175,6 +187,15 @@ struct ScreenHeuristicsTests { ) #expect(DetectedAgent.cursor.detectState(in: "⏳ Trusting workspace...") == .working) #expect(DetectedAgent.cursor.detectState(in: "⬡ indexing") == .working) + #expect( + DetectedAgent.cursor.detectState( + in: """ + The docs mention pressing (y) to allow a run. + This is historical output, not a prompt. + """ + ) == .idle + ) + #expect(DetectedAgent.cursor.detectState(in: "Skip (esc or n)") == .idle) #expect(DetectedAgent.cursor.detectState(in: "done") == .idle) } -- 2.51.2 From 89ac001795180a8e892d72a12de13dcee8ce132d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:22:30 +0900 Subject: [PATCH 109/528] Tighten Cursor agent alias detection --- .../AgentDetection/AgentClassifier.swift | 24 ++++++++++++++++-- supacodeTests/AgentClassifierTests.swift | 25 ++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 7170b8aa..289c1295 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -10,7 +10,7 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .codex case "gemini": return .gemini - case "agent", "cursor", "cursor-agent": + case "cursor", "cursor-agent": return .cursor case "cline": return .cline @@ -34,7 +34,7 @@ func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: St for process in job.processes { for candidate in agentCandidates(for: process) { - guard let agent = identifyAgent(processName: candidate.name) else { continue } + guard let agent = identifyAgent(candidate: candidate, process: process) else { continue } if best == nil || candidate.score > best!.score { best = AgentCandidate(score: candidate.score, agent: agent, name: candidate.name) } @@ -65,6 +65,26 @@ private func agentCandidates(for process: ForegroundProcess) -> [(name: String, return candidates } +private func identifyAgent(candidate: (name: String, score: Int), process: ForegroundProcess) -> DetectedAgent? { + if candidate.name == "agent", isCursorAgentAlias(process) { + return .cursor + } + return identifyAgent(processName: candidate.name) +} + +private func isCursorAgentAlias(_ process: ForegroundProcess) -> Bool { + let haystack = [ + process.argv0, + process.cmdline, + ] + .compactMap(\.self) + .joined(separator: " ") + .lowercased() + + return haystack.contains("cursor-agent") + || haystack.contains("cursor.app") +} + private struct AgentCandidate { let score: Int let agent: DetectedAgent diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index 34e45c76..ab8c2c34 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -9,7 +9,6 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "claude-code") == .claude) #expect(identifyAgent(processName: "codex") == .codex) #expect(identifyAgent(processName: "gemini") == .gemini) - #expect(identifyAgent(processName: "agent") == .cursor) #expect(identifyAgent(processName: "cursor") == .cursor) #expect(identifyAgent(processName: "cursor-agent") == .cursor) #expect(identifyAgent(processName: "cline") == .cline) @@ -31,8 +30,11 @@ struct AgentClassifierTests { ForegroundProcess( pid: 100, name: "agent", - argv0: "agent", - cmdline: "agent" + argv0: "/Users/onevcat/.local/bin/agent", + cmdline: """ + /Users/onevcat/.local/bin/agent --use-system-ca \ + /Users/onevcat/.local/share/cursor-agent/versions/2026.05.09-0afadcc/index.js + """ ) ] ) @@ -42,6 +44,23 @@ struct AgentClassifierTests { #expect(result.name == "agent") } + @Test func ignoresGenericAgentProcessWithoutCursorContext() { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "agent", + argv0: "agent", + cmdline: "agent --serve" + ) + ] + ) + + #expect(identifyAgent(processName: "agent") == nil) + #expect(identifyAgentInJob(job) == nil) + } + @Test func identifiesCursorAgentCommandLines() throws { let job = ForegroundJob( processGroupID: 42, -- 2.51.2 From 14cf61fb939be35595ed996155e8f726f49e00e2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:25:43 +0900 Subject: [PATCH 110/528] Address active agents review comments --- supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift | 1 + supacode/Features/Terminal/Models/WorktreeTerminalState.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index b460a40f..b534644b 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import SwiftUI diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 64ea574a..c665243c 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1724,7 +1724,7 @@ final class WorktreeTerminalState { surfaceID: surfaceID, paneIndex: paneIndex, agent: agent, - rawState: state.state, + rawState: state.fallbackState, displayState: state.displayState, lastChangedAt: state.lastChangedAt ) -- 2.51.2 From d98600c7f8532c31662f77ee93052b53933605bf Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:30:13 +0900 Subject: [PATCH 111/528] Clear Claude sticky state for other agents --- supacode/Domain/AgentDetection/PaneAgentState.swift | 1 + supacodeTests/PaneAgentStateTests.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 2e199da9..85a8a17d 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -77,6 +77,7 @@ func stabilizeAgentState( lastClaudeWorkingAt: inout Date? ) -> AgentRawState { guard agent == .claude else { + lastClaudeWorkingAt = nil return raw } diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index 8c2fcccf..89a4ecdd 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -69,6 +69,7 @@ struct PaneAgentStateTests { ) #expect(idle == .idle) + #expect(lastWorking == nil) } @Test func presenceRequiresSixMissesBeforeRelease() { -- 2.51.2 From 9e31b6db79e7a0804d2d6e12429894d443b0c344 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:40:32 +0900 Subject: [PATCH 112/528] Reduce idle agent detection polling --- supacode/Features/Terminal/Models/WorktreeTerminalState.swift | 4 +++- supacode/Infrastructure/AgentDetection/ProcessDetection.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index c665243c..2c7ef37d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -6,6 +6,8 @@ import Observation import Sharing private let terminalStateLogger = SupaLogger("TerminalState") +private let activeAgentDetectionInterval: Duration = .milliseconds(300) +private let idleAgentDetectionInterval: Duration = .seconds(2) private struct AgentDetectionDiagnostic { let tabId: TerminalTabID @@ -1593,7 +1595,7 @@ final class WorktreeTerminalState { guard let self, let view, self.surfaces[view.id] != nil else { return } await self.detectAgentState(for: view, tabId: tabId) let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil - try? await Task.sleep(for: hasAgent ? .milliseconds(300) : .milliseconds(500)) + try? await Task.sleep(for: hasAgent ? activeAgentDetectionInterval : idleAgentDetectionInterval) } } } diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift index 34cf424b..cc5966f7 100644 --- a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -181,7 +181,7 @@ nonisolated enum ProcessDetection { static func procargs2Argv(_ buffer: [UInt8]) -> [String]? { guard buffer.count >= MemoryLayout.size else { return nil } let argc = buffer.withUnsafeBytes { rawBuffer in - rawBuffer.load(as: Int32.self) + rawBuffer.loadUnaligned(as: Int32.self) } guard argc > 0 else { return nil } -- 2.51.2 From 93897312d9411fc0cf7afdce789d8b019f821e24 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 10 May 2026 20:55:24 +0900 Subject: [PATCH 113/528] Sync active agents docs with shipped state Update task log to reference the actual SF Symbols used by the footer toggle, and expand the Ghostty fork patch list so future upgrades cherry-pick all three commits, not just the first one. --- doc-onevcat/active-agents-panel-task-log.md | 2 +- doc-onevcat/fork-sync-ghostty.md | 22 ++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/doc-onevcat/active-agents-panel-task-log.md b/doc-onevcat/active-agents-panel-task-log.md index 6dcc65ce..cad8b10f 100644 --- a/doc-onevcat/active-agents-panel-task-log.md +++ b/doc-onevcat/active-agents-panel-task-log.md @@ -23,7 +23,7 @@ - The Active Agents panel height is persisted globally but visually capped by the sidebar container height, reserving at least 200 pt for the repository list. - Agent display names intentionally use short command-style lowercase tokens (`pi`, `claude`, `codex`, `kimi`, etc.) because the panel is a compact terminal-status surface, not product branding. - Screen heuristics are exposed as `DetectedAgent.detectState(in:)` so detection behavior stays attached to the identified agent while the per-agent detectors remain private pure functions. -- The Active Agents footer toggle uses stable `eye` / `eye.slash` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. +- The Active Agents footer toggle uses stable `person.crop.rectangle.stack` / `person.crop.rectangle.stack.fill` SF Symbols after the previous bottom-panel symbol rendered empty in the hidden state on the tested system. - Added DEBUG-only agent detection diagnostics for child PID lookup, foreground process group, candidate processes, identified/retained agent, raw screen state, and stabilized state after manual testing showed no agents appearing in the panel. - Added `ghostty_surface_foreground_process_group` to the Ghostty fork and switched Swift detection to prefer Ghostty's pty foreground process group over `proc_bsdinfo.e_tpgid`, which was nil for the shell PID during manual testing. diff --git a/doc-onevcat/fork-sync-ghostty.md b/doc-onevcat/fork-sync-ghostty.md index dc7553cd..31e72ba8 100644 --- a/doc-onevcat/fork-sync-ghostty.md +++ b/doc-onevcat/fork-sync-ghostty.md @@ -11,11 +11,23 @@ Prowl embeds GhosttyKit from `ThirdParty/ghostty`. The submodule points to the ` Each patched branch starts at the matching upstream tag and only adds onevcat patches. Do not rewrite an existing patched branch after publishing it. -## Current Patch - -- Commit: `76dce319f55db097b2b7ae3cad2f6267475936f0` -- Summary: expose `ghostty_surface_pid(ghostty_surface_t)` from the embedded C API. -- Behavior: returns the local surface child process PID, or `0` when unavailable or exited. +## Current Patches + +Listed in topological order on `release/v1.3.1-patched`. When upgrading to a new +upstream tag, cherry-pick all of them. + +1. `76dce319f55db097b2b7ae3cad2f6267475936f0` — `embedded: expose surface child PID` + - Adds `ghostty_surface_pid(ghostty_surface_t)` to the embedded C API. + - Returns the local surface child process PID, or `0` when unavailable or exited. +2. `a284127166ce76d872320d7dfa2a5c57268be9de` — `embedded: expose surface foreground process group` + - Adds `ghostty_surface_foreground_process_group(ghostty_surface_t)` to the + embedded C API for callers that need the pty's foreground job, not just the + shell PID. +3. `fe714860c12da41442b63135d09ba80e293b66ad` — `surface: use libc tcgetpgrp for foreground group` + - Switches the foreground-group lookup from `proc_bsdinfo.e_tpgid` to + `tcgetpgrp` on the pty fd, which is more reliable when the shell PID + itself is not the controlling process. + - This is the commit the submodule currently points at. ## Upgrade To A New Ghostty Tag -- 2.51.2 From 2cfdbdfca0986fb1f19f191d2a66929d9763dc53 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 20:24:11 +0900 Subject: [PATCH 114/528] Animate repo header color dot on hover The color dot used to snap to a new position when hover-only action buttons appeared in the sidebar repo header. Wrap the hover state toggle in a 0.15s easeOut animation so the dot slides into place and buttons fade in/out smoothly. Skip the animation when Reduce Motion is enabled. --- .../Repositories/Views/RepositorySectionView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 4b13eb6c..45102122 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -15,6 +15,7 @@ struct RepositorySectionView: View { let onRepositorySelected: () -> Void @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isHovering = false @Shared(.repositoryAppearances) private var repositoryAppearances @@ -198,7 +199,15 @@ struct RepositorySectionView: View { } } } - .onHover { isHovering = $0 } + .onHover { hovering in + if reduceMotion { + isHovering = hovering + } else { + withAnimation(.easeOut(duration: 0.15)) { + isHovering = hovering + } + } + } .onTapGesture { onRepositorySelected() } -- 2.51.2 From 7c12e538031d16dc5196f34994d9efbfd843ed5f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 20:39:44 +0900 Subject: [PATCH 115/528] Trim agent detection log volume Three TerminalState agent-detection log sites fired per poll or per surface creation, relying on a message-content dedup to stay quiet. Active shells still churned the diagnostic string each command, and the "detection started" line carried no diagnostic value. Keep only the two meaningful transitions: agent lost (previously detected, now gone) and agent state change (identity or stabilized state flips). Raw/seen oscillation no longer logs. --- .../Models/WorktreeTerminalState.swift | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2c7ef37d..32efc85f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1586,10 +1586,6 @@ final class WorktreeTerminalState { guard agentDetectionEnabled else { return } agentDetectionTasks[view.id]?.cancel() surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) - terminalStateLogger.debug( - "agent detection started worktree=\(worktree.name) " - + "surface=\(view.id.uuidString.prefix(8)) tab=\(tabId.rawValue.uuidString.prefix(8))" - ) agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } @@ -1615,19 +1611,23 @@ final class WorktreeTerminalState { agentDetectionPresenceBySurface[surfaceID] = presence guard let agent else { - logAgentDetectionDiagnostic( - surfaceID: surfaceID, - diagnostic: AgentDetectionDiagnostic( - tabId: tabId, - childPID: childPID, - processGroupID: processGroupID, - job: job, - identified: identified, - retainedAgent: nil, - raw: nil, - stabilized: nil + // Only log the moment we lose a previously detected agent; pre-agent + // shells churn process lists every command and would otherwise spam. + if surfaceAgentStates[surfaceID]?.detectedAgent != nil { + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + processGroupID: processGroupID, + job: job, + identified: identified, + retainedAgent: nil, + raw: nil, + stabilized: nil + ) ) - ) + } removeAgentEntryIfNeeded(surfaceID: surfaceID) return } @@ -1670,19 +1670,24 @@ final class WorktreeTerminalState { seen: seen, lastChangedAt: lastChangedAt ) - logAgentDetectionDiagnostic( - surfaceID: surfaceID, - diagnostic: AgentDetectionDiagnostic( - tabId: tabId, - childPID: childPID, - processGroupID: processGroupID, - job: job, - identified: identified, - retainedAgent: agent, - raw: raw, - stabilized: stabilized + // Limit logging to meaningful transitions — agent identity or + // stabilized state changes. Raw oscillation and `seen` flips are + // routine and would otherwise dominate the log stream. + if previous.detectedAgent != agent || previous.state != stabilized { + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + processGroupID: processGroupID, + job: job, + identified: identified, + retainedAgent: agent, + raw: raw, + stabilized: stabilized + ) ) - ) + } guard next != previous else { return } surfaceAgentStates[surfaceID] = next emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) -- 2.51.2 From 38a80e25b13f54f2df65e5fcecfe2f603525f380 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 21:44:57 +0900 Subject: [PATCH 116/528] bump v2026.5.11 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 3da337cf..6bb4d23e 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260509; + CURRENT_PROJECT_VERSION = 20260511; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.9; + MARKETING_VERSION = 2026.5.11; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260509; + CURRENT_PROJECT_VERSION = 20260511; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.9; + MARKETING_VERSION = 2026.5.11; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260509; + CURRENT_PROJECT_VERSION = 20260511; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.9; + MARKETING_VERSION = 2026.5.11; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260509; + CURRENT_PROJECT_VERSION = 20260511; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.9; + MARKETING_VERSION = 2026.5.11; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index b2f74ced..263dde49 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.9" + public static let current = "2026.5.11" } -- 2.51.2 From 14d58da1057661bd1b0285d66ebb24877bb6e440 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 21:44:58 +0900 Subject: [PATCH 117/528] Update CHANGELOG for 2026.5.11 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 205e8a9d..adfd7106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2026.5.11](https://github.com/onevcat/Prowl/releases/tag/v2026.5.11) + +This release centers on a new Active Agents panel that gives you a live view of every coding agent running across your worktrees. + +### New + +- **Active Agents panel**: a resizable sidebar panel that tracks every running coding agent — Claude, Codex, Cursor, Kimi, Cline, Gemini, Copilot, Droid, Amp, Pi, and more — across all worktrees, tabs, and split panes. Each row shows the worktree, branch, and current status (working, blocked, idle, or done). Click a row to jump directly to that pane. Toggle the panel with `⌘⌥P` or the button in the sidebar footer. +- **Auto-show on agent detection**: a new toggle in Settings › Appearance › Active Agents opens the panel automatically whenever an agent is detected (off by default). + +### Fixed + +- In Shelf mode, holding `⌘` now shows `⌘1`–`⌘9` jump glyphs only on the open book's tabs. Closed-book tabs keep their icons, no longer dim, and continue to show the close button on hover while `⌘` is held. + +### Improved + +- Hovering a repository header in the sidebar animates the color dot smoothly as the action buttons appear, instead of snapping. The animation is skipped when Reduce Motion is enabled. + ## [2026.5.9](https://github.com/onevcat/Prowl/releases/tag/v2026.5.9) This release adds browser-style worktree navigation, inline tab title editing, dynamic window titles, and smarter notification handling, alongside loading-state polish and several notable bug fixes. -- 2.51.2 From 3e22060cb9878532d90519793451910e583443dc Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 12 May 2026 21:25:22 +0900 Subject: [PATCH 118/528] Honor Ghostty split-divider-color and prowl-split-divider-width Plumb Ghostty's native split-divider-color through GhosttyRuntime to the SwiftUI split tree (Terminal tabs, Shelf, Canvas) so user themes can finally restyle the pane separator. Width is not exposed by Ghostty so Prowl parses prowl-split-divider-width from the primary config file instead of carrying another fork patch. Closes #278 --- .../Canvas/Views/CanvasCardView.swift | 2 + .../Features/Canvas/Views/CanvasView.swift | 2 + .../Shelf/Views/ShelfOpenBookView.swift | 2 + .../WorktreeTerminalManager.swift | 5 ++ .../Features/Terminal/Views/SplitView.swift | 7 +- .../Views/TerminalSplitTreeView.swift | 10 ++- .../Views/WorktreeTerminalTabsView.swift | 2 + .../Ghostty/GhosttyRuntime.swift | 53 ++++++++++++ ...GhosttyRuntimeSplitDividerWidthTests.swift | 86 +++++++++++++++++++ 9 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 supacodeTests/GhosttyRuntimeSplitDividerWidthTests.swift diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index c8c6c9e8..17349daf 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -18,6 +18,7 @@ struct CanvasCardView: View { let tree: SplitTree let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) + var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let isFocused: Bool let isSelected: Bool let hasUnseenNotification: Bool @@ -232,6 +233,7 @@ struct CanvasCardView: View { pinnedSize: cardSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: { _ in false }, action: onSplitOperation ) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 9383ea16..559011fd 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -91,6 +91,7 @@ struct CanvasView: View { let screenCenter = screenPosition(for: resized.center) let cardTotalHeight = resized.size.height + titleBarHeight let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() + let splitDivider = terminalManager.splitDividerAppearance() let repositoryAppearance = appearance(for: state.repositoryRootURL) let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) @@ -103,6 +104,7 @@ struct CanvasView: View { tree: tree, activeSurfaceID: state.activeSurfaceID(for: tab.id), unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index 4472bebb..b24796f5 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -25,6 +25,7 @@ struct ShelfOpenBookView: View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() Group { if let selectedId = state.tabManager.selectedTabId { TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in @@ -32,6 +33,7 @@ struct ShelfOpenBookView: View { tree: state.splitTree(for: tabId), activeSurfaceID: state.activeSurfaceID(for: tabId), unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: { surfaceID in state.hasUnseenNotification(forSurfaceID: surfaceID) }, diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 8a7ed2b1..7a7467c8 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -498,6 +498,11 @@ final class WorktreeTerminalManager { return (runtime.unfocusedSplitFill(), runtime.unfocusedSplitOverlayOpacity()) } + func splitDividerAppearance() -> (color: Color?, width: CGFloat?) { + guard let runtime else { return (nil, nil) } + return (runtime.splitDividerColor(), runtime.splitDividerWidth()) + } + func syncPreferredFontSize(from worktreeID: Worktree.ID) { guard let state = states[worktreeID] else { return } let fontSize = state.focusedFontSize() diff --git a/supacode/Features/Terminal/Views/SplitView.swift b/supacode/Features/Terminal/Views/SplitView.swift index abeedc30..12db4dab 100644 --- a/supacode/Features/Terminal/Views/SplitView.swift +++ b/supacode/Features/Terminal/Views/SplitView.swift @@ -10,7 +10,10 @@ struct SplitView: View { let onEqualize: () -> Void let minSize: CGFloat = 10 @Binding var split: CGFloat - private let splitterVisibleSize: CGFloat = 1 + static var defaultVisibleSize: CGFloat { 1 } + // Visible thickness of the divider bar. The invisible hitbox stays constant + // so resize ergonomics don't depend on the visible thickness. + let splitterVisibleSize: CGFloat private let splitterInvisibleSize: CGFloat = 6 var body: some View { @@ -45,6 +48,7 @@ struct SplitView: View { _ direction: Direction, _ split: Binding, dividerColor: Color, + dividerVisibleSize: CGFloat = Self.defaultVisibleSize, resizeIncrements: CGSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R), @@ -53,6 +57,7 @@ struct SplitView: View { self.direction = direction self._split = split self.dividerColor = dividerColor + self.splitterVisibleSize = dividerVisibleSize self.resizeIncrements = resizeIncrements self.left = left() self.right = right() diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index ad01d629..4c7111b3 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -8,6 +8,7 @@ struct TerminalSplitTreeView: View { var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) + var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (Operation) -> Void @@ -33,6 +34,7 @@ struct TerminalSplitTreeView: View { pinnedSize: pinnedSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: hasNotification, action: action ) @@ -52,6 +54,7 @@ struct TerminalSplitTreeView: View { var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) + var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (Operation) -> Void @@ -86,7 +89,8 @@ struct TerminalSplitTreeView: View { set: { action(.resize(node: node, ratio: Double($0))) }), - dividerColor: Color(nsColor: .separatorColor), + dividerColor: splitDivider.color ?? Color(nsColor: .separatorColor), + dividerVisibleSize: splitDivider.width ?? SplitView.defaultVisibleSize, resizeIncrements: .init(width: 1, height: 1), left: { SubtreeView( @@ -94,6 +98,7 @@ struct TerminalSplitTreeView: View { pinnedSize: leftPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: hasNotification, action: action ) @@ -104,6 +109,7 @@ struct TerminalSplitTreeView: View { pinnedSize: rightPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: hasNotification, action: action ) @@ -375,6 +381,7 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) + var splitDivider: (color: Color?, width: CGFloat?) = (nil, nil) let hasNotification: (UUID) -> Bool let action: (TerminalSplitTreeView.Operation) -> Void @@ -389,6 +396,7 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { tree: tree, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: hasNotification, action: action ) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 3cfd2ff2..fa2ba54e 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -14,6 +14,7 @@ struct WorktreeTerminalTabsView: View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() VStack(spacing: 0) { TerminalTabBarView( manager: state.tabManager, @@ -53,6 +54,7 @@ struct WorktreeTerminalTabsView: View { tree: state.splitTree(for: tabId), activeSurfaceID: state.activeSurfaceID(for: tabId), unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, hasNotification: { surfaceID in state.hasUnseenNotification(forSurfaceID: surfaceID) }, diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index ce89df2c..6e0cc908 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -968,6 +968,59 @@ final class GhosttyRuntime { return nil } + func splitDividerColor() -> Color? { + guard let config else { return nil } + var color = ghostty_config_color_s() + let key = "split-divider-color" + guard ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) else { + return nil + } + return Color(nsColor: NSColor(ghostty: color)) + } + + /// Reads a Prowl-specific override for the visible divider thickness from the + /// primary Ghostty config file (the one returned by `ghostty_config_open_path`). + /// Ghostty itself has no such option (and its own divider size is hardcoded), + /// so we layer a `prowl-split-divider-width = N` directive on top of the + /// user's existing Ghostty config to avoid carrying another fork patch. + func splitDividerWidth() -> CGFloat? { + Self.parseProwlSplitDividerWidth(at: Self.ghosttyConfigPath()) + } + + nonisolated static func ghosttyConfigPath() -> String? { + let configStr = ghostty_config_open_path() + defer { ghostty_string_free(configStr) } + guard let ptr = configStr.ptr, configStr.len > 0 else { return nil } + let path = String(data: Data(bytes: ptr, count: Int(configStr.len)), encoding: .utf8) + guard let path, !path.isEmpty else { return nil } + return path + } + + nonisolated static func parseProwlSplitDividerWidth(at path: String?) -> CGFloat? { + guard let path else { return nil } + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + return parseProwlSplitDividerWidth(from: contents) + } + + nonisolated static func parseProwlSplitDividerWidth(from contents: String) -> CGFloat? { + let key = "prowl-split-divider-width" + var resolved: CGFloat? + for rawLine in contents.split(whereSeparator: \.isNewline) { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + guard let equalsIndex = trimmed.firstIndex(of: "=") else { continue } + let lhs = trimmed[.. NSColor { backgroundColorFromConfig() ?? NSColor.windowBackgroundColor } diff --git a/supacodeTests/GhosttyRuntimeSplitDividerWidthTests.swift b/supacodeTests/GhosttyRuntimeSplitDividerWidthTests.swift new file mode 100644 index 00000000..3a38ab5b --- /dev/null +++ b/supacodeTests/GhosttyRuntimeSplitDividerWidthTests.swift @@ -0,0 +1,86 @@ +import CoreGraphics +import Testing + +@testable import supacode + +struct GhosttyRuntimeSplitDividerWidthTests { + @Test func parsesBareIntegerValue() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: """ + font-size = 14 + prowl-split-divider-width = 3 + background = #000000 + """) + + #expect(parsed == 3) + } + + @Test func parsesDecimalValue() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "prowl-split-divider-width = 1.5\n") + + #expect(parsed == 1.5) + } + + @Test func toleratesExtraWhitespaceAroundEquals() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: " prowl-split-divider-width = 2 ") + + #expect(parsed == 2) + } + + @Test func ignoresCommentedDirective() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "# prowl-split-divider-width = 5") + + #expect(parsed == nil) + } + + @Test func returnsNilWhenKeyAbsent() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: """ + font-size = 14 + background = #000000 + """) + + #expect(parsed == nil) + } + + @Test func ignoresNonNumericValue() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "prowl-split-divider-width = wide") + + #expect(parsed == nil) + } + + @Test func lastDeclarationWins() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: """ + prowl-split-divider-width = 1 + prowl-split-divider-width = 4 + """) + + #expect(parsed == 4) + } + + @Test func clampsExcessiveValues() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "prowl-split-divider-width = 9999") + + #expect(parsed == 32) + } + + @Test func clampsNegativeValuesToZero() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "prowl-split-divider-width = -3") + + #expect(parsed == 0) + } + + @Test func ignoresUnrelatedProwlPrefixedKeys() { + let parsed = GhosttyRuntime.parseProwlSplitDividerWidth( + from: "prowl-split-divider-color = #ff0000") + + #expect(parsed == nil) + } +} -- 2.51.2 From c791164bfafae7728272c390688e3ce1640b4e0b Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 12 May 2026 22:36:00 +0900 Subject: [PATCH 119/528] Link demo video from README header --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6536355f..8bec03f1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@

Download · + Watch Demo + · Website · brew install --cask onevcat/tap/prowl -- 2.51.2 From eea75890856a2b1aef341a40c181a97a63946a91 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 13 May 2026 20:44:32 +0900 Subject: [PATCH 120/528] Detect Claude blocked when cursor sits at the top of a long menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The +11 line look-ahead from the prompt missed footer markers like "Chat about this" when the menu was tall (wrapped descriptions plus status bar below). Extending the region down to the end of the recent buffer keeps the stale-prompt protection above ❯ while ensuring the trailing markers are always visible. Fixes #281. --- .../AgentDetection/ScreenHeuristics.swift | 3 +- supacodeTests/ScreenHeuristicsTests.swift | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index c1abea83..44b4b6ad 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -258,8 +258,7 @@ nonisolated private func claudeCurrentInteractionRegion(_ content: String) -> St } let lowerBound = max(lines.startIndex, promptIndex - 10) - let upperBound = min(lines.endIndex, promptIndex + 11) - return lines[lowerBound.. Bool { diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index a716ac4c..caaf3f50 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -118,6 +118,34 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeDetectsBlockedWhenFirstOptionSelectedInLongMenu() { + #expect( + DetectedAgent.claude.detectState( + in: """ + 需要决策:/release 跳进去发现 APK 没有链时,怎么走接? + + ❯ 1. 自动 release: scripts/build-bridge.sh (Recommended) + /release 路径检及发现 APK 类失败链通,自动执行 build-bridge.sh 后继续。 + 便交不会进入入想运能走链。 + 缺点: /release 隐含使用 JDK + Android SDK + 清看的语述置自动应才进。 + 2. Pre-flight 重新链: 不动动作 + Step 1: 检查相关 /release 提示具子选定有应作 build-bridge.sh,从是 + scripts/build-bridge.sh。 + 缺点: 严格、明确、不会有什么动作,但有手动确认。 + 3. Pre-flight 只检查: 例丁检发标 + 选择 APK 检证,看丁路否构建?[Y/n] + 确即标当链全式动连验。 + 4. Type something. + 5. Chat about this + ────────────────────────────────────────────────────────────────────────────── + [Opus 4.7 (1M context) | Max] █░░░░░░░░░ 8% | Prowl git:(branch*) | 2 CLAUDE.md + ⏵⏵ bypass permissions on (shift+tab to cycle) + """ + ) == .blocked + ) + } + @Test func claudeDoesNotTreatHistoryInputAndBranchNameAsPermissionPrompt() { #expect( DetectedAgent.claude.detectState( -- 2.51.2 From 95fc3bb831baf05b957bafe1468d242ce1a1138e Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 13 May 2026 21:13:39 +0900 Subject: [PATCH 121/528] Tag Sentry events with the PostHog install identifier Sentry's auto-generated installation UUID never matched the PostHog distinct_id, so digging into a memory or crash report meant pivoting through device + release tags. Setting the Sentry user id to `InstallIdentifier.current` lets both systems be cross-referenced by the same UUID per install. --- supacode/App/supacodeApp.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 89d3e5b1..a98c34a2 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -122,6 +122,10 @@ struct SupacodeApp: App { options.tracesSampleRate = 0.05 options.enableAppHangTracking = false } + // Match the Sentry user id to the PostHog distinct id so an event in + // one system can be traced to the same install in the other. + let sentryUser = Sentry.User(userId: InstallIdentifier.current) + SentrySDK.setUser(sentryUser) } if initialSettings.analyticsEnabled, let apiKey = infoPlistSecret(infoDictionary, key: "ProwlPostHogAPIKey"), -- 2.51.2 From 669989f0a1f54a8f3db96e31eea917d5971196f4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 13 May 2026 22:33:30 +0900 Subject: [PATCH 122/528] Stop leaking task allocations per agent detection tick `detectAgentState` was wrapping the pure-function `detectState` call in `Task.detached(priority: .utility) { ... }.value` for no real benefit: the function is `nonisolated`, runs in well under a millisecond on a 24-line viewport, and didn't need to leave the MainActor caller's suspension point. Each detection tick (300 ms or 2 s per surface) was instead leaving an unreferenced Swift task allocation (1024 B task stack + 320 B allocator overflow + closure capture) behind that never reached ARC. In long sessions this added up to hundreds of MB: a live 25 h instance was holding 38 k+ uncollected task stacks and `leaks` reported 460 MB of unreferenced bytes against it, with no other plausible source in the post-5.10 diff. Inlining the call removes the dispatch entirely and resolves the post-2026.5.11 surge in `memory_threshold_4096mb` PostHog events. --- .../Terminal/Models/WorktreeTerminalState.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 32efc85f..bdcc52eb 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1635,9 +1635,13 @@ final class WorktreeTerminalState { let now = Date() let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) let viewportText = view.bridge.readViewportText() ?? "" - let raw = await Task.detached(priority: .utility) { - agent.detectState(in: viewportText) - }.value + // `detectState` is a `nonisolated` pure function that runs in well under a + // millisecond on a 24-line viewport, so the prior `Task.detached` hop + // bought nothing but allocator churn. In long sessions, each detection + // tick (300 ms or 2 s per surface) was leaving a task stack + closure + // capture behind that never reached ARC; over a 24 h session this added + // up to hundreds of MB of unreferenced allocations. + let raw = agent.detectState(in: viewportText) guard surfaces[surfaceID] != nil else { return } var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] -- 2.51.2 From 1691ace55c4a6905f0a6803cf016ade5f51e64ba Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 13 May 2026 22:34:22 +0900 Subject: [PATCH 123/528] Profile the supacode scheme under Debug Release Profile builds fail to launch under Instruments because the prebuilt Sparkle.framework carries a different Team ID than the ad-hoc-signed app binary; dyld refuses the load. Switching the Profile action to the Debug configuration avoids re-signing and keeps the "Profile from Xcode" workflow usable for ad-hoc memory investigations like the one that uncovered the agent-detection task leak. --- supacode.xcodeproj/xcshareddata/xcschemes/supacode.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode.xcodeproj/xcshareddata/xcschemes/supacode.xcscheme b/supacode.xcodeproj/xcshareddata/xcschemes/supacode.xcscheme index 3af070aa..8d5188dd 100644 --- a/supacode.xcodeproj/xcshareddata/xcschemes/supacode.xcscheme +++ b/supacode.xcodeproj/xcshareddata/xcschemes/supacode.xcscheme @@ -64,7 +64,7 @@ Date: Thu, 14 May 2026 01:23:05 +0900 Subject: [PATCH 124/528] Backport Ghostty text free ABI fix --- ThirdParty/ghostty | 2 +- doc-onevcat/fork-sync-ghostty.md | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ThirdParty/ghostty b/ThirdParty/ghostty index fe714860..48365577 160000 --- a/ThirdParty/ghostty +++ b/ThirdParty/ghostty @@ -1 +1 @@ -Subproject commit fe714860c12da41442b63135d09ba80e293b66ad +Subproject commit 48365577c1ae8e422c0dd90489921f07b9f79171 diff --git a/doc-onevcat/fork-sync-ghostty.md b/doc-onevcat/fork-sync-ghostty.md index 31e72ba8..99a46742 100644 --- a/doc-onevcat/fork-sync-ghostty.md +++ b/doc-onevcat/fork-sync-ghostty.md @@ -27,6 +27,11 @@ upstream tag, cherry-pick all of them. - Switches the foreground-group lookup from `proc_bsdinfo.e_tpgid` to `tcgetpgrp` on the pty fd, which is more reliable when the shell PID itself is not the controlling process. +4. `48365577c1ae8e422c0dd90489921f07b9f79171` — `Backport Ghostty text free ABI fix` + - Backports upstream `ghostty-org/ghostty#12025` before an upstream tag that includes it exists. + - Keeps the public `ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*)` + API shape and fixes the Zig export to accept the unused surface parameter. + - Drop this patch when upgrading to an upstream tag that contains `4803d58`. - This is the commit the submodule currently points at. ## Upgrade To A New Ghostty Tag @@ -58,4 +63,14 @@ Do not force-push `release/v*-patched` branches. If a cherry-pick needs repair, ## Build Note -On macOS 26.3.1 with Zig 0.15.2, native `zig build` can fail before running Ghostty's build script because Zig links the build runner with `-platform_version macos 26.3.1 26.4` and fails to resolve libSystem symbols. Direct `zig build-exe -target aarch64-macos.15.0 --sysroot "$(xcrun --sdk macosx --show-sdk-path)"` works, so this is a local Zig host-target/toolchain issue rather than a Ghostty patch syntax error. Re-run `make sync-ghostty` after the Zig toolchain issue is resolved. +Build GhosttyKit with Xcode 26.3: + +```bash +DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty +``` + +Build Prowl itself with the current Xcode, typically Xcode 26.4: + +```bash +DEVELOPER_DIR=/Applications/Xcode-26.4.1.app/Contents/Developer make build-app +``` -- 2.51.2 From f2fef0877037155203ef96c29f094e908f4cc4b2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 14 May 2026 02:05:25 +0900 Subject: [PATCH 125/528] bump v2026.5.14 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 6bb4d23e..3c73c40d 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260511; + CURRENT_PROJECT_VERSION = 20260514; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.11; + MARKETING_VERSION = 2026.5.14; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260511; + CURRENT_PROJECT_VERSION = 20260514; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.11; + MARKETING_VERSION = 2026.5.14; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260511; + CURRENT_PROJECT_VERSION = 20260514; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.11; + MARKETING_VERSION = 2026.5.14; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260511; + CURRENT_PROJECT_VERSION = 20260514; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.11; + MARKETING_VERSION = 2026.5.14; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 263dde49..9ffac7c6 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.11" + public static let current = "2026.5.14" } -- 2.51.2 From f9c957853f6241fe0aad9d1c88d0d3c59828f7f9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 14 May 2026 02:05:27 +0900 Subject: [PATCH 126/528] Update CHANGELOG for 2026.5.14 --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adfd7106..f28cd7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026.5.14](https://github.com/onevcat/Prowl/releases/tag/v2026.5.14) + +This release focuses on memory stability after the active-agent detection introduced in 2026.5.11. + +### New + +- Split pane dividers now respect your Ghostty `split-divider-color` setting. To also control the divider width, add `prowl-split-divider-width = N` (in points, 0–32) to your Ghostty config file. + +### Fixed + +- Fixed a major memory leak in the Ghostty terminal layer where viewport text buffers were never freed, causing physical memory to grow at roughly 100–200 MB/h on long-running sessions. +- Fixed an additional leak introduced in 2026.5.11 where the active-agent detection loop allocated a Swift task per tick, adding to the footprint growth over time. +- Agent "blocked" state is now correctly detected when the cursor sits on the first option of a tall interactive menu (e.g. a long `/` command list or permission prompt in Claude). + ## [2026.5.11](https://github.com/onevcat/Prowl/releases/tag/v2026.5.11) This release centers on a new Active Agents panel that gives you a live view of every coding agent running across your worktrees. -- 2.51.2 From f5dbf29aa34479183fdd5ec51e7dc19bf9e529e0 Mon Sep 17 00:00:00 2001 From: Norvon Date: Thu, 14 May 2026 11:40:29 +0800 Subject: [PATCH 127/528] fix: improve sidebar branch name truncation --- supacode/Features/Repositories/Views/WorktreeRow.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 229c2467..b705a8a1 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -73,7 +73,9 @@ struct WorktreeRow: View { .font(.body) .foregroundStyle(nameColor) .lineLimit(1) - .truncationMode(.tail) + .truncationMode(.middle) + .layoutPriority(1) + .help(name) Spacer(minLength: 4) if isHovered, pinAction != nil { Button { -- 2.51.2 From 623392d50eca28cf1018fa295a0989da6326ed81 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 14 May 2026 09:41:54 +0900 Subject: [PATCH 128/528] Fix command palette query focus --- .../Clients/Terminal/TerminalClient.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 30 +++- .../Views/CommandPaletteOverlayView.swift | 166 +++++++++++++++--- .../WorktreeTerminalManager.swift | 2 + .../AppFeatureCommandPaletteTests.swift | 145 +++++++++++++++ 5 files changed, 320 insertions(+), 24 deletions(-) diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 232e70d9..e7b86806 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -41,6 +41,7 @@ struct TerminalClient { case navigateSearchNext(Worktree) case navigateSearchPrevious(Worktree) case endSearch(Worktree) + case focusSelectedTab(Worktree) case prune(Set) case setNotificationsEnabled(Bool) case setCommandFinishedNotification(enabled: Bool, threshold: Int) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index bbc66d8b..8c7ffa7c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -124,6 +124,13 @@ struct AppFeature { return ["session_duration_seconds": seconds] } + private func restoreTerminalFocusEffect(for worktree: Worktree?) -> Effect { + guard let worktree else { return .none } + return .run { _ in + await terminalClient.send(.focusSelectedTab(worktree)) + } + } + static func sessionDurationSeconds(launchedAt: Date?, now: Date) -> Int? { guard let launchedAt else { return nil } return max(0, Int(now.timeIntervalSince(launchedAt))) @@ -932,11 +939,22 @@ struct AppFeature { case .updates: return .none + case .commandPalette(.setPresented(false)): + guard state.commandPalette.isPresented else { return .none } + return restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + + case .commandPalette(.togglePresented): + guard state.commandPalette.isPresented else { return .none } + return restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + case .commandPalette(.delegate(.selectWorktree(let worktreeID))): return .send(.repositories(.selectWorktree(worktreeID))) case .commandPalette(.delegate(.checkForUpdates)): - return .send(.updates(.checkForUpdates)) + return .merge( + .send(.updates(.checkForUpdates)), + restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + ) case .commandPalette(.delegate(.openSettings)): return .merge( @@ -962,7 +980,10 @@ struct AppFeature { return .send(.repositories(.selectArchivedWorktrees)) case .commandPalette(.delegate(.refreshWorktrees)): - return .send(.repositories(.refreshWorktrees)) + return .merge( + .send(.repositories(.refreshWorktrees)), + restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + ) case .commandPalette(.delegate(.jumpToLatestUnread)): return .send(.jumpToLatestUnread) @@ -1014,7 +1035,10 @@ struct AppFeature { #if DEBUG case .commandPalette(.delegate(.debugTestToast(let toast))): - return .send(.repositories(.showToast(toast))) + return .merge( + .send(.repositories(.showToast(toast))), + restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + ) case .commandPalette(.delegate(.debugSimulateUpdateFound)): return .send(.updates(.debugSimulateUpdateFound)) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 61377a32..997d9585 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -7,7 +7,8 @@ struct CommandPaletteOverlayView: View { @Bindable var store: StoreOf let items: [CommandPaletteItem] let resolvedKeybindings: ResolvedKeybindingMap - @FocusState private var isQueryFocused: Bool + @State private var isQueryFocused = false + @State private var queryFocusTask: Task? @State private var hoveredID: CommandPaletteItem.ID? @State private var filteredItems: [CommandPaletteItem] = [] @@ -37,7 +38,7 @@ struct CommandPaletteOverlayView: View { items: filteredItems, resolvedKeybindings: resolvedKeybindings, hoveredID: $hoveredID, - isQueryFocused: _isQueryFocused, + isQueryFocused: isQueryFocused, onEvent: { event in switch event { case .exit: @@ -54,7 +55,7 @@ struct CommandPaletteOverlayView: View { ) .zIndex(1) .task { - isQueryFocused = store.isPresented + focusQueryField() } Spacer(minLength: 0) @@ -70,11 +71,14 @@ struct CommandPaletteOverlayView: View { } } .onChange(of: store.isPresented) { _, newValue in - isQueryFocused = newValue if newValue { let updatedItems = refreshFilteredItems(items: items) updateSelection(rows: updatedItems) + focusQueryField() } else { + queryFocusTask?.cancel() + queryFocusTask = nil + isQueryFocused = false hoveredID = nil } } @@ -147,6 +151,26 @@ struct CommandPaletteOverlayView: View { filteredItems = updatedItems return updatedItems } + + private func focusQueryField() { + queryFocusTask?.cancel() + isQueryFocused = false + queryFocusTask = Task { @MainActor in + let delays: [Duration?] = [nil, .milliseconds(50), .milliseconds(150)] + for delay in delays { + if let delay { + try? await ContinuousClock().sleep(for: delay) + } else { + await Task.yield() + } + guard !Task.isCancelled else { return } + isQueryFocused = false + await Task.yield() + guard !Task.isCancelled else { return } + isQueryFocused = true + } + } + } } private struct CommandPaletteCard: View { @@ -157,7 +181,7 @@ private struct CommandPaletteCard: View { let items: [CommandPaletteItem] let resolvedKeybindings: ResolvedKeybindingMap @Binding var hoveredID: CommandPaletteItem.ID? - let isQueryFocused: FocusState + let isQueryFocused: Bool let onEvent: (CommandPaletteKeyboardEvent) -> Void let activate: (CommandPaletteItem.ID) -> Void @@ -221,17 +245,17 @@ private struct CommandPaletteQuery: View { static let fieldHeight: CGFloat = 48 @Binding var query: String + let isTextFieldFocused: Bool var onEvent: ((CommandPaletteKeyboardEvent) -> Void)? - @FocusState private var isTextFieldFocused: Bool init( query: Binding, - isTextFieldFocused: FocusState, + isTextFieldFocused: Bool, onEvent: ((CommandPaletteKeyboardEvent) -> Void)? = nil ) { _query = query + self.isTextFieldFocused = isTextFieldFocused self.onEvent = onEvent - _isTextFieldFocused = isTextFieldFocused } var body: some View { @@ -270,20 +294,120 @@ private struct CommandPaletteQuery: View { .frame(width: 0, height: 0) .accessibilityHidden(true) - TextField("Search for actions or branches...", text: $query) - .padding() - .font(.title3.weight(.light)) - .frame(height: Self.fieldHeight) - .textFieldStyle(.plain) - .focused($isTextFieldFocused) - .onChange(of: isTextFieldFocused) { _, focused in - if !focused { - onEvent?(.exit) - } + CommandPaletteQueryTextField( + query: $query, + isFocused: isTextFieldFocused, + onEvent: { event in + onEvent?(event) + } + ) + .padding() + .frame(height: Self.fieldHeight) + } + } +} + +private struct CommandPaletteQueryTextField: NSViewRepresentable { + @Binding var query: String + let isFocused: Bool + let onEvent: (CommandPaletteKeyboardEvent) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(query: $query, onEvent: onEvent) + } + + func makeNSView(context: Context) -> QueryField { + let field = QueryField() + field.delegate = context.coordinator + field.onEvent = onEvent + field.placeholderString = "Search for actions or branches..." + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + let titleFont = NSFont.preferredFont(forTextStyle: .title3) + field.font = NSFont.systemFont(ofSize: titleFont.pointSize, weight: .light) + field.usesSingleLineMode = true + field.lineBreakMode = .byTruncatingTail + return field + } + + func updateNSView(_ nsView: QueryField, context: Context) { + context.coordinator.query = $query + context.coordinator.onEvent = onEvent + nsView.onEvent = onEvent + if nsView.stringValue != query { + nsView.stringValue = query + } + if isFocused, !Self.isFocused(nsView) { + nsView.window?.makeFirstResponder(nsView) + } + } + + private static func isFocused(_ field: NSTextField) -> Bool { + guard let window = field.window else { return false } + return window.firstResponder === field || window.firstResponder === field.currentEditor() + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var query: Binding + var onEvent: (CommandPaletteKeyboardEvent) -> Void + + init(query: Binding, onEvent: @escaping (CommandPaletteKeyboardEvent) -> Void) { + self.query = query + self.onEvent = onEvent + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + query.wrappedValue = field.stringValue + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.cancelOperation(_:)): + onEvent(.exit) + case #selector(NSResponder.insertNewline(_:)): + onEvent(.submit) + case #selector(NSResponder.moveUp(_:)): + onEvent(.move(.up)) + case #selector(NSResponder.moveDown(_:)): + onEvent(.move(.down)) + default: + return false + } + return true + } + } + + final class QueryField: NSTextField { + var onEvent: ((CommandPaletteKeyboardEvent) -> Void)? + + override func cancelOperation(_ sender: Any?) { + onEvent?(.exit) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 36 || event.keyCode == 76 { + onEvent?(.submit) + return + } + if event.modifierFlags.contains(.control) { + switch event.charactersIgnoringModifiers?.lowercased() { + case "p": + onEvent?(.move(.up)) + return + case "n": + onEvent?(.move(.down)) + return + default: + break } - .onExitCommand { onEvent?(.exit) } - .onMoveCommand { onEvent?(.move($0)) } - .onSubmit { onEvent?(.submit) } + } + super.keyDown(with: event) } } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 7a7467c8..4dc29a89 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -105,6 +105,8 @@ final class WorktreeTerminalManager { _ = closeFocusedTab(in: worktree) case .closeFocusedSurface(let worktree): _ = closeFocusedSurface(in: worktree) + case .focusSelectedTab(let worktree): + state(for: worktree).focusSelectedTab() default: return false } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index a245da9d..da3bc3ab 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -8,6 +8,151 @@ import Testing @MainActor struct AppFeatureCommandPaletteTests { + @Test(.dependencies) func closingCommandPaletteRestoresSelectedTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-focus/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-focus" + ) + let repository = makeRepository(id: "/tmp/repo-focus", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + var state = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + state.commandPalette.isPresented = true + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.setPresented(false))) { + $0.commandPalette.isPresented = false + } + await store.finish() + + #expect(sent.value == [.focusSelectedTab(worktree)]) + } + + @Test(.dependencies) func togglingPresentedCommandPaletteClosedRestoresSelectedTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-toggle-focus/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-toggle-focus" + ) + let repository = makeRepository(id: "/tmp/repo-toggle-focus", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + var state = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + state.commandPalette.isPresented = true + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.togglePresented)) { + $0.commandPalette.isPresented = false + } + await store.finish() + + #expect(sent.value == [.focusSelectedTab(worktree)]) + } + + @Test(.dependencies) func closingCommandPaletteDoesNotRestoreFocusWithoutSelectedTerminal() async { + var state = AppFeature.State() + state.commandPalette.isPresented = true + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.setPresented(false))) { + $0.commandPalette.isPresented = false + } + await store.finish() + + #expect(sent.value.isEmpty) + } + + @Test(.dependencies) func passiveCommandPaletteCommandRestoresSelectedTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-passive/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-passive" + ) + let repository = makeRepository(id: "/tmp/repo-passive", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.delegate(.checkForUpdates))) + await store.receive(\.updates.checkForUpdates) + await store.finish() + + #expect(sent.value == [.focusSelectedTab(worktree)]) + } + + @Test(.dependencies) func selectingWorktreeDoesNotRestorePreviousTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-select/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-select" + ) + let repository = makeRepository(id: "/tmp/repo-select", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.selectWorktree(worktree.id)))) + await store.finish() + + #expect(!sent.value.contains(.focusSelectedTab(worktree))) + } + @Test(.dependencies) func openSettingsShowsWindow() async { let shown = LockIsolated(false) var state = AppFeature.State() -- 2.51.2 From 6b7f68842db8fffdff264182b66a2450f7202b32 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 14 May 2026 18:19:16 +0900 Subject: [PATCH 129/528] Restore command palette focus in canvas --- .../Features/App/Reducer/AppFeature.swift | 41 ++++++++++-- .../AppFeatureCommandPaletteTests.swift | 65 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 8c7ffa7c..8702140c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -124,13 +124,40 @@ struct AppFeature { return ["session_duration_seconds": seconds] } - private func restoreTerminalFocusEffect(for worktree: Worktree?) -> Effect { - guard let worktree else { return .none } + private func restoreCommandPaletteTerminalFocusEffect(repositories: RepositoriesFeature.State) -> Effect { + guard let worktree = commandPaletteTerminalFocusTarget(repositories: repositories) else { return .none } return .run { _ in await terminalClient.send(.focusSelectedTab(worktree)) } } + private func commandPaletteTerminalFocusTarget(repositories: RepositoriesFeature.State) -> Worktree? { + if let worktree = repositories.selectedTerminalWorktree { + return worktree + } + guard repositories.isShowingCanvas, + let worktreeID = terminalClient.canvasFocusedWorktreeID() + else { + return nil + } + if let worktree = repositories.worktree(for: worktreeID) { + return worktree + } + guard let repository = repositories.repositories[id: worktreeID], + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + else { + return nil + } + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + static func sessionDurationSeconds(launchedAt: Date?, now: Date) -> Int? { guard let launchedAt else { return nil } return max(0, Int(now.timeIntervalSince(launchedAt))) @@ -941,11 +968,11 @@ struct AppFeature { case .commandPalette(.setPresented(false)): guard state.commandPalette.isPresented else { return .none } - return restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) case .commandPalette(.togglePresented): guard state.commandPalette.isPresented else { return .none } - return restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) case .commandPalette(.delegate(.selectWorktree(let worktreeID))): return .send(.repositories(.selectWorktree(worktreeID))) @@ -953,7 +980,7 @@ struct AppFeature { case .commandPalette(.delegate(.checkForUpdates)): return .merge( .send(.updates(.checkForUpdates)), - restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) ) case .commandPalette(.delegate(.openSettings)): @@ -982,7 +1009,7 @@ struct AppFeature { case .commandPalette(.delegate(.refreshWorktrees)): return .merge( .send(.repositories(.refreshWorktrees)), - restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) ) case .commandPalette(.delegate(.jumpToLatestUnread)): @@ -1037,7 +1064,7 @@ struct AppFeature { case .commandPalette(.delegate(.debugTestToast(let toast))): return .merge( .send(.repositories(.showToast(toast))), - restoreTerminalFocusEffect(for: state.repositories.selectedTerminalWorktree) + restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) ) case .commandPalette(.delegate(.debugSimulateUpdateFound)): diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index da3bc3ab..9fede7a6 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -92,6 +92,71 @@ struct AppFeatureCommandPaletteTests { #expect(sent.value.isEmpty) } + @Test(.dependencies) func closingCommandPaletteInCanvasRestoresCanvasFocusedTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-canvas-focus/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-canvas-focus" + ) + let repository = makeRepository(id: "/tmp/repo-canvas-focus", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .canvas + var state = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + state.commandPalette.isPresented = true + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.setPresented(false))) { + $0.commandPalette.isPresented = false + } + await store.finish() + + #expect(sent.value == [.focusSelectedTab(worktree)]) + } + + @Test(.dependencies) func passiveCommandPaletteCommandInCanvasRestoresCanvasFocusedTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-canvas-passive/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-canvas-passive" + ) + let repository = makeRepository(id: "/tmp/repo-canvas-passive", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .canvas + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.delegate(.checkForUpdates))) + await store.receive(\.updates.checkForUpdates) + await store.finish() + + #expect(sent.value == [.focusSelectedTab(worktree)]) + } + @Test(.dependencies) func passiveCommandPaletteCommandRestoresSelectedTerminalFocus() async { let worktree = makeWorktree( id: "/tmp/repo-passive/wt-1", -- 2.51.2 From d6c60fb59eee95b6654cf9c12cd527e7e14a44ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 15 May 2026 21:38:22 +0900 Subject: [PATCH 130/528] bump v2026.5.15 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 3c73c40d..8c4efa60 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260514; + CURRENT_PROJECT_VERSION = 20260515; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.14; + MARKETING_VERSION = 2026.5.15; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260514; + CURRENT_PROJECT_VERSION = 20260515; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.14; + MARKETING_VERSION = 2026.5.15; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260514; + CURRENT_PROJECT_VERSION = 20260515; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.14; + MARKETING_VERSION = 2026.5.15; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260514; + CURRENT_PROJECT_VERSION = 20260515; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.14; + MARKETING_VERSION = 2026.5.15; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 9ffac7c6..35712a1e 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.14" + public static let current = "2026.5.15" } -- 2.51.2 From 3a3842d82b4f14b3a6e81d938522d9a5c315110f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 15 May 2026 21:38:23 +0900 Subject: [PATCH 131/528] Update CHANGELOG for 2026.5.15 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f28cd7b6..a65be8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2026.5.15](https://github.com/onevcat/Prowl/releases/tag/v2026.5.15) + +### Fixed + +- Command Palette (`Cmd+P`) now correctly captures keyboard input on macOS 26.4 and 26.5 (Tahoe). Previously, typed text would pass through to the terminal behind the palette instead of updating the query field. +- Dismissing the Command Palette via Escape, repeated `Cmd+P`, or passive commands (such as "Check for Updates") now reliably returns focus to the previously active terminal, including in Canvas mode. +- Long branch names in the sidebar are now truncated in the middle, keeping both the prefix and suffix visible. Hovering shows the full name in a tooltip. + ## [2026.5.14](https://github.com/onevcat/Prowl/releases/tag/v2026.5.14) This release focuses on memory stability after the active-agent detection introduced in 2026.5.11. -- 2.51.2 From e90bee1988ecd0c2de771b44acb1ff760821acce Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 16 May 2026 01:02:26 +0800 Subject: [PATCH 132/528] fix: add icon to check for updates menu item --- supacode/Commands/UpdateCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Commands/UpdateCommands.swift b/supacode/Commands/UpdateCommands.swift index 842cfe8e..ab99b8ee 100644 --- a/supacode/Commands/UpdateCommands.swift +++ b/supacode/Commands/UpdateCommands.swift @@ -7,7 +7,7 @@ struct UpdateCommands: Commands { var body: some Commands { CommandGroup(after: .appInfo) { - Button("Check for Updates...") { + Button("Check for Updates...", systemImage: "arrow.down.circle") { store.send(.checkForUpdates) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.checkForUpdates))) -- 2.51.2 From ab80262bb012147f5b25a018fc70ab07b5633b01 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 16 May 2026 14:21:28 +0900 Subject: [PATCH 133/528] refactor(command palette): replace isGlobal/isRootAction with category + defaultSuggestion The old two-flag model (isGlobal + isRootAction) collapsed into one bit of meaningful state: empty-query visibility. Both flags were set on the 8 app-level commands so they could never appear when the palette opened with no query. Introduce CommandPaletteItem.Category (view / navigation / worktree / pullRequest / terminal / app / debug) and a single defaultSuggestion: Bool that controls whether an item appears in the empty-query suggestion list. PR1 keeps empty-query behavior byte-identical to before; PR2-4 will populate categories into the UI, add keyword aliases, and batch-register the missing commands. Adds doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md describing the full four-PR sequence. --- ...05-16-command-palette-architecture-plan.md | 285 +++++++++++++++ .../CommandPalette/CommandPaletteItem.swift | 70 +--- .../Reducer/CommandPaletteFeature.swift | 344 +++++++++++------- supacodeTests/AppShortcutsTests.swift | 12 +- .../CommandPaletteFeatureTests.swift | 149 +++++--- 5 files changed, 620 insertions(+), 240 deletions(-) create mode 100644 doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md diff --git a/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md b/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md new file mode 100644 index 00000000..964bf77e --- /dev/null +++ b/doc-onevcat/plans/2026-05-16-command-palette-architecture-plan.md @@ -0,0 +1,285 @@ +# Command Palette Architecture Refactor + +## Context + +The command palette (`supacode/Features/CommandPalette/`) ships ~24 commands today. We want to bring it to **60–80 commands** by surfacing view toggles (Canvas/Shelf/Sidebar/Active Agents), navigation actions (next/prev worktree, worktree history), terminal/tab/pane operations, and find-in-terminal — actions currently reachable only via hotkeys or menu items. + +Before adding commands, the existing architecture has three issues that would compound at scale: + +1. **Confusing visibility model.** Two booleans (`isGlobal`, `isRootAction`) collapse into a single bit of meaningful state. `isGlobal` is named as if it controls "appears in search", but every command participates in search regardless. `isRootAction` is a pure negative override — its only effect is to *hide* items from the empty-query suggestion list. The 8 app-level commands set both flags to `true`, so they never appear when the palette opens; opening Cmd+P shows a blank list in normal use. +2. **No keyword aliases.** The fuzzy scorer only matches `title` and `subtitle`. `Toggle Sidebar` cannot be found by typing `sb`. At 60+ commands users will rely heavily on short queries — keyword support is load-bearing for discoverability. +3. **High cost-per-command.** Adding one command requires changes in `CommandPaletteItem.Kind`, the builder in `CommandPaletteFeature.commandPaletteItems`, the delegate routing in `AppFeature`, and icon/badge rules in the overlay view. There's no factory to compress repetitive registration (e.g., commands that simply forward to an `AppShortcut`). + +This plan refactors the foundations first (PR1–PR3), then batches the actual command additions (PR4+). + +--- + +## Design Overview + +### New `CommandPaletteItem` shape + +```swift +struct CommandPaletteItem: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String? + let kind: Kind + let priorityTier: Int + let category: Category // NEW: required, drives section grouping + let keywords: [String] // NEW: aliases that participate in fuzzy match + let defaultSuggestion: Bool // NEW: replaces isGlobal + isRootAction +} + +enum Category: String, CaseIterable { + case view // Toggle Sidebar / Canvas / Shelf / Active Agents / Diff + case navigation // Next/Prev worktree / tab / pane / shelf book / history + case worktree // New / Refresh / Archive / Remove / Run / Stop / Open + case pullRequest // Open / Merge / Close / Ready / CI actions + case terminal // Font size / Find / Ghostty-bridged commands + case app // Settings / Check Updates / Open Repository / Install CLI + #if DEBUG + case debug + #endif +} +``` + +### Why a single `defaultSuggestion` bit (not three-state) + +A three-state enum (`alwaysSuggest / onSearch / contextual`) overlaps with what the builder already does. The current builder is context-aware — it only constructs PR commands when an open PR exists; it only adds the worktree icon-change command when a worktree is selected; it filters out unwanted Ghostty actions. **Contextuality lives in command construction**, not in the visibility flag. + +That means `defaultSuggestion: Bool` is sufficient and uniform: an item is suggested when (a) it was constructed (the builder decided the context is right) **and** (b) the static `defaultSuggestion` flag is true. PR commands appear in Suggested when a PR exists because the builder constructs them then, not because of any PR-specific filter in the suggestion logic. + +This satisfies the constraint *"don't special-case PR commands"* — the suggestion view is one filter + one sort. + +### Empty-query rendering (post-PR2) + +``` +┌──────────────────────────────────────────┐ +│ [search box] │ +├──────────────────────────────────────────┤ +│ Recent │ +│ • Toggle Canvas ⌘⌥↩ │ +│ • New Worktree ⌘N │ +│ Suggested │ +│ • Toggle Sidebar ⌘⌃S │ +│ • Check for Updates ⌘⇧U │ +│ • Open Settings ⌘, │ +│ • … │ +└──────────────────────────────────────────┘ +``` + +- **Cap at 8 rows total** (5 Recent + 3 Suggested, dynamic fill). +- **Recent** = items with non-zero recency score, ordered by score desc. +- **Suggested** = remaining items with `defaultSuggestion == true`, ordered by `priorityTier` then declaration order, dedup'd against Recent. +- **Section headers only render on empty query.** When the user types, the scorer takes over: flat, fuzzy-ranked, no headers. + +### Keyword scoring + +`doScoreFuzzy` will score `title` and each entry in `keywords` independently and take the max. The matched label positions returned for highlighting always come from the `title` scoring run, even when a keyword scored higher — so the UI never paints highlights at indexes that don't exist in the visible string. Keywords are short labels (1–3 words), 0–5 per command, and not displayed. + +### Factory for `AppShortcut`-backed commands + +```swift +extension CommandPaletteItem { + static func appShortcut( + id: String, + title: String, + category: Category, + keywords: [String] = [], + defaultSuggestion: Bool = true, + priorityTier: Int = defaultPriorityTier, + kind: Kind + ) -> CommandPaletteItem { ... } +} +``` + +Most batch additions in PR4+ collapse to single-line calls like: + +```swift +.appShortcut( + id: "view.toggle-sidebar", + title: "Toggle Sidebar", + category: .view, + keywords: ["sb", "hide", "left panel"], + kind: .toggleSidebar +) +``` + +--- + +## PR1 — Model Refactor (no behavior change) + +**Goal:** swap the two-flag model for `category` + `keywords` + `defaultSuggestion` without changing what the user sees. This PR is pure refactor; any UI/UX change goes to PR2. + +### Scope + +1. **Add `Category` enum** in `CommandPaletteItem.swift`. Six base cases plus `#if DEBUG case debug`. +2. **Modify `CommandPaletteItem`**: + - Add `category: Category` (required init param) + - Add `keywords: [String]` (default `[]`) + - Add `defaultSuggestion: Bool` (required init param) + - Delete `isGlobal: Bool` computed property + - Delete `isRootAction: Bool` computed property +3. **Update `commandPaletteItems` builder** (`CommandPaletteFeature.swift:168-266`) to pass `category` and `defaultSuggestion` for each construction site. Mapping table below — **`defaultSuggestion` must equal `current isGlobal && !isRootAction`** so empty-query behavior is byte-identical. +4. **Update `filterItems`** (line 159–163): replace `items.filter(\.isGlobal).filter { !$0.isRootAction }` with `items.filter(\.defaultSuggestion)`. +5. **Update `ghosttyCommandItems`** helper (line 737–749) to pass `category: .terminal, defaultSuggestion: false`. +6. **Update tests** (`supacodeTests/CommandPaletteFeatureTests.swift`, `AppFeatureCommandPaletteTests.swift`): every `CommandPaletteItem(...)` construction needs the new fields. Existing assertions should still pass — that *is* the verification. + +### Command tagging table + +| Kind | Category | defaultSuggestion (= current `isGlobal && !isRootAction`) | +|---|---|---| +| `checkForUpdates` | `.app` | false | +| `openSettings` | `.app` | false | +| `openRepository` | `.app` | false | +| `installCLI` | `.app` | false | +| `newWorktree` | `.worktree` | false | +| `refreshWorktrees` | `.worktree` | false | +| `viewArchivedWorktrees` | `.worktree` | false | +| `jumpToLatestUnread` | `.navigation` | false | +| `worktreeSelect` | `.navigation` | false | +| `removeWorktree` | `.worktree` | false | +| `archiveWorktree` | `.worktree` | false | +| `changeFocusedTabIcon` | `.worktree` | false | +| `ghosttyCommand` | `.terminal` | false | +| `openPullRequest` | `.pullRequest` | **true** | +| `openRepositoryOnCodeHost` | `.pullRequest` | false | +| `markPullRequestReady` | `.pullRequest` | **true** | +| `mergePullRequest` | `.pullRequest` | **true** | +| `closePullRequest` | `.pullRequest` | **true** | +| `copyFailingJobURL` | `.pullRequest` | **true** | +| `copyCiFailureLogs` | `.pullRequest` | **true** | +| `rerunFailedJobs` | `.pullRequest` | **true** | +| `openFailingCheckDetails` | `.pullRequest` | **true** | +| `debugTestToast` | `.debug` | **true** | +| `debugSimulateUpdateFound` | `.debug` | **true** | + +Result: in normal usage, empty Cmd+P still shows the same things it did before (nothing in the no-PR case; PR commands when a PR is open). + +### Verification + +- Existing `filterItems` test suite passes unchanged (the public observable behavior is identical). +- Add one new test: `filterItems_emptyQuery_returnsOnlyDefaultSuggestionItems` asserting the post-refactor field reads correctly. +- `make build-app` succeeds. +- `make check` clean (formatting, swiftlint, swift-format). + +### Out of scope (deferred to PR2) + +- No change to which commands have `defaultSuggestion = true`. The 8 app-level commands stay hidden from empty palette in PR1. +- No keyword data populated yet (`keywords: []` everywhere). +- No section headers in the view. +- No scorer changes. + +--- + +## PR2 — Search & Empty-State UX + +**Goal:** make the palette useful on open, and let users search via short aliases. + +### Changes + +1. **Scorer**: extend `doScoreFuzzy` so each candidate scores against `[title] + keywords`, taking the max. Match highlight positions are always computed against `title`, never keywords. +2. **`filterItems` empty-query path**: replace the simple `defaultSuggestion` filter + sort with a Recent/Suggested split (see Design Overview rendering box). Cap at 8 total. +3. **`CommandPaletteOverlayView`**: add a tiny `Section` wrapper that renders headers — only when the active query is empty. When searching, headers disappear. +4. **Flip `defaultSuggestion` to `true` for the 8 app-level commands.** Add starter keywords: + - `checkForUpdates` — `["update", "version"]` + - `openSettings` — `["preferences", "config"]` + - `openRepository` — `["repo", "add repo"]` + - `newWorktree` — `["worktree", "branch"]` + - `refreshWorktrees` — `["reload", "rescan"]` + - `viewArchivedWorktrees` — `["archive", "history"]` + - `jumpToLatestUnread` — `["unread", "bell", "notification"]` + - `installCLI` — `["cli", "command line", "terminal", "prowl"]` + +### Verification + +- New tests for keyword matching (`Toggle Sidebar` findable via `sb`, etc.). +- New tests for Recent/Suggested split (8-cap, dedup, ordering by recency then priority). +- New tests asserting headers render only when query is empty. +- Manual smoke: open Cmd+P → see populated suggestions; type `sb` → see Toggle Sidebar (note: this command is added in PR4, so PR2's keyword tests use the 8 app-level commands' new keywords). + +--- + +## PR3 — Factories + +**Goal:** make PR4+ command additions one-liners. No behavior change. + +### Additions + +1. **`CommandPaletteItem.appShortcut(id:title:category:keywords:defaultSuggestion:priorityTier:kind:)`** factory — handles the most common case where a command forwards to a hotkey already registered in `AppShortcuts`. +2. **`CommandPaletteItem.ghosttyCommand(_:category:keywords:defaultSuggestion:)`** factory — consumes a `GhosttyCommand` value and returns a tagged item. Replaces `ghosttyCommandItems` inline construction. +3. **`CommandPaletteItem.contextual(id:title:category:kind:)`** factory — for items that are constructed only when context allows (worktree commands, PR commands). `defaultSuggestion` defaults to `false` here. + +### Verification + +- Refactor existing builders in `commandPaletteItems` to use the new factories. Tests must still pass. +- `make build-app` succeeds. + +--- + +## PR4+ — Batch Command Additions + +Each PR adds one category's worth of commands. Suggested order (high → low priority based on user feedback): + +### PR4: View toggles + Diff + +- Toggle Sidebar (`⌘⌃S`) +- Toggle Active Agents Panel (`⌘⌥P`) +- Toggle Canvas (`⌘⌥↩`) +- Toggle Shelf (`⌘⇧↩`) +- Show Diff (`⌘⇧Y`) + +All `category: .view`, `defaultSuggestion: true`. + +### PR5: Navigation + +- Select Next / Previous Worktree (`⌘⌃↑/↓`) +- Back / Forward Worktree History (`⌘⌥[` / `⌘⌥]`) +- Open Worktree in Finder (`⌘O`) +- Copy Worktree Path (`⌘⇧C`) +- Reveal in Sidebar (`⌘⇧L`) + +All `category: .navigation`. Suggested = high-traffic only (Next/Prev Worktree, Jump to Unread already exists). + +### PR6: Worktree actions + +- Run Script (`⌘R`) +- Stop Script (`⌘.`) +- Pin / Unpin Worktree +- Delete Worktree +- Rename Branch (`⌘⇧M`) + +### PR7: Terminal / Tab / Pane + +Most pipe through Ghostty's existing actions — confirm each action key is exposed via `GhosttyCommand`. If exposed, register via the existing `.ghosttyCommand` factory; otherwise we may need a Ghostty-side patch (defer that conversation). + +- Select Tab 1-9, Prev/Next Tab, Prev/Next Pane, Pane Up/Down/Left/Right +- Font size: increase / decrease / reset +- Find / Find Next / Find Previous / Hide Find +- New / Close Terminal / Close Tab + +### PR8: Shelf navigation + +- Select Next / Previous Shelf Book +- Select Shelf Book 1-9 + +### Stretch (no PR yet) + +- Repository context menu actions (Settings, Remove) +- Bulk selection actions (Archive Selected, Delete Selected) +- Confirm Worktree Action (`⌘↩`) + +--- + +## Non-goals + +- **No registry pattern.** The centralized builder stays — moving to per-feature command contribution is a larger architectural shift that doesn't justify itself at this scale. +- **No frequency tracking on top of recency.** The current exponential-decay recency model is good enough; adding a frequency counter is a measurable-impact-later question. +- **No declarative "availability" framework.** Context conditions stay as `if` branches in the builder. Pulling them out would force every command kind to define an availability predicate, which is heavy for the current ~24 → ~80 jump. +- **No virtualization.** SwiftUI `ForEach` in a `ScrollView` will handle 80 rows fine on macOS 26+. + +## Open questions (defer to PR2 design review) + +- Should `Recent` show a relative timestamp ("2m ago")? Probably not — adds visual noise for marginal value. +- When a command becomes contextually applicable mid-session (e.g., a PR opens), should its priority in Suggested temporarily boost? Current plan: no — it just shows up because the builder includes it. +- Should keywords be localized? Today the app is English-only; defer until we add localization. diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index c906ff2c..0830b73b 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -6,18 +6,27 @@ struct CommandPaletteItem: Identifiable, Equatable { let subtitle: String? let kind: Kind let priorityTier: Int + let category: Category + let keywords: [String] + let defaultSuggestion: Bool init( id: String, title: String, subtitle: String?, kind: Kind, + category: Category, + defaultSuggestion: Bool, + keywords: [String] = [], priorityTier: Int = defaultPriorityTier ) { self.id = id self.title = title self.subtitle = subtitle self.kind = kind + self.category = category + self.defaultSuggestion = defaultSuggestion + self.keywords = keywords self.priorityTier = priorityTier } @@ -50,61 +59,16 @@ struct CommandPaletteItem: Identifiable, Equatable { #endif } - var isGlobal: Bool { - switch kind { - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .jumpToLatestUnread: - return true - case .ghosttyCommand: - return false - case .openPullRequest, - .markPullRequestReady, - .mergePullRequest, - .closePullRequest, - .copyFailingJobURL, - .copyCiFailureLogs, - .rerunFailedJobs, - .openFailingCheckDetails: - return true - case .worktreeSelect, - .removeWorktree, - .archiveWorktree, - .changeFocusedTabIcon, - .openRepositoryOnCodeHost: - return false - #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: - return true - #endif - } - } - - var isRootAction: Bool { - switch kind { - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .jumpToLatestUnread: - return true - case .ghosttyCommand: - return false - case .openPullRequest, - .openRepositoryOnCodeHost, - .markPullRequestReady, - .mergePullRequest, - .closePullRequest, - .copyFailingJobURL, - .copyCiFailureLogs, - .rerunFailedJobs, - .openFailingCheckDetails, - .worktreeSelect, - .removeWorktree, - .archiveWorktree, - .changeFocusedTabIcon: - return false + enum Category: String, CaseIterable, Equatable { + case view + case navigation + case worktree + case pullRequest + case terminal + case app #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: - return false + case debug #endif - } } var appShortcutCommandID: String? { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 19a824c7..24e6c4fc 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -156,9 +156,8 @@ struct CommandPaletteFeature { now: Date = .now ) -> [CommandPaletteItem] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - let globalItems = items.filter(\.isGlobal) guard !trimmed.isEmpty else { - let visibleItems = globalItems.filter { !$0.isRootAction } + let visibleItems = items.filter(\.defaultSuggestion) return prioritizeItems(items: visibleItems, recencyByID: recencyByID, now: now) } let scorer = CommandPaletteFuzzyScorer(query: trimmed, recencyByID: recencyByID, now: now) @@ -172,75 +171,16 @@ struct CommandPaletteFeature { let showsNewWorktreeAction = repositories.repositories.isEmpty || repositories.repositories.contains { $0.capabilities.supportsWorktrees } - var items: [CommandPaletteItem] = [ - CommandPaletteItem( - id: CommandPaletteItemID.globalCheckForUpdates, - title: "Check for Updates", - subtitle: nil, - kind: .checkForUpdates - ), - CommandPaletteItem( - id: CommandPaletteItemID.globalOpenSettings, - title: "Open Settings", - subtitle: nil, - kind: .openSettings - ), - CommandPaletteItem( - id: CommandPaletteItemID.globalOpenRepository, - title: "Open Repository", - subtitle: nil, - kind: .openRepository - ), - ] - if showsNewWorktreeAction { - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalNewWorktree, - title: "New Worktree", - subtitle: nil, - kind: .newWorktree - ) - ) - } - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalRefreshWorktrees, - title: "Refresh Worktrees", - subtitle: nil, - kind: .refreshWorktrees - ) - ) - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalJumpToLatestUnread, - title: "Jump to Latest Unread", - subtitle: nil, - kind: .jumpToLatestUnread - ) - ) - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalViewArchivedWorktrees, - title: "View Archived Worktrees", - subtitle: nil, - kind: .viewArchivedWorktrees - ) - ) - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalInstallCLI, - title: "Install Command Line Tool", - subtitle: nil, - kind: .installCLI - ) - ) + var items = globalCommandItems(showsNewWorktreeAction: showsNewWorktreeAction) if let terminalWorktree = repositories.selectedTerminalWorktree { items.append( CommandPaletteItem( id: CommandPaletteItemID.changeFocusedTabIcon(terminalWorktree.id), title: "Change Tab Icon...", subtitle: terminalWorktree.name, - kind: .changeFocusedTabIcon(terminalWorktree.id) + kind: .changeFocusedTabIcon(terminalWorktree.id), + category: .worktree, + defaultSuggestion: false ) ) items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) @@ -258,7 +198,9 @@ struct CommandPaletteFeature { id: CommandPaletteItemID.worktreeSelect(row.id), title: title, subtitle: nil, - kind: .worktreeSelect(row.id) + kind: .worktreeSelect(row.id), + category: .navigation, + defaultSuggestion: false ) ) } @@ -280,6 +222,88 @@ struct CommandPaletteFeature { } } +private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPaletteItem] { + var items: [CommandPaletteItem] = [ + CommandPaletteItem( + id: CommandPaletteItemID.globalCheckForUpdates, + title: "Check for Updates", + subtitle: nil, + kind: .checkForUpdates, + category: .app, + defaultSuggestion: false + ), + CommandPaletteItem( + id: CommandPaletteItemID.globalOpenSettings, + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: false + ), + CommandPaletteItem( + id: CommandPaletteItemID.globalOpenRepository, + title: "Open Repository", + subtitle: nil, + kind: .openRepository, + category: .app, + defaultSuggestion: false + ), + ] + if showsNewWorktreeAction { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalNewWorktree, + title: "New Worktree", + subtitle: nil, + kind: .newWorktree, + category: .worktree, + defaultSuggestion: false + ) + ) + } + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalRefreshWorktrees, + title: "Refresh Worktrees", + subtitle: nil, + kind: .refreshWorktrees, + category: .worktree, + defaultSuggestion: false + ) + ) + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalJumpToLatestUnread, + title: "Jump to Latest Unread", + subtitle: nil, + kind: .jumpToLatestUnread, + category: .navigation, + defaultSuggestion: false + ) + ) + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalViewArchivedWorktrees, + title: "View Archived Worktrees", + subtitle: nil, + kind: .viewArchivedWorktrees, + category: .worktree, + defaultSuggestion: false + ) + ) + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalInstallCLI, + title: "Install Command Line Tool", + subtitle: nil, + kind: .installCLI, + category: .app, + defaultSuggestion: false + ) + ) + return items +} + private func selectedCodeHostItems( from repositories: RepositoriesFeature.State ) -> [CommandPaletteItem] { @@ -316,6 +340,8 @@ private func selectedCodeHostItems( title: "Open Repository on \(codeHost.displayName)", subtitle: repository.name, kind: .openRepositoryOnCodeHost(selectedWorktreeID), + category: .pullRequest, + defaultSuggestion: false, priorityTier: 2 ) ] @@ -327,74 +353,10 @@ private func pullRequestItems( repositoryID: Repository.ID, codeHost: CodeHost ) -> [CommandPaletteItem] { - let state = pullRequest.state.uppercased() - let isOpen = state == "OPEN" - let isDraft = pullRequest.isDraft + let isOpen = pullRequest.state.uppercased() == "OPEN" let mergeReadiness = PullRequestMergeReadiness(pullRequest: pullRequest) - let checks = pullRequest.statusCheckRollup?.checks ?? [] - let breakdown = PullRequestCheckBreakdown(checks: checks) - let hasFailingChecks = breakdown.failed > 0 - let canMerge = isOpen && !isDraft && !mergeReadiness.isBlocking - - func makeReadyItem() -> CommandPaletteItem? { - guard isOpen && isDraft else { return nil } - return CommandPaletteItem( - id: CommandPaletteItemID.pullRequestReady(repositoryID), - title: "Mark PR Ready for Review", - subtitle: pullRequest.title, - kind: .markPullRequestReady(worktreeID), - priorityTier: 0 - ) - } - - func makeFailingItems() -> [CommandPaletteItem] { - guard isOpen && hasFailingChecks else { return [] } - let hasFailingCheckWithDetails = checks.contains { $0.checkState == .failure && $0.detailsUrl != nil } - let leadingTier = isDraft ? 1 : 0 - let followupTier = leadingTier + 1 - var failingItems: [CommandPaletteItem] = [] - if hasFailingCheckWithDetails { - failingItems.append( - CommandPaletteItem( - id: CommandPaletteItemID.pullRequestCopyFailingJobURL(repositoryID), - title: "Copy failing job URL", - subtitle: pullRequest.title, - kind: .copyFailingJobURL(worktreeID), - priorityTier: leadingTier - ) - ) - } - failingItems.append( - CommandPaletteItem( - id: CommandPaletteItemID.pullRequestCopyCiLogs(repositoryID), - title: "Copy CI Failure Logs", - subtitle: pullRequest.title, - kind: .copyCiFailureLogs(worktreeID), - priorityTier: hasFailingCheckWithDetails ? followupTier : leadingTier - ) - ) - failingItems.append( - CommandPaletteItem( - id: CommandPaletteItemID.pullRequestRerunFailedJobs(repositoryID), - title: "Re-run Failed Jobs", - subtitle: pullRequest.title, - kind: .rerunFailedJobs(worktreeID), - priorityTier: followupTier - ) - ) - if hasFailingCheckWithDetails { - failingItems.append( - CommandPaletteItem( - id: CommandPaletteItemID.pullRequestOpenFailingCheck(repositoryID), - title: "Open Failing Check Details", - subtitle: pullRequest.title, - kind: .openFailingCheckDetails(worktreeID), - priorityTier: followupTier - ) - ) - } - return failingItems - } + let breakdown = PullRequestCheckBreakdown(checks: pullRequest.statusCheckRollup?.checks ?? []) + let canMerge = isOpen && !pullRequest.isDraft && !mergeReadiness.isBlocking var items: [CommandPaletteItem] = [ CommandPaletteItem( @@ -402,15 +364,27 @@ private func pullRequestItems( title: "Open Pull Request on \(codeHost.displayName)", subtitle: pullRequest.title, kind: .openPullRequest(worktreeID), + category: .pullRequest, + defaultSuggestion: true, priorityTier: 2 ) ] - if let readyItem = makeReadyItem() { + if let readyItem = makeReadyPullRequestItem( + pullRequest: pullRequest, + repositoryID: repositoryID, + worktreeID: worktreeID + ) { items.append(readyItem) } - items.append(contentsOf: makeFailingItems()) + items.append( + contentsOf: makeFailingPullRequestItems( + pullRequest: pullRequest, + repositoryID: repositoryID, + worktreeID: worktreeID + ) + ) if let mergeItem = makeMergePullRequestItem( canMerge: canMerge, @@ -433,6 +407,88 @@ private func pullRequestItems( return items } +private func makeReadyPullRequestItem( + pullRequest: GithubPullRequest, + repositoryID: Repository.ID, + worktreeID: Worktree.ID +) -> CommandPaletteItem? { + let isOpen = pullRequest.state.uppercased() == "OPEN" + guard isOpen && pullRequest.isDraft else { return nil } + return CommandPaletteItem( + id: CommandPaletteItemID.pullRequestReady(repositoryID), + title: "Mark PR Ready for Review", + subtitle: pullRequest.title, + kind: .markPullRequestReady(worktreeID), + category: .pullRequest, + defaultSuggestion: true, + priorityTier: 0 + ) +} + +private func makeFailingPullRequestItems( + pullRequest: GithubPullRequest, + repositoryID: Repository.ID, + worktreeID: Worktree.ID +) -> [CommandPaletteItem] { + let isOpen = pullRequest.state.uppercased() == "OPEN" + let checks = pullRequest.statusCheckRollup?.checks ?? [] + let hasFailingChecks = PullRequestCheckBreakdown(checks: checks).failed > 0 + guard isOpen && hasFailingChecks else { return [] } + let hasFailingCheckWithDetails = checks.contains { $0.checkState == .failure && $0.detailsUrl != nil } + let leadingTier = pullRequest.isDraft ? 1 : 0 + let followupTier = leadingTier + 1 + var failingItems: [CommandPaletteItem] = [] + if hasFailingCheckWithDetails { + failingItems.append( + CommandPaletteItem( + id: CommandPaletteItemID.pullRequestCopyFailingJobURL(repositoryID), + title: "Copy failing job URL", + subtitle: pullRequest.title, + kind: .copyFailingJobURL(worktreeID), + category: .pullRequest, + defaultSuggestion: true, + priorityTier: leadingTier + ) + ) + } + failingItems.append( + CommandPaletteItem( + id: CommandPaletteItemID.pullRequestCopyCiLogs(repositoryID), + title: "Copy CI Failure Logs", + subtitle: pullRequest.title, + kind: .copyCiFailureLogs(worktreeID), + category: .pullRequest, + defaultSuggestion: true, + priorityTier: hasFailingCheckWithDetails ? followupTier : leadingTier + ) + ) + failingItems.append( + CommandPaletteItem( + id: CommandPaletteItemID.pullRequestRerunFailedJobs(repositoryID), + title: "Re-run Failed Jobs", + subtitle: pullRequest.title, + kind: .rerunFailedJobs(worktreeID), + category: .pullRequest, + defaultSuggestion: true, + priorityTier: followupTier + ) + ) + if hasFailingCheckWithDetails { + failingItems.append( + CommandPaletteItem( + id: CommandPaletteItemID.pullRequestOpenFailingCheck(repositoryID), + title: "Open Failing Check Details", + subtitle: pullRequest.title, + kind: .openFailingCheckDetails(worktreeID), + category: .pullRequest, + defaultSuggestion: true, + priorityTier: followupTier + ) + ) + } + return failingItems +} + private func makeMergePullRequestItem( canMerge: Bool, breakdown: PullRequestCheckBreakdown, @@ -450,6 +506,8 @@ private func makeMergePullRequestItem( title: "Merge PR", subtitle: "Merge Ready - \(successfulChecksLabel)", kind: .mergePullRequest(worktreeID), + category: .pullRequest, + defaultSuggestion: true, priorityTier: 0 ) } @@ -466,6 +524,8 @@ private func makeClosePullRequestItem( title: "Close PR", subtitle: pullRequestTitle, kind: .closePullRequest(worktreeID), + category: .pullRequest, + defaultSuggestion: true, priorityTier: 1 ) } @@ -477,19 +537,25 @@ private func makeClosePullRequestItem( id: "debug.toast.inProgress", title: "[Debug] Toast: In Progress", subtitle: "Simulates an in-progress toast", - kind: .debugTestToast(.inProgress("Merging pull request…")) + kind: .debugTestToast(.inProgress("Merging pull request…")), + category: .debug, + defaultSuggestion: true ), CommandPaletteItem( id: "debug.toast.success", title: "[Debug] Toast: Success", subtitle: "Simulates a success toast", - kind: .debugTestToast(.success("Pull request merged")) + kind: .debugTestToast(.success("Pull request merged")), + category: .debug, + defaultSuggestion: true ), CommandPaletteItem( id: "debug.update.simulate-found", title: "[Debug] Simulate Update Found", subtitle: "Shows the toolbar update badge without querying Sparkle", - kind: .debugSimulateUpdateFound + kind: .debugSimulateUpdateFound, + category: .debug, + defaultSuggestion: true ), ] } @@ -743,6 +809,8 @@ private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPalett title: command.title, subtitle: subtitle.isEmpty ? nil : subtitle, kind: .ghosttyCommand(command.action), + category: .terminal, + defaultSuggestion: false, priorityTier: CommandPaletteItem.defaultPriorityTier + 100 ) } diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 00063691..867ed71d 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -351,7 +351,9 @@ struct AppShortcutsTests { id: "settings", title: "Open Settings", subtitle: nil, - kind: .openSettings + kind: .openSettings, + category: .app, + defaultSuggestion: false ) expectNoDifference(paletteItem.appShortcutLabel(in: resolved), "⌘;") @@ -381,7 +383,9 @@ struct AppShortcutsTests { id: "settings", title: "Open Settings", subtitle: nil, - kind: .openSettings + kind: .openSettings, + category: .app, + defaultSuggestion: false ) #expect(paletteItem.appShortcutLabel(in: resolved) == nil) @@ -440,7 +444,9 @@ struct AppShortcutsTests { id: "settings", title: "Open Settings", subtitle: nil, - kind: .openSettings + kind: .openSettings, + category: .app, + defaultSuggestion: false ) expectNoDifference(paletteItem.appShortcutLabel(in: resolved), "⌘1") diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 954866dd..80358f9f 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -248,13 +248,13 @@ struct CommandPaletteFeatureTests { } @Test func emptyQueryHidesGhosttyCommands() { - let ghosttyItem = CommandPaletteItem( + let ghosttyItem = makeItem( id: "ghostty.goto_split:right|Focus Split Right", title: "Focus Split Right", subtitle: nil, kind: .ghosttyCommand("goto_split:right") ) - let prAction = CommandPaletteItem( + let prAction = makeItem( id: "pr.open", title: "Open PR on GitHub", subtitle: "PR title", @@ -466,31 +466,31 @@ struct CommandPaletteFeatureTests { } @Test func showsGlobalItemsWhenQueryEmpty() { - let openSettings = CommandPaletteItem( + let openSettings = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let newWorktree = CommandPaletteItem( + let newWorktree = makeItem( id: "global.new-worktree", title: "New Worktree", subtitle: nil, kind: .newWorktree ) - let selectFox = CommandPaletteItem( + let selectFox = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: "main", kind: .worktreeSelect("wt-fox") ) - let archiveFox = CommandPaletteItem( + let archiveFox = makeItem( id: "worktree.fox.archive", title: "Repo / fox", subtitle: "Archive Worktree - main", kind: .archiveWorktree("wt-fox", "repo-fox") ) - let removeFox = CommandPaletteItem( + let removeFox = makeItem( id: "worktree.fox.remove", title: "Repo / fox", subtitle: "Remove Worktree - main", @@ -521,13 +521,13 @@ struct CommandPaletteFeatureTests { } @Test func queryRanksByFuzzyScoreAcrossAllItems() { - let openSettings = CommandPaletteItem( + let openSettings = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let selectSettings = CommandPaletteItem( + let selectSettings = makeItem( id: "worktree.settings.select", title: "Repo / settings", subtitle: "main", @@ -541,13 +541,13 @@ struct CommandPaletteFeatureTests { } @Test func fuzzyRanksPrefixAndShorterLabelFirst() { - let short = CommandPaletteItem( + let short = makeItem( id: "worktree.set.select", title: "Set", subtitle: nil, kind: .worktreeSelect("wt-set") ) - let long = CommandPaletteItem( + let long = makeItem( id: "worktree.settings.select", title: "Settings", subtitle: nil, @@ -561,7 +561,7 @@ struct CommandPaletteFeatureTests { } @Test func fuzzyMatchesSubtitleWhenLabelDoesNot() { - let item = CommandPaletteItem( + let item = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: "main", @@ -575,7 +575,7 @@ struct CommandPaletteFeatureTests { } @Test func fuzzyMatchesMultiplePieces() { - let item = CommandPaletteItem( + let item = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: "main", @@ -730,13 +730,13 @@ struct CommandPaletteFeatureTests { @Test func recencyBreaksFuzzyTiesWithinGroup() { let now = Date(timeIntervalSince1970: 1_000_000) - let recent = CommandPaletteItem( + let recent = makeItem( id: "global.recent", title: "Open", subtitle: nil, kind: .openRepository ) - let older = CommandPaletteItem( + let older = makeItem( id: "global.older", title: "Open", subtitle: nil, @@ -759,13 +759,13 @@ struct CommandPaletteFeatureTests { } @Test func supacodeItemsBeatGhosttyItemsWhenScoresTie() { - let supacodeItem = CommandPaletteItem( + let supacodeItem = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let ghosttyItem = CommandPaletteItem( + let ghosttyItem = makeItem( id: "ghostty.open-settings|Open Settings", title: "Open Settings", subtitle: nil, @@ -785,13 +785,13 @@ struct CommandPaletteFeatureTests { // MARK: - Unified Ranking Tests @Test func worktreeOutranksGlobalWhenBetterMatch() { - let checkForUpdates = CommandPaletteItem( + let checkForUpdates = makeItem( id: "global.check-for-updates", title: "Check for Updates", subtitle: nil, kind: .checkForUpdates ) - let worktreeFox = CommandPaletteItem( + let worktreeFox = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: nil, @@ -805,13 +805,13 @@ struct CommandPaletteFeatureTests { } @Test func worktreeExactPrefixOutranksGlobalSubstringMatch() { - let openSettings = CommandPaletteItem( + let openSettings = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let worktreeOpen = CommandPaletteItem( + let worktreeOpen = makeItem( id: "worktree.open.select", title: "open", subtitle: nil, @@ -826,19 +826,19 @@ struct CommandPaletteFeatureTests { } @Test func globalAndWorktreeItemsInterleavedByScore() { - let openRepo = CommandPaletteItem( + let openRepo = makeItem( id: "global.open-repository", title: "Open Repository", subtitle: nil, kind: .openRepository ) - let worktreeRepo = CommandPaletteItem( + let worktreeRepo = makeItem( id: "worktree.repo.select", title: "repo", subtitle: nil, kind: .worktreeSelect("wt-repo") ) - let refreshWorktrees = CommandPaletteItem( + let refreshWorktrees = makeItem( id: "global.refresh-worktrees", title: "Refresh Worktrees", subtitle: nil, @@ -856,13 +856,13 @@ struct CommandPaletteFeatureTests { } @Test func nonMatchingItemsExcludedRegardlessOfType() { - let checkForUpdates = CommandPaletteItem( + let checkForUpdates = makeItem( id: "global.check-for-updates", title: "Check for Updates", subtitle: nil, kind: .checkForUpdates ) - let worktreeFox = CommandPaletteItem( + let worktreeFox = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: nil, @@ -876,19 +876,19 @@ struct CommandPaletteFeatureTests { } @Test func multipleWorktreesCanAppearBeforeGlobalItems() { - let openSettings = CommandPaletteItem( + let openSettings = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let worktreeAlpha = CommandPaletteItem( + let worktreeAlpha = makeItem( id: "worktree.alpha.select", title: "set", subtitle: nil, kind: .worktreeSelect("wt-alpha") ) - let worktreeBeta = CommandPaletteItem( + let worktreeBeta = makeItem( id: "worktree.beta.select", title: "sett", subtitle: nil, @@ -906,14 +906,14 @@ struct CommandPaletteFeatureTests { } @Test func priorityTierBreaksTiesAcrossItemTypes() { - let prAction = CommandPaletteItem( + let prAction = makeItem( id: "pr.merge", title: "Merge PR", subtitle: "Ready", kind: .mergePullRequest("wt-1"), priorityTier: 0 ) - let worktreeMerge = CommandPaletteItem( + let worktreeMerge = makeItem( id: "worktree.merge.select", title: "Merge", subtitle: nil, @@ -933,13 +933,13 @@ struct CommandPaletteFeatureTests { @Test func recencyBreaksTiesAcrossItemTypes() { let now = Date(timeIntervalSince1970: 1_000_000) - let globalItem = CommandPaletteItem( + let globalItem = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let worktreeItem = CommandPaletteItem( + let worktreeItem = makeItem( id: "worktree.settings.select", title: "Repo / settings", subtitle: nil, @@ -961,13 +961,13 @@ struct CommandPaletteFeatureTests { } @Test func worktreeWithLabelMatchOutranksGlobalWithDescriptionMatch() { - let globalItem = CommandPaletteItem( + let globalItem = makeItem( id: "global.pr.open", title: "Open PR on GitHub", subtitle: "deploy-fixes", kind: .openPullRequest("wt-1") ) - let worktreeItem = CommandPaletteItem( + let worktreeItem = makeItem( id: "worktree.deploy.select", title: "Repo / deploy-fixes", subtitle: nil, @@ -983,13 +983,13 @@ struct CommandPaletteFeatureTests { } @Test func shorterWorktreeLabelWinsOverLongerGlobalLabel() { - let globalItem = CommandPaletteItem( + let globalItem = makeItem( id: "global.new-worktree", title: "New Worktree", subtitle: nil, kind: .newWorktree ) - let worktreeItem = CommandPaletteItem( + let worktreeItem = makeItem( id: "worktree.new.select", title: "new", subtitle: nil, @@ -1005,19 +1005,19 @@ struct CommandPaletteFeatureTests { } @Test func emptyQueryStillHidesRootActionsAndWorktrees() { - let checkForUpdates = CommandPaletteItem( + let checkForUpdates = makeItem( id: "global.check-for-updates", title: "Check for Updates", subtitle: nil, kind: .checkForUpdates ) - let worktreeFox = CommandPaletteItem( + let worktreeFox = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: nil, kind: .worktreeSelect("wt-fox") ) - let prAction = CommandPaletteItem( + let prAction = makeItem( id: "pr.open", title: "Open PR on GitHub", subtitle: "PR title", @@ -1036,13 +1036,13 @@ struct CommandPaletteFeatureTests { } @Test func whitespaceOnlyQueryTreatedAsEmpty() { - let checkForUpdates = CommandPaletteItem( + let checkForUpdates = makeItem( id: "global.check-for-updates", title: "Check for Updates", subtitle: nil, kind: .checkForUpdates ) - let worktreeFox = CommandPaletteItem( + let worktreeFox = makeItem( id: "worktree.fox.select", title: "Repo / fox", subtitle: nil, @@ -1062,13 +1062,13 @@ struct CommandPaletteFeatureTests { } @Test func inputOrderDoesNotAffectScoreBasedRanking() { - let globalItem = CommandPaletteItem( + let globalItem = makeItem( id: "global.open-settings", title: "Open Settings", subtitle: nil, kind: .openSettings ) - let worktreeItem = CommandPaletteItem( + let worktreeItem = makeItem( id: "worktree.open.select", title: "open", subtitle: nil, @@ -1092,7 +1092,7 @@ struct CommandPaletteFeatureTests { state.isPresented = true state.query = "bear" state.selectedIndex = 1 - let item = CommandPaletteItem( + let item = makeItem( id: "global.open-repository", title: "Open Repository", subtitle: nil, @@ -1115,7 +1115,7 @@ struct CommandPaletteFeatureTests { @Test func activateGhosttyCommandDispatchesDelegate() async { let now = Date(timeIntervalSince1970: 7_654_321) - let item = CommandPaletteItem( + let item = makeItem( id: "ghostty.goto_split:right|Focus Split Right", title: "Focus Split Right", subtitle: nil, @@ -1170,6 +1170,63 @@ private func makeRepository( ) } +private func makeItem( + id: String, + title: String, + subtitle: String?, + kind: CommandPaletteItem.Kind, + priorityTier: Int = CommandPaletteItem.defaultPriorityTier +) -> CommandPaletteItem { + CommandPaletteItem( + id: id, + title: title, + subtitle: subtitle, + kind: kind, + category: testCategory(for: kind), + defaultSuggestion: testDefaultSuggestion(for: kind), + priorityTier: priorityTier + ) +} + +private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteItem.Category { + switch kind { + case .checkForUpdates, .openSettings, .openRepository, .installCLI: + return .app + case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, + .removeWorktree, .archiveWorktree, .changeFocusedTabIcon: + return .worktree + case .jumpToLatestUnread, .worktreeSelect: + return .navigation + case .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, + .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, + .rerunFailedJobs, .openFailingCheckDetails: + return .pullRequest + case .ghosttyCommand: + return .terminal + #if DEBUG + case .debugTestToast, .debugSimulateUpdateFound: + return .debug + #endif + } +} + +private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { + switch kind { + case .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, + .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails: + return true + case .checkForUpdates, .openSettings, .openRepository, .installCLI, + .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, + .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, + .ghosttyCommand, .openRepositoryOnCodeHost: + return false + #if DEBUG + case .debugTestToast, .debugSimulateUpdateFound: + return true + #endif + } +} + private func makePullRequest( state: String = "OPEN", isDraft: Bool = false, -- 2.51.2 From eb190b254d34c2f7f0a793f30e9f8250d799b998 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 16 May 2026 14:56:10 +0900 Subject: [PATCH 134/528] test: pin filterItems empty-query contract on defaultSuggestion alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other empty-query tests go through the kind → makeItem helper mapping, so they conflate two contracts: "kind X maps to defaultSuggestion Y" and "filter respects defaultSuggestion". This direct test constructs two items with the same kind and only the flag differing, so future changes to the kind/category mapping cannot accidentally break or pass the filter contract. --- .../CommandPaletteFeatureTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 80358f9f..0bad2f2b 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -247,6 +247,28 @@ struct CommandPaletteFeatureTests { #expect(searchedItems.contains(where: { $0.id == changeIconItem?.id })) } + @Test func filterItemsEmptyQueryHonorsDefaultSuggestionFlagOnly() { + let suggested = CommandPaletteItem( + id: "suggested", + title: "Suggested", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: true + ) + let hidden = CommandPaletteItem( + id: "hidden", + title: "Hidden", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: false + ) + + let result = CommandPaletteFeature.filterItems(items: [suggested, hidden], query: "") + expectNoDifference(result.map(\.id), [suggested.id]) + } + @Test func emptyQueryHidesGhosttyCommands() { let ghosttyItem = makeItem( id: "ghostty.goto_split:right|Focus Split Right", -- 2.51.2 From 147a9ca9c78a583a2cfd7933ed118a27041fa13f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 16 May 2026 15:18:55 +0900 Subject: [PATCH 135/528] feat(command palette): populated empty-query view with Recent/Suggested sections and keyword aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+P used to open to a blank list unless the current worktree had a PR. The new behavior: - Empty query shows up to 8 items split into "Recent" (anything used, ranked by 7-day half-life decay) and "Suggested" (defaultSuggestion=true items not already in Recent, sorted by priorityTier then declaration order). Section headers render only when query is empty; typing collapses back to a flat fuzzy-ranked list. - The 8 app-level commands (Check for Updates, Open Settings, Open Repository, New Worktree, Refresh Worktrees, Jump to Latest Unread, View Archived Worktrees, Install CLI) flip to defaultSuggestion=true so they show up on open. - Each app-level command gets keyword aliases ("preferences" → Open Settings, "cli" → Install CLI, etc.). Keywords participate in fuzzy scoring but never display; if a keyword outscores the title, the row still uses title-derived highlight positions so no synthetic offsets leak into the visible string. New: CommandPaletteSuggestions value type + CommandPaletteFeature.suggestions(...) static method. filterItems(query: "") now delegates to suggestions().allItems to keep the flat-array contract. --- .../CommandPalette/CommandPaletteItem.swift | 10 + .../Reducer/CommandPaletteFeature.swift | 127 ++++++--- .../Views/CommandPaletteOverlayView.swift | 100 ++++++-- .../CommandPaletteFeatureTests.swift | 240 +++++++++++++++++- 4 files changed, 410 insertions(+), 67 deletions(-) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 0830b73b..5d12c82c 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -1,3 +1,13 @@ +struct CommandPaletteSuggestions: Equatable { + static let maxItems = 8 + + let recent: [CommandPaletteItem] + let suggested: [CommandPaletteItem] + + var allItems: [CommandPaletteItem] { recent + suggested } + var isEmpty: Bool { recent.isEmpty && suggested.isEmpty } +} + struct CommandPaletteItem: Identifiable, Equatable { static let defaultPriorityTier = 100 diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 24e6c4fc..6ac6cbae 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -149,6 +149,42 @@ struct CommandPaletteFeature { } } + static func suggestions( + items: [CommandPaletteItem], + recencyByID: [CommandPaletteItem.ID: TimeInterval] = [:], + now: Date = .now + ) -> CommandPaletteSuggestions { + let recencyScored: [(item: CommandPaletteItem, score: Double)] = items.compactMap { item in + let score = commandPaletteRecencyScore(item, recencyByID: recencyByID, now: now) + return score > 0 ? (item, score) : nil + } + let recent = Array( + recencyScored + .sorted { $0.score > $1.score } + .prefix(CommandPaletteSuggestions.maxItems) + .map(\.item) + ) + + let recentIDs = Set(recent.map(\.id)) + let suggestedCandidates = items.enumerated().compactMap { idx, item -> (item: CommandPaletteItem, idx: Int)? in + guard item.defaultSuggestion, !recentIDs.contains(item.id) else { return nil } + return (item, idx) + } + let suggested = Array( + suggestedCandidates + .sorted { left, right in + if left.item.priorityTier != right.item.priorityTier { + return left.item.priorityTier < right.item.priorityTier + } + return left.idx < right.idx + } + .prefix(CommandPaletteSuggestions.maxItems - recent.count) + .map(\.item) + ) + + return CommandPaletteSuggestions(recent: recent, suggested: suggested) + } + static func filterItems( items: [CommandPaletteItem], query: String, @@ -157,8 +193,7 @@ struct CommandPaletteFeature { ) -> [CommandPaletteItem] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - let visibleItems = items.filter(\.defaultSuggestion) - return prioritizeItems(items: visibleItems, recencyByID: recencyByID, now: now) + return suggestions(items: items, recencyByID: recencyByID, now: now).allItems } let scorer = CommandPaletteFuzzyScorer(query: trimmed, recencyByID: recencyByID, now: now) return scorer.rankedItems(from: items) @@ -230,7 +265,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .checkForUpdates, category: .app, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["update", "version"] ), CommandPaletteItem( id: CommandPaletteItemID.globalOpenSettings, @@ -238,7 +274,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .openSettings, category: .app, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["preferences", "config"] ), CommandPaletteItem( id: CommandPaletteItemID.globalOpenRepository, @@ -246,7 +283,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .openRepository, category: .app, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["repo", "add repo"] ), ] if showsNewWorktreeAction { @@ -257,7 +295,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .newWorktree, category: .worktree, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["worktree", "branch"] ) ) } @@ -268,7 +307,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .refreshWorktrees, category: .worktree, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["reload", "rescan"] ) ) items.append( @@ -278,7 +318,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .jumpToLatestUnread, category: .navigation, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["unread", "bell", "notification"] ) ) items.append( @@ -288,7 +329,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .viewArchivedWorktrees, category: .worktree, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["archive", "history"] ) ) items.append( @@ -298,7 +340,8 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette subtitle: nil, kind: .installCLI, category: .app, - defaultSuggestion: false + defaultSuggestion: true, + keywords: ["cli", "command line", "terminal", "prowl"] ) ) return items @@ -643,26 +686,6 @@ private enum CommandPaletteItemID { } } -private func prioritizeItems( - items: [CommandPaletteItem], - recencyByID: [CommandPaletteItem.ID: TimeInterval], - now: Date -) -> [CommandPaletteItem] { - let scored = items.enumerated().map { index, item in - (item: item, index: index, recency: commandPaletteRecencyScore(item, recencyByID: recencyByID, now: now)) - } - let sorted = scored.sorted { left, right in - if left.item.priorityTier != right.item.priorityTier { - return left.item.priorityTier < right.item.priorityTier - } - if left.item.priorityTier < CommandPaletteItem.defaultPriorityTier, left.recency != right.recency { - return left.recency > right.recency - } - return left.index < right.index - } - return sorted.map(\.item) -} - private func commandPaletteRecencyScore( _ item: CommandPaletteItem, recencyByID: [CommandPaletteItem.ID: TimeInterval], @@ -899,19 +922,27 @@ private struct CommandPaletteFuzzyScorer { return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) } - let label = item.title - let description = item.subtitle - if let values = query.values, !values.isEmpty { - return scoreItemMultiple(label: label, description: description, query: values) + return scoreItemMultiple( + label: item.title, + description: item.subtitle, + keywords: item.keywords, + query: values + ) } - return scoreItemSingle(label: label, description: description, query: query.piece) + return scoreItemForPiece( + label: item.title, + description: item.subtitle, + keywords: item.keywords, + query: query.piece + ) } private func scoreItemMultiple( label: String, description: String?, + keywords: [String], query: [PreparedQueryPiece] ) -> ItemScore { var totalScore = 0 @@ -919,7 +950,7 @@ private struct CommandPaletteFuzzyScorer { var totalDescriptionMatches: [Match] = [] for piece in query { - let score = scoreItemSingle(label: label, description: description, query: piece) + let score = scoreItemForPiece(label: label, description: description, keywords: keywords, query: piece) if score.score == 0 { return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) } @@ -939,6 +970,30 @@ private struct CommandPaletteFuzzyScorer { ) } + /// Score one query piece against the item's title (with description fallback) + /// and each keyword. Keywords act as alternative labels — when one outscores + /// the title path, we keep title-derived highlight positions so the UI never + /// paints offsets that don't exist in the visible string. + private func scoreItemForPiece( + label: String, + description: String?, + keywords: [String], + query: PreparedQueryPiece + ) -> ItemScore { + var best = scoreItemSingle(label: label, description: description, query: query) + for keyword in keywords { + let keywordScore = scoreItemSingle(label: keyword, description: nil, query: query) + if keywordScore.score > best.score { + best = ItemScore( + score: keywordScore.score, + labelMatch: best.labelMatch, + descriptionMatch: best.descriptionMatch + ) + } + } + return best + } + private func scoreItemSingle( label: String, description: String?, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 997d9585..cf35d6ef 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -11,6 +11,7 @@ struct CommandPaletteOverlayView: View { @State private var queryFocusTask: Task? @State private var hoveredID: CommandPaletteItem.ID? @State private var filteredItems: [CommandPaletteItem] = [] + @State private var sectionedSuggestions: CommandPaletteSuggestions? var body: some View { ZStack { @@ -36,6 +37,7 @@ struct CommandPaletteOverlayView: View { query: $store.query, selectedIndex: $store.selectedIndex, items: filteredItems, + sections: sectionedSuggestions, resolvedKeybindings: resolvedKeybindings, hoveredID: $hoveredID, isQueryFocused: isQueryFocused, @@ -142,14 +144,25 @@ struct CommandPaletteOverlayView: View { private func refreshFilteredItems(items: [CommandPaletteItem]) -> [CommandPaletteItem] { let now = Date.now - let updatedItems = CommandPaletteFeature.filterItems( - items: items, - query: store.query, - recencyByID: store.recencyByItemID, - now: now - ) - filteredItems = updatedItems - return updatedItems + let trimmed = store.query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + let suggestions = CommandPaletteFeature.suggestions( + items: items, + recencyByID: store.recencyByItemID, + now: now + ) + sectionedSuggestions = suggestions + filteredItems = suggestions.allItems + } else { + sectionedSuggestions = nil + filteredItems = CommandPaletteFeature.filterItems( + items: items, + query: trimmed, + recencyByID: store.recencyByItemID, + now: now + ) + } + return filteredItems } private func focusQueryField() { @@ -179,6 +192,7 @@ private struct CommandPaletteCard: View { @Binding var query: String @Binding var selectedIndex: Int? let items: [CommandPaletteItem] + let sections: CommandPaletteSuggestions? let resolvedKeybindings: ResolvedKeybindingMap @Binding var hoveredID: CommandPaletteItem.ID? let isQueryFocused: Bool @@ -207,6 +221,7 @@ private struct CommandPaletteCard: View { CommandPaletteList( rows: items, + sections: sections, resolvedKeybindings: resolvedKeybindings, selectedIndex: $selectedIndex, hoveredID: $hoveredID @@ -416,6 +431,7 @@ private struct CommandPaletteList: View { static let listHeight: CGFloat = 200 let rows: [CommandPaletteItem] + let sections: CommandPaletteSuggestions? let resolvedKeybindings: ResolvedKeybindingMap @Binding var selectedIndex: Int? @Binding var hoveredID: CommandPaletteItem.ID? @@ -428,17 +444,10 @@ private struct CommandPaletteList: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 4) { - ForEach(Array(rows.enumerated()), id: \.1.id) { index, row in - CommandPaletteRowView( - row: row, - resolvedKeybindings: resolvedKeybindings, - shortcutIndex: index < 5 ? index : nil, - isSelected: isRowSelected(index: index), - hoveredID: $hoveredID - ) { - activate(row.id) - } - .id(row.id) + if let sections { + renderSectioned(sections) + } else { + renderFlat() } } .padding(10) @@ -452,6 +461,47 @@ private struct CommandPaletteList: View { } } + @ViewBuilder + private func renderFlat() -> some View { + ForEach(Array(rows.enumerated()), id: \.1.id) { index, row in + rowView(for: row, index: index) + } + } + + @ViewBuilder + private func renderSectioned(_ sections: CommandPaletteSuggestions) -> some View { + if !sections.recent.isEmpty { + CommandPaletteSectionHeader(title: "Recent") + ForEach(sections.recent) { row in + if let index = rows.firstIndex(where: { $0.id == row.id }) { + rowView(for: row, index: index) + } + } + } + if !sections.suggested.isEmpty { + CommandPaletteSectionHeader(title: "Suggested") + .padding(.top, sections.recent.isEmpty ? 0 : 6) + ForEach(sections.suggested) { row in + if let index = rows.firstIndex(where: { $0.id == row.id }) { + rowView(for: row, index: index) + } + } + } + } + + private func rowView(for row: CommandPaletteItem, index: Int) -> some View { + CommandPaletteRowView( + row: row, + resolvedKeybindings: resolvedKeybindings, + shortcutIndex: index < 5 ? index : nil, + isSelected: isRowSelected(index: index), + hoveredID: $hoveredID + ) { + activate(row.id) + } + .id(row.id) + } + private func isRowSelected(index: Int) -> Bool { guard let selectedIndex else { return false } if selectedIndex < rows.count { @@ -461,6 +511,18 @@ private struct CommandPaletteList: View { } } +private struct CommandPaletteSectionHeader: View { + let title: String + + var body: some View { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.bottom, 2) + } +} + private struct CommandPaletteRowView: View { let row: CommandPaletteItem let resolvedKeybindings: ResolvedKeybindingMap diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 0bad2f2b..7129566c 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -247,6 +247,222 @@ struct CommandPaletteFeatureTests { #expect(searchedItems.contains(where: { $0.id == changeIconItem?.id })) } + @Test func keywordOnlyMatchSurfacesItem() { + // "preferences" cannot fuzzy-match "Open Settings" (no p/r/f) so a match + // only succeeds if keywords participate. + let openSettings = makeItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + keywords: ["preferences"] + ) + + let result = CommandPaletteFeature.filterItems(items: [openSettings], query: "preferences") + expectNoDifference(result.map(\.id), [openSettings.id]) + } + + @Test func keywordMatchRanksBelowDirectTitleMatch() { + let openSettings = makeItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + keywords: ["preferences"] + ) + let prefBranch = makeItem( + id: "worktree.preferences.select", + title: "preferences", + subtitle: nil, + kind: .worktreeSelect("wt-pref") + ) + + let result = CommandPaletteFeature.filterItems(items: [openSettings, prefBranch], query: "preferences") + #expect(result.first?.id == prefBranch.id) + } + + @Test func keywordMatchSurvivesMultiPieceQuery() { + // "preferences" has to match via the keyword; "settings" matches the title. + let openSettings = makeItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + keywords: ["preferences"] + ) + + let result = CommandPaletteFeature.filterItems(items: [openSettings], query: "preferences settings") + expectNoDifference(result.map(\.id), [openSettings.id]) + } + + @Test func keywordMatchDoesNotIntroduceLabelHighlightsOutsideTitle() { + let openSettings = makeItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + keywords: ["preferences"] + ) + + let result = CommandPaletteFeature.filterItems(items: [openSettings], query: "preferences") + #expect(result.count == 1) + // Sanity: the returned item is unchanged — no synthetic match positions leaked into the item. + #expect(result.first?.title == openSettings.title) + } + + // MARK: - suggestions() + + @Test func suggestionsEmptyInputReturnsEmptySections() { + let result = CommandPaletteFeature.suggestions(items: [], recencyByID: [:], now: .now) + #expect(result.recent.isEmpty) + #expect(result.suggested.isEmpty) + } + + @Test func suggestionsOnlyDefaultSuggestionItemsAppearWhenNoRecency() { + let suggested = makeItem( + id: "s", + title: "Suggested", + subtitle: nil, + kind: .openSettings + ) + let hidden = makeItem( + id: "h", + title: "Hidden", + subtitle: nil, + kind: .ghosttyCommand("focus_split") + ) + let suggestedWithFlag = CommandPaletteItem( + id: suggested.id, + title: suggested.title, + subtitle: suggested.subtitle, + kind: suggested.kind, + category: suggested.category, + defaultSuggestion: true + ) + + let result = CommandPaletteFeature.suggestions( + items: [suggestedWithFlag, hidden], + recencyByID: [:], + now: .now + ) + #expect(result.recent.isEmpty) + expectNoDifference(result.suggested.map(\.id), [suggestedWithFlag.id]) + } + + @Test func suggestionsRecentIncludesNonSuggestionItemsWithRecency() { + let now = Date(timeIntervalSince1970: 1_000_000) + let worktree = makeItem( + id: "worktree.fox.select", + title: "Repo / fox", + subtitle: nil, + kind: .worktreeSelect("wt-fox") + ) + + let result = CommandPaletteFeature.suggestions( + items: [worktree], + recencyByID: [worktree.id: now.timeIntervalSince1970 - 86_400], + now: now + ) + expectNoDifference(result.recent.map(\.id), [worktree.id]) + #expect(result.suggested.isEmpty) + } + + @Test func suggestionsRecentSortedByRecencyDesc() { + let now = Date(timeIntervalSince1970: 1_000_000) + let recent = makeItem(id: "recent", title: "Recent", subtitle: nil, kind: .openSettings) + let older = makeItem(id: "older", title: "Older", subtitle: nil, kind: .openRepository) + + let result = CommandPaletteFeature.suggestions( + items: [older, recent], + recencyByID: [ + recent.id: now.timeIntervalSince1970 - 86_400, + older.id: now.timeIntervalSince1970 - 10 * 86_400, + ], + now: now + ) + expectNoDifference(result.recent.map(\.id), [recent.id, older.id]) + } + + @Test func suggestionsDedupesRecentFromSuggested() { + let now = Date(timeIntervalSince1970: 1_000_000) + let appLevel = CommandPaletteItem( + id: "global.open-settings", + title: "Open Settings", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: true + ) + + let result = CommandPaletteFeature.suggestions( + items: [appLevel], + recencyByID: [appLevel.id: now.timeIntervalSince1970 - 86_400], + now: now + ) + expectNoDifference(result.recent.map(\.id), [appLevel.id]) + #expect(result.suggested.isEmpty) + } + + @Test func suggestionsCapsTotalAt8() { + let now = Date(timeIntervalSince1970: 1_000_000) + let items = (0..<12).map { index in + makeItem( + id: "item-\(index)", + title: "Item \(index)", + subtitle: nil, + kind: .worktreeSelect("wt-\(index)") + ) + } + let recency = Dictionary( + uniqueKeysWithValues: items.enumerated().map { idx, item in + (item.id, now.timeIntervalSince1970 - TimeInterval(idx + 1) * 3600) + } + ) + + let result = CommandPaletteFeature.suggestions(items: items, recencyByID: recency, now: now) + #expect(result.recent.count + result.suggested.count == CommandPaletteSuggestions.maxItems) + } + + @Test func suggestionsSuggestedSortsByPriorityTierThenDeclarationOrder() { + let lowPriority = CommandPaletteItem( + id: "low", + title: "Low", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: true, + priorityTier: 0 + ) + let defaultPriorityFirst = CommandPaletteItem( + id: "default-first", + title: "Default First", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: true, + priorityTier: CommandPaletteItem.defaultPriorityTier + ) + let defaultPrioritySecond = CommandPaletteItem( + id: "default-second", + title: "Default Second", + subtitle: nil, + kind: .openSettings, + category: .app, + defaultSuggestion: true, + priorityTier: CommandPaletteItem.defaultPriorityTier + ) + + let result = CommandPaletteFeature.suggestions( + items: [defaultPriorityFirst, defaultPrioritySecond, lowPriority], + recencyByID: [:], + now: .now + ) + expectNoDifference( + result.suggested.map(\.id), + [lowPriority.id, defaultPriorityFirst.id, defaultPrioritySecond.id] + ) + } + @Test func filterItemsEmptyQueryHonorsDefaultSuggestionFlagOnly() { let suggested = CommandPaletteItem( id: "suggested", @@ -519,13 +735,11 @@ struct CommandPaletteFeatureTests { kind: .removeWorktree("wt-fox", "repo-fox") ) - expectNoDifference( - CommandPaletteFeature.filterItems( - items: [openSettings, newWorktree, selectFox, archiveFox, removeFox], - query: "" - ), - [] + let result = CommandPaletteFeature.filterItems( + items: [openSettings, newWorktree, selectFox, archiveFox, removeFox], + query: "" ) + expectNoDifference(result.map(\.id), [openSettings.id, newWorktree.id]) } @Test func queryKeepsSelectionWhenEmpty() async { @@ -1026,7 +1240,7 @@ struct CommandPaletteFeatureTests { #expect(result.first?.id == worktreeItem.id) } - @Test func emptyQueryStillHidesRootActionsAndWorktrees() { + @Test func emptyQueryShowsSuggestionItemsAndHidesContextualOnes() { let checkForUpdates = makeItem( id: "global.check-for-updates", title: "Check for Updates", @@ -1052,7 +1266,7 @@ struct CommandPaletteFeatureTests { query: "" ) - #expect(!result.contains { $0.id == checkForUpdates.id }) + #expect(result.contains { $0.id == checkForUpdates.id }) #expect(!result.contains { $0.id == worktreeFox.id }) #expect(result.contains { $0.id == prAction.id }) } @@ -1197,6 +1411,7 @@ private func makeItem( title: String, subtitle: String?, kind: CommandPaletteItem.Kind, + keywords: [String] = [], priorityTier: Int = CommandPaletteItem.defaultPriorityTier ) -> CommandPaletteItem { CommandPaletteItem( @@ -1206,6 +1421,7 @@ private func makeItem( kind: kind, category: testCategory(for: kind), defaultSuggestion: testDefaultSuggestion(for: kind), + keywords: keywords, priorityTier: priorityTier ) } @@ -1234,12 +1450,12 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { switch kind { - case .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, - .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails: - return true case .checkForUpdates, .openSettings, .openRepository, .installCLI, .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, - .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, + .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, + .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails: + return true + case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost: return false #if DEBUG -- 2.51.2 From 1070e044b8697dca427a5f04d1fbd837b946bcde Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 16 May 2026 16:17:47 +0900 Subject: [PATCH 136/528] refactor(command palette): add appShortcut + ghosttyCommand factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two static factories on CommandPaletteItem to compress the most repetitive construction patterns ahead of PR4+: - appShortcut(id:title:category:kind:keywords:priorityTier:) — for top-level AppShortcut-backed commands. Defaults defaultSuggestion: true and omits subtitle (hotkey hint + title are the signal). - ghosttyCommand(_ command:) — wraps a GhosttyCommand value into an item in the .terminal category, search-only, priority +100 (ranks below regular suggestions when matches tie). Re-expresses globalCommandItems and ghosttyCommandItems through the factories. Pure refactor — no behavior change, all tests pass unchanged. The third factory the plan sketched (contextual(...)) would only save one line per call site versus the explicit init, so it's deferred. If PR4+ ends up adding many contextual commands with shared shape, we can add it then. --- .../Reducer/CommandPaletteFeature.swift | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6ac6cbae..8b55655a 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -259,88 +259,72 @@ struct CommandPaletteFeature { private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPaletteItem] { var items: [CommandPaletteItem] = [ - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalCheckForUpdates, title: "Check for Updates", - subtitle: nil, - kind: .checkForUpdates, category: .app, - defaultSuggestion: true, + kind: .checkForUpdates, keywords: ["update", "version"] ), - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalOpenSettings, title: "Open Settings", - subtitle: nil, - kind: .openSettings, category: .app, - defaultSuggestion: true, + kind: .openSettings, keywords: ["preferences", "config"] ), - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalOpenRepository, title: "Open Repository", - subtitle: nil, - kind: .openRepository, category: .app, - defaultSuggestion: true, + kind: .openRepository, keywords: ["repo", "add repo"] ), ] if showsNewWorktreeAction { items.append( - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalNewWorktree, title: "New Worktree", - subtitle: nil, - kind: .newWorktree, category: .worktree, - defaultSuggestion: true, + kind: .newWorktree, keywords: ["worktree", "branch"] ) ) } items.append( - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalRefreshWorktrees, title: "Refresh Worktrees", - subtitle: nil, - kind: .refreshWorktrees, category: .worktree, - defaultSuggestion: true, + kind: .refreshWorktrees, keywords: ["reload", "rescan"] ) ) items.append( - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalJumpToLatestUnread, title: "Jump to Latest Unread", - subtitle: nil, - kind: .jumpToLatestUnread, category: .navigation, - defaultSuggestion: true, + kind: .jumpToLatestUnread, keywords: ["unread", "bell", "notification"] ) ) items.append( - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalViewArchivedWorktrees, title: "View Archived Worktrees", - subtitle: nil, - kind: .viewArchivedWorktrees, category: .worktree, - defaultSuggestion: true, + kind: .viewArchivedWorktrees, keywords: ["archive", "history"] ) ) items.append( - CommandPaletteItem( + .appShortcut( id: CommandPaletteItemID.globalInstallCLI, title: "Install Command Line Tool", - subtitle: nil, - kind: .installCLI, category: .app, - defaultSuggestion: true, + kind: .installCLI, keywords: ["cli", "command line", "terminal", "prowl"] ) ) @@ -826,6 +810,39 @@ private let filteredGhosttyActionKeys: Set = [ private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { commands.compactMap { command in guard !filteredGhosttyActionKeys.contains(command.actionKey) else { return nil } + return .ghosttyCommand(command) + } +} + +extension CommandPaletteItem { + /// Build a top-level command backed by an `AppShortcuts` hotkey. Defaults to + /// `defaultSuggestion: true` (since these are the kinds of actions worth + /// listing when the palette opens with no query) and uses no subtitle (the + /// hotkey hint and title already do the work). + static func appShortcut( + id: String, + title: String, + category: Category, + kind: Kind, + keywords: [String] = [], + priorityTier: Int = defaultPriorityTier + ) -> CommandPaletteItem { + CommandPaletteItem( + id: id, + title: title, + subtitle: nil, + kind: kind, + category: category, + defaultSuggestion: true, + keywords: keywords, + priorityTier: priorityTier + ) + } + + /// Build an item wrapping a Ghostty command exposed via the runtime. + /// Lives in `.terminal`, ranks below regular suggestions (priority +100), + /// and is search-only (never surfaces on empty query). + static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem { let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) return CommandPaletteItem( id: CommandPaletteItemID.ghosttyCommand(command), -- 2.51.2 From 7d0fdbd063710bf70fb91da3a976cea78fa309b5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 16 May 2026 16:49:10 +0900 Subject: [PATCH 137/528] feat(command palette): add View toggles (Sidebar, Active Agents, Canvas, Shelf, Show Diff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the 5 view toggles that were previously hotkey-only: - Toggle Sidebar (⌘⌃S) - Toggle Active Agents Panel (⌘⌥P) - Toggle Canvas (⌘⌥↩) - Toggle Shelf (⌘⇧↩) - Show Diff (⌘⇧Y) — only appears when a worktree is selected All registered through the `.appShortcut` factory (PR3), so each is a one-liner. They live in the new `.view` category, are suggested by default, and ship with keyword aliases ("sidebar", "agents", "canvas", "shelf", "diff", etc.). Architectural change: lift `leftSidebarVisibility` from ContentView's private @State into AppFeature.State, so the palette can dispatch `.toggleLeftSidebar` and `.showLeftSidebar` actions via the existing delegate-routing mechanism. The diff window keeps its own per-scene column visibility — they're independent NavigationSplitViews. --- supacode/App/ContentView.swift | 32 +++---- .../Features/App/Reducer/AppFeature.swift | 45 ++++++++++ .../CommandPalette/CommandPaletteItem.swift | 15 ++++ .../Reducer/CommandPaletteFeature.swift | 84 ++++++++++++++++++- .../Views/CommandPaletteOverlayView.swift | 26 +++++- .../CommandPaletteFeatureTests.swift | 25 +++++- 6 files changed, 208 insertions(+), 19 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index fd87b11f..ca6c34f4 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -15,7 +15,6 @@ struct ContentView: View { let terminalManager: WorktreeTerminalManager @Environment(\.scenePhase) private var scenePhase @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts - @State private var leftSidebarVisibility: NavigationSplitViewVisibility = .all init(store: StoreOf, terminalManager: WorktreeTerminalManager) { self.store = store @@ -34,13 +33,7 @@ struct ContentView: View { ) Group { if store.repositories.isInitialLoadComplete { - NavigationSplitView(columnVisibility: $leftSidebarVisibility) { - SidebarView(store: repositoriesStore, terminalManager: terminalManager) - .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) - } detail: { - WorktreeDetailView(store: store, terminalManager: terminalManager) - } - .navigationSplitViewStyle(.automatic) + mainSplitView } else { AppLoadingView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -102,18 +95,29 @@ struct ContentView: View { .background(WindowTabbingDisabler()) } - private func toggleLeftSidebar() { - withAnimation(.easeOut(duration: 0.2)) { - leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly + private var mainSplitView: some View { + let visibility = Binding( + get: { store.leftSidebarVisibility }, + set: { store.send(.setLeftSidebarVisibility($0)) } + ) + return NavigationSplitView(columnVisibility: visibility) { + SidebarView(store: repositoriesStore, terminalManager: terminalManager) + .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) + } detail: { + WorktreeDetailView(store: store, terminalManager: terminalManager) } + .navigationSplitViewStyle(.automatic) + .animation(.easeOut(duration: 0.2), value: store.leftSidebarVisibility) + } + + private func toggleLeftSidebar() { + store.send(.toggleLeftSidebar) } private var revealInSidebarAction: (() -> Void)? { guard store.repositories.selectedWorktreeID != nil else { return nil } return { - withAnimation(.easeOut(duration: 0.2)) { - leftSidebarVisibility = .all - } + store.send(.showLeftSidebar) store.send(.repositories(.revealSelectedWorktreeInSidebar)) } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 8702140c..6d9d8a98 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -54,6 +54,7 @@ struct AppFeature { var launchRestoreMode: LaunchRestoreMode var suppressLayoutSaveUntilRelaunch = false var launchedAt: Date? + var leftSidebarVisibility: NavigationSplitViewVisibility = .all @Presents var alert: AlertState? init( @@ -83,6 +84,9 @@ struct AppFeature { case requestQuit case newTerminal case jumpToLatestUnread + case toggleLeftSidebar + case showLeftSidebar + case setLeftSidebarVisibility(NavigationSplitViewVisibility) case runScript case runCustomCommand(Int) case runScriptDraftChanged(String) @@ -679,6 +683,18 @@ struct AppFeature { } ) + case .toggleLeftSidebar: + state.leftSidebarVisibility = state.leftSidebarVisibility == .detailOnly ? .all : .detailOnly + return .none + + case .showLeftSidebar: + state.leftSidebarVisibility = .all + return .none + + case .setLeftSidebarVisibility(let visibility): + state.leftSidebarVisibility = visibility + return .none + case .runScript: guard let worktree = state.repositories.selectedTerminalWorktree else { return .none @@ -1018,6 +1034,35 @@ struct AppFeature { case .commandPalette(.delegate(.installCLI)): return .send(.settings(.installCLIButtonTapped(showAlert: false))) + case .commandPalette(.delegate(.toggleLeftSidebar)): + return .send(.toggleLeftSidebar) + + case .commandPalette(.delegate(.toggleActiveAgentsPanel)): + return .send(.repositories(.activeAgents(.togglePanelVisibility))) + + case .commandPalette(.delegate(.toggleCanvas)): + return .send(.repositories(.toggleCanvas)) + + case .commandPalette(.delegate(.toggleShelf)): + return .send(.repositories(.toggleShelf)) + + case .commandPalette(.delegate(.showDiff)): + guard let worktreeID = state.repositories.selectedWorktreeID, + let worktree = state.repositories.worktree(for: worktreeID) + else { + return .none + } + let keybindings = state.resolvedKeybindings + return .run { _ in + await MainActor.run { + DiffWindowManager.shared.show( + worktreeURL: worktree.workingDirectory, + branchName: worktree.name, + resolvedKeybindings: keybindings + ) + } + } + case .commandPalette(.delegate(.ghosttyCommand(let action))): guard let worktree = state.repositories.selectedTerminalWorktree else { return .none diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 5d12c82c..bf943678 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -63,6 +63,11 @@ struct CommandPaletteItem: Identifiable, Equatable { case openFailingCheckDetails(Worktree.ID) case installCLI case changeFocusedTabIcon(Worktree.ID) + case toggleLeftSidebar + case toggleActiveAgentsPanel + case toggleCanvas + case toggleShelf + case showDiff #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -100,6 +105,16 @@ struct CommandPaletteItem: Identifiable, Equatable { case .openPullRequest, .openRepositoryOnCodeHost: return AppShortcuts.CommandID.openPullRequest + case .toggleLeftSidebar: + return AppShortcuts.CommandID.toggleLeftSidebar + case .toggleActiveAgentsPanel: + return AppShortcuts.CommandID.toggleActiveAgentsPanel + case .toggleCanvas: + return AppShortcuts.CommandID.toggleCanvas + case .toggleShelf: + return AppShortcuts.CommandID.toggleShelf + case .showDiff: + return AppShortcuts.CommandID.showDiff case .ghosttyCommand, .markPullRequestReady, .mergePullRequest, diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 8b55655a..2ff75ced 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -52,6 +52,11 @@ struct CommandPaletteFeature { case openFailingCheckDetails(Worktree.ID) case installCLI case changeFocusedTabIcon(Worktree.ID) + case toggleLeftSidebar + case toggleActiveAgentsPanel + case toggleCanvas + case toggleShelf + case showDiff #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -207,6 +212,17 @@ struct CommandPaletteFeature { repositories.repositories.isEmpty || repositories.repositories.contains { $0.capabilities.supportsWorktrees } var items = globalCommandItems(showsNewWorktreeAction: showsNewWorktreeAction) + if repositories.selectedWorktreeID != nil { + items.append( + .appShortcut( + id: CommandPaletteItemID.globalShowDiff, + title: "Show Diff", + category: .view, + kind: .showDiff, + keywords: ["diff", "changes", "git"] + ) + ) + } if let terminalWorktree = repositories.selectedTerminalWorktree { items.append( CommandPaletteItem( @@ -328,9 +344,43 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette keywords: ["cli", "command line", "terminal", "prowl"] ) ) + items.append(contentsOf: viewToggleCommandItems()) return items } +private func viewToggleCommandItems() -> [CommandPaletteItem] { + [ + .appShortcut( + id: CommandPaletteItemID.globalToggleLeftSidebar, + title: "Toggle Sidebar", + category: .view, + kind: .toggleLeftSidebar, + keywords: ["sidebar", "hide", "left panel"] + ), + .appShortcut( + id: CommandPaletteItemID.globalToggleActiveAgentsPanel, + title: "Toggle Active Agents Panel", + category: .view, + kind: .toggleActiveAgentsPanel, + keywords: ["agents", "panel"] + ), + .appShortcut( + id: CommandPaletteItemID.globalToggleCanvas, + title: "Toggle Canvas", + category: .view, + kind: .toggleCanvas, + keywords: ["canvas", "overview", "grid"] + ), + .appShortcut( + id: CommandPaletteItemID.globalToggleShelf, + title: "Toggle Shelf", + category: .view, + kind: .toggleShelf, + keywords: ["shelf", "books"] + ), + ] +} + private func selectedCodeHostItems( from repositories: RepositoriesFeature.State ) -> [CommandPaletteItem] { @@ -598,6 +648,11 @@ private enum CommandPaletteItemID { static let globalJumpToLatestUnread = "global.jump-to-latest-unread" static let globalViewArchivedWorktrees = "global.view-archived-worktrees" static let globalInstallCLI = "global.install-cli" + static let globalToggleLeftSidebar = "global.toggle-left-sidebar" + static let globalToggleActiveAgentsPanel = "global.toggle-active-agents-panel" + static let globalToggleCanvas = "global.toggle-canvas" + static let globalToggleShelf = "global.toggle-shelf" + static let globalShowDiff = "global.show-diff" static var globalIDs: [CommandPaletteItem.ID] { [ @@ -609,6 +664,11 @@ private enum CommandPaletteItemID { globalJumpToLatestUnread, globalViewArchivedWorktrees, globalInstallCLI, + globalToggleLeftSidebar, + globalToggleActiveAgentsPanel, + globalToggleCanvas, + globalToggleShelf, + globalShowDiff, ] } @@ -720,7 +780,12 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .viewArchivedWorktrees, .refreshWorktrees, .jumpToLatestUnread, - .installCLI: + .installCLI, + .toggleLeftSidebar, + .toggleActiveAgentsPanel, + .toggleCanvas, + .toggleShelf, + .showDiff: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -743,6 +808,16 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .jumpToLatestUnread case .installCLI: return .installCLI + case .toggleLeftSidebar: + return .toggleLeftSidebar + case .toggleActiveAgentsPanel: + return .toggleActiveAgentsPanel + case .toggleCanvas: + return .toggleCanvas + case .toggleShelf: + return .toggleShelf + case .showDiff: + return .showDiff default: return nil } @@ -781,7 +856,12 @@ private func pullRequestDelegateAction( .jumpToLatestUnread, .installCLI, .ghosttyCommand, - .changeFocusedTabIcon: + .changeFocusedTabIcon, + .toggleLeftSidebar, + .toggleActiveAgentsPanel, + .toggleCanvas, + .toggleShelf, + .showDiff: return nil #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index cf35d6ef..9c66f0ae 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -538,7 +538,8 @@ private struct CommandPaletteRowView: View { .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, - .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon: + .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: return nil case .removeWorktree: return "Remove" @@ -595,6 +596,16 @@ private struct CommandPaletteRowView: View { return "trash" case .archiveWorktree: return "archivebox" + case .toggleLeftSidebar: + return "sidebar.left" + case .toggleActiveAgentsPanel: + return "person.2" + case .toggleCanvas: + return "square.grid.2x2" + case .toggleShelf: + return "books.vertical" + case .showDiff: + return "doc.text.magnifyingglass" #if DEBUG case .debugTestToast: return "ladybug" @@ -611,7 +622,8 @@ private struct CommandPaletteRowView: View { .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, - .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon: + .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false @@ -731,6 +743,16 @@ private struct CommandPaletteRowView: View { base = "Install Command Line Tool" case .changeFocusedTabIcon: base = "Change Tab Icon" + case .toggleLeftSidebar: + base = "Toggle Sidebar" + case .toggleActiveAgentsPanel: + base = "Toggle Active Agents Panel" + case .toggleCanvas: + base = "Toggle Canvas" + case .toggleShelf: + base = "Toggle Shelf" + case .showDiff: + base = "Show Diff" #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: base = row.title diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 7129566c..7569ba3d 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -19,6 +19,10 @@ struct CommandPaletteFeatureTests { "global.jump-to-latest-unread", "global.view-archived-worktrees", "global.install-cli", + "global.toggle-left-sidebar", + "global.toggle-active-agents-panel", + "global.toggle-canvas", + "global.toggle-shelf", ] #if DEBUG expectedIDs.append(contentsOf: [ @@ -30,6 +34,22 @@ struct CommandPaletteFeatureTests { expectNoDifference(items.map(\.id), expectedIDs) } + @Test func commandPaletteItems_includesShowDiffWhenWorktreeSelected() { + let rootPath = "/tmp/repo-diff" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + #expect(items.contains { $0.id == "global.show-diff" }) + } + + @Test func commandPaletteItems_omitsShowDiffWithoutSelectedWorktree() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + #expect(!items.contains { $0.id == "global.show-diff" }) + } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) let item = items.first { $0.id == "global.jump-to-latest-unread" } @@ -1441,6 +1461,8 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt return .pullRequest case .ghosttyCommand: return .terminal + case .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: + return .view #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: return .debug @@ -1453,7 +1475,8 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { case .checkForUpdates, .openSettings, .openRepository, .installCLI, .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, - .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails: + .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost: -- 2.51.2 From bf8c1076f599257383111f0c40c9064aaf0dc446 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 17 May 2026 20:37:29 +0900 Subject: [PATCH 138/528] fix(command palette): align View toggle icons with the rest of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toggle Active Agents Panel: person.2 → person.crop.rectangle.stack, matching SidebarFooterView's panel toggle button. - Show Diff: doc.text.magnifyingglass → plusminus.circle. The action had no canonical icon elsewhere; settle on plusminus.circle as the shared symbol for future menu/toolbar entry points. --- .../CommandPalette/Views/CommandPaletteOverlayView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 9c66f0ae..40981435 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -599,13 +599,13 @@ private struct CommandPaletteRowView: View { case .toggleLeftSidebar: return "sidebar.left" case .toggleActiveAgentsPanel: - return "person.2" + return "person.crop.rectangle.stack" case .toggleCanvas: return "square.grid.2x2" case .toggleShelf: return "books.vertical" case .showDiff: - return "doc.text.magnifyingglass" + return "plusminus.circle" #if DEBUG case .debugTestToast: return "ladybug" -- 2.51.2 From de42599ce1e2a880b71e04285f86e86eec432e8e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 17 May 2026 20:44:58 +0900 Subject: [PATCH 139/528] feat(menu): add plusminus.circle icon to Show Diff menu item Mirrors the icon used in the command palette so the two entry points read as the same action. --- supacode/Commands/SidebarCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 1b531a91..0b4da087 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -55,7 +55,7 @@ struct SidebarCommands: Commands { ) .help(helpText(title: "Select Previous Book", commandID: AppShortcuts.CommandID.selectPreviousShelfBook)) shelfBookMenuButtons - Button("Show Diff") { + Button("Show Diff", systemImage: "plusminus.circle") { let repos = store.repositories guard let worktreeID = repos.selectedWorktreeID, let worktree = repos.worktree(for: worktreeID) -- 2.51.2 From 52786b1db7ff72ea66b6939a3598e0521cdd998f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 00:40:13 +0900 Subject: [PATCH 140/528] feat(palette): worktree navigation actions + focus restore by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three worktree navigation commands and reverses the focus-restore default so future delegate handlers inherit it for free. Navigation actions (gated on selectedWorktreeID): - Reveal in Finder — dispatches openWorktree(.finder), so it always opens Finder regardless of the user's configured default editor. No hotkey hint: ⌘O binds the configurable openWorktree action, which may resolve to a different tool. - Copy Path — writes selectedTerminalWorktree.workingDirectory.path to NSPasteboard inline. No hotkey hint: the underlying ⌘⇧C binding is dead (#295 tracks removing it). - Reveal in Sidebar — sends .showLeftSidebar followed by .revealSelectedWorktreeInSidebar. Focus restore refactor: The original fix (#287) patched five sites to merge restoreCommandPaletteTerminalFocusEffect into their effects. Every delegate handler added since silently dropped the behavior (Toggle Sidebar / Active Agents / Shelf, Show Diff, and the new navigation actions all lost focus restoration). Instead of per-case opt-in, this commit adds a dedicated Reduce after `core` that fires the restore for every CommandPalette delegate action except those in commandPaletteDelegateChangesActiveSelection (selectWorktree, jumpToLatestUnread, viewArchivedWorktrees, newWorktree, toggleCanvas). The three explicit .merge(restore...) calls in checkForUpdates / refreshWorktrees / debugTestToast are removed since they're now redundant. Future commands get focus restoration by default; opting out means adding the case to the deny list. Implementation notes: - navigationDelegateAction helper in CommandPaletteFeature mirrors the existing pullRequestDelegateAction pattern, keeping appDelegateAction under the cyclomatic-complexity threshold. - ghosttyCommand test updated to expect both performBindingAction and the new focusSelectedTab effect (order-independent assertions). - New tests cover the whitelist default (toggleLeftSidebar) and the deny list (toggleCanvas), plus delegate routing for all three new navigation actions. --- .../Features/App/Reducer/AppFeature.swift | 64 +++++-- .../CommandPalette/CommandPaletteItem.swift | 9 +- .../Reducer/CommandPaletteFeature.swift | 62 ++++++- .../Views/CommandPaletteOverlayView.swift | 18 +- .../AppFeatureCommandPaletteTests.swift | 169 +++++++++++++++++- .../CommandPaletteFeatureTests.swift | 27 ++- 6 files changed, 326 insertions(+), 23 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 6d9d8a98..29bd5fe4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -135,6 +135,21 @@ struct AppFeature { } } + /// Delegate actions that intentionally shift focus elsewhere (selection + /// changes, view changes that swap the focused terminal). These skip the + /// post-delegate focus restore so we don't fight the natural new focus. + private static func commandPaletteDelegateChangesActiveSelection( + _ delegate: CommandPaletteFeature.Delegate + ) -> Bool { + switch delegate { + case .selectWorktree, .jumpToLatestUnread, .viewArchivedWorktrees, + .newWorktree, .toggleCanvas: + return true + default: + return false + } + } + private func commandPaletteTerminalFocusTarget(repositories: RepositoriesFeature.State) -> Worktree? { if let worktree = repositories.selectedTerminalWorktree { return worktree @@ -994,10 +1009,7 @@ struct AppFeature { return .send(.repositories(.selectWorktree(worktreeID))) case .commandPalette(.delegate(.checkForUpdates)): - return .merge( - .send(.updates(.checkForUpdates)), - restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - ) + return .send(.updates(.checkForUpdates)) case .commandPalette(.delegate(.openSettings)): return .merge( @@ -1023,10 +1035,7 @@ struct AppFeature { return .send(.repositories(.selectArchivedWorktrees)) case .commandPalette(.delegate(.refreshWorktrees)): - return .merge( - .send(.repositories(.refreshWorktrees)), - restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - ) + return .send(.repositories(.refreshWorktrees)) case .commandPalette(.delegate(.jumpToLatestUnread)): return .send(.jumpToLatestUnread) @@ -1063,6 +1072,28 @@ struct AppFeature { } } + case .commandPalette(.delegate(.revealInFinder)): + return .send(.openWorktree(.finder)) + + case .commandPalette(.delegate(.copyPath)): + guard let worktree = state.repositories.selectedTerminalWorktree else { + return .none + } + let path = worktree.workingDirectory.path + return .run { _ in + await MainActor.run { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } + } + + case .commandPalette(.delegate(.revealInSidebar)): + guard state.repositories.selectedWorktreeID != nil else { return .none } + return .merge( + .send(.showLeftSidebar), + .send(.repositories(.revealSelectedWorktreeInSidebar)) + ) + case .commandPalette(.delegate(.ghosttyCommand(let action))): guard let worktree = state.repositories.selectedTerminalWorktree else { return .none @@ -1107,10 +1138,7 @@ struct AppFeature { #if DEBUG case .commandPalette(.delegate(.debugTestToast(let toast))): - return .merge( - .send(.repositories(.showToast(toast))), - restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - ) + return .send(.repositories(.showToast(toast))) case .commandPalette(.delegate(.debugSimulateUpdateFound)): return .send(.updates(.debugSimulateUpdateFound)) @@ -1244,6 +1272,18 @@ struct AppFeature { } } core + Reduce { state, action in + // Default-on focus restore: every command-palette delegate action that + // doesn't intentionally shift selection sends focus back to the active + // terminal once its effect has dispatched. Runs after `core` so it + // sees the same action and adds a parallel effect. + guard case .commandPalette(.delegate(let delegate)) = action, + !Self.commandPaletteDelegateChangesActiveSelection(delegate) + else { + return .none + } + return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) + } Scope(state: \.repositories, action: \.repositories) { RepositoriesFeature() } diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index bf943678..ce243532 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -68,6 +68,9 @@ struct CommandPaletteItem: Identifiable, Equatable { case toggleCanvas case toggleShelf case showDiff + case revealInFinder + case copyPath + case revealInSidebar #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -115,6 +118,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.toggleShelf case .showDiff: return AppShortcuts.CommandID.showDiff + case .revealInSidebar: + return AppShortcuts.CommandID.revealInSidebar case .ghosttyCommand, .markPullRequestReady, .mergePullRequest, @@ -127,7 +132,9 @@ struct CommandPaletteItem: Identifiable, Equatable { .worktreeSelect, .removeWorktree, .archiveWorktree, - .changeFocusedTabIcon: + .changeFocusedTabIcon, + .revealInFinder, + .copyPath: return nil #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 2ff75ced..a6685ab2 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -57,6 +57,9 @@ struct CommandPaletteFeature { case toggleCanvas case toggleShelf case showDiff + case revealInFinder + case copyPath + case revealInSidebar #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -222,6 +225,7 @@ struct CommandPaletteFeature { keywords: ["diff", "changes", "git"] ) ) + items.append(contentsOf: worktreeNavigationCommandItems()) } if let terminalWorktree = repositories.selectedTerminalWorktree { items.append( @@ -348,6 +352,32 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette return items } +private func worktreeNavigationCommandItems() -> [CommandPaletteItem] { + [ + .appShortcut( + id: CommandPaletteItemID.globalRevealInFinder, + title: "Reveal in Finder", + category: .navigation, + kind: .revealInFinder, + keywords: ["finder", "open", "show"] + ), + .appShortcut( + id: CommandPaletteItemID.globalCopyPath, + title: "Copy Path", + category: .navigation, + kind: .copyPath, + keywords: ["copy", "path", "clipboard"] + ), + .appShortcut( + id: CommandPaletteItemID.globalRevealInSidebar, + title: "Reveal in Sidebar", + category: .navigation, + kind: .revealInSidebar, + keywords: ["reveal", "locate", "find worktree"] + ), + ] +} + private func viewToggleCommandItems() -> [CommandPaletteItem] { [ .appShortcut( @@ -653,6 +683,9 @@ private enum CommandPaletteItemID { static let globalToggleCanvas = "global.toggle-canvas" static let globalToggleShelf = "global.toggle-shelf" static let globalShowDiff = "global.show-diff" + static let globalRevealInFinder = "global.reveal-in-finder" + static let globalCopyPath = "global.copy-path" + static let globalRevealInSidebar = "global.reveal-in-sidebar" static var globalIDs: [CommandPaletteItem.ID] { [ @@ -669,6 +702,9 @@ private enum CommandPaletteItemID { globalToggleCanvas, globalToggleShelf, globalShowDiff, + globalRevealInFinder, + globalCopyPath, + globalRevealInSidebar, ] } @@ -785,12 +821,18 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, - .showDiff: + .showDiff, + .revealInFinder, + .copyPath, + .revealInSidebar: fatalError("appDelegateAction should handle app-level command palette actions") } } private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + if let delegate = navigationDelegateAction(for: kind) { + return delegate + } switch kind { case .checkForUpdates: return .checkForUpdates @@ -823,6 +865,19 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale } } +private func navigationDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .revealInFinder: + return .revealInFinder + case .copyPath: + return .copyPath + case .revealInSidebar: + return .revealInSidebar + default: + return nil + } +} + private func pullRequestDelegateAction( for kind: CommandPaletteItem.Kind ) -> CommandPaletteFeature.Delegate? { @@ -861,7 +916,10 @@ private func pullRequestDelegateAction( .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, - .showDiff: + .showDiff, + .revealInFinder, + .copyPath, + .revealInSidebar: return nil #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 40981435..8b4f56ba 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -539,7 +539,8 @@ private struct CommandPaletteRowView: View { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .revealInFinder, .copyPath, .revealInSidebar: return nil case .removeWorktree: return "Remove" @@ -606,6 +607,12 @@ private struct CommandPaletteRowView: View { return "books.vertical" case .showDiff: return "plusminus.circle" + case .revealInFinder: + return "folder" + case .copyPath: + return "doc.on.clipboard" + case .revealInSidebar: + return "sidebar.left.badge.dot" #if DEBUG case .debugTestToast: return "ladybug" @@ -623,7 +630,8 @@ private struct CommandPaletteRowView: View { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .revealInFinder, .copyPath, .revealInSidebar: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false @@ -753,6 +761,12 @@ private struct CommandPaletteRowView: View { base = "Toggle Shelf" case .showDiff: base = "Show Diff" + case .revealInFinder: + base = "Reveal in Finder" + case .copyPath: + base = "Copy Path" + case .revealInSidebar: + base = "Reveal in Sidebar" #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: base = row.title diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 9fede7a6..53c5f0c3 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -1,7 +1,9 @@ +import AppKit import ComposableArchitecture import DependenciesTestSupport import Foundation import IdentifiedCollections +import SwiftUI import Testing @testable import supacode @@ -325,11 +327,170 @@ struct AppFeatureCommandPaletteTests { await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) await store.finish() - #expect( - sent.value == [ - .performBindingAction(worktree, action: "goto_split:right") - ] + // Two effects run in parallel (.merge) — assert both fire without + // depending on dispatch order. + #expect(sent.value.count == 2) + #expect(sent.value.contains(.performBindingAction(worktree, action: "goto_split:right"))) + #expect(sent.value.contains(.focusSelectedTab(worktree))) + } + + @Test(.dependencies) func viewToggleDelegateRestoresTerminalFocusByDefault() async { + let worktree = makeWorktree( + id: "/tmp/repo-view-toggle/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-view-toggle" + ) + let repository = makeRepository(id: "/tmp/repo-view-toggle", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.toggleLeftSidebar))) + await store.finish() + + #expect(sent.value.contains(.focusSelectedTab(worktree))) + } + + @Test(.dependencies) func toggleCanvasDelegateDoesNotRestoreTerminalFocus() async { + let worktree = makeWorktree( + id: "/tmp/repo-canvas-toggle/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-canvas-toggle" + ) + let repository = makeRepository(id: "/tmp/repo-canvas-toggle", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.toggleCanvas))) + await store.finish() + + #expect(!sent.value.contains(.focusSelectedTab(worktree))) + } + + @Test(.dependencies) func revealInFinderDispatchesOpenWorktreeFinder() async { + let worktree = makeWorktree( + id: "/tmp/repo-finder/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-finder" + ) + let repository = makeRepository(id: "/tmp/repo-finder", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.revealInFinder))) + await store.receive(\.openWorktree) + } + + @Test(.dependencies) func copyPathWritesWorktreePathToPasteboard() async { + let worktree = makeWorktree( + id: "/tmp/repo-copy/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-copy" ) + let repository = makeRepository(id: "/tmp/repo-copy", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("__sentinel__", forType: .string) + + await store.send(.commandPalette(.delegate(.copyPath))) + await store.finish() + + #expect(NSPasteboard.general.string(forType: .string) == worktree.workingDirectory.path) + } + + @Test(.dependencies) func copyPathWithoutSelectedWorktreeIsNoop() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.copyPath))) + await store.finish() + } + + @Test(.dependencies) func revealInSidebarShowsSidebarAndReveals() async { + let worktree = makeWorktree( + id: "/tmp/repo-reveal/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-reveal" + ) + let repository = makeRepository(id: "/tmp/repo-reveal", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + var appState = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + appState.leftSidebarVisibility = .detailOnly + let store = TestStore(initialState: appState) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.revealInSidebar))) + await store.receive(\.showLeftSidebar) { + $0.leftSidebarVisibility = .all + } + await store.receive(\.repositories.revealSelectedWorktreeInSidebar) + } + + @Test(.dependencies) func revealInSidebarWithoutSelectedWorktreeIsNoop() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.revealInSidebar))) + await store.finish() } @Test(.dependencies) func closePullRequestDispatchesAction() async { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 7569ba3d..49955d7c 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -50,6 +50,28 @@ struct CommandPaletteFeatureTests { #expect(!items.contains { $0.id == "global.show-diff" }) } + @Test func commandPaletteItems_includesWorktreeNavigationWhenWorktreeSelected() { + let rootPath = "/tmp/repo-nav" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let ids = Set(items.map(\.id)) + #expect(ids.contains("global.reveal-in-finder")) + #expect(ids.contains("global.copy-path")) + #expect(ids.contains("global.reveal-in-sidebar")) + } + + @Test func commandPaletteItems_omitsWorktreeNavigationWithoutSelectedWorktree() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + let ids = Set(items.map(\.id)) + #expect(!ids.contains("global.reveal-in-finder")) + #expect(!ids.contains("global.copy-path")) + #expect(!ids.contains("global.reveal-in-sidebar")) + } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) let item = items.first { $0.id == "global.jump-to-latest-unread" } @@ -1453,7 +1475,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon: return .worktree - case .jumpToLatestUnread, .worktreeSelect: + case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation case .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, @@ -1476,7 +1498,8 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .revealInFinder, .copyPath, .revealInSidebar: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost: -- 2.51.2 From 8a627a52d9504cd8021cb3efa50b5452931a5819 Mon Sep 17 00:00:00 2001 From: Norvon Date: Mon, 18 May 2026 14:13:46 +0800 Subject: [PATCH 141/528] Fix sidebar diff badge truncation --- supacode/Features/Repositories/Views/WorktreeRow.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 229c2467..e702fd56 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -240,6 +240,13 @@ private struct WorktreeRowPreview: View { id: "diff", name: "feature/sidebar-redesign", worktreeName: "sidebar-redesign", addedLines: 120, removedLines: 45 ) + row( + id: "long-diff", + name: "feature/long-running-sidebar-layout-stress-case", + worktreeName: "sidebar-layout-stress", + addedLines: 632, + removedLines: 344 + ) row(id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) row(id: "running", name: "feature/auth-flow", worktreeName: "auth-flow", taskStatus: .running) row(id: "loading", name: "creating-worktree...", worktreeName: "Setting up", isLoading: true) @@ -327,6 +334,7 @@ private struct WorktreeRowChangeCountView: View { .lineLimit(1) .padding(.horizontal, 4) .padding(.vertical, 0) + .fixedSize(horizontal: true, vertical: false) .overlay { RoundedRectangle(cornerRadius: 4, style: .continuous) .stroke(isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) -- 2.51.2 From 94b5b52dfee7787875aa3d50ed5d487476456bd9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 20:30:20 +0900 Subject: [PATCH 142/528] feat(palette): worktree actions + custom commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR6 of the command-palette buildout. Surfaces five worktree-scoped actions plus the repo's user-defined custom commands. Worktree actions (gated on selectedWorktreeID): - Run Script (⌘R) — appears when no script is running for the selected worktree; dispatches the existing AppFeature.runScript action. - Stop Script (⌘.) — replaces Run Script when one is in progress (consults the new runScriptStatusByWorktreeID builder parameter). - Pin / Unpin Worktree — single command whose title and icon flip based on the selected worktree's current pin state. Hidden for the main worktree. - Delete Worktree — non-default-suggestion; routes through the existing removeWorktree delegate so the existing alert flow is reused. Hidden for the main worktree. Custom commands: - The builder now accepts `customCommands: [UserCustomCommand]` and emits one palette item per command. Commands with an empty body are filtered out. Items use the user-defined title/icon, subtitle is the execution mode ("New Tab" / "In Place" / "New Split"). Not default-suggested — they only appear when the user types. - recencyRetentionIDs gained a `customCommands` parameter so per-command recency survives across sessions. - Item IDs are stable (UUID-based via CommandPaletteItemID.customCommand). Refactor: - Split appDelegateAction into navigationDelegateAction (PR5) + viewDelegateAction (new) + the remaining flat switch, to keep cyclomatic complexity under the SwiftLint threshold while adding runScript / stopRunScript. - ContentView passes selectedCustomCommands + runScriptStatusByWorktreeID into the builder. Focus restore behavior: all five new delegates fall through to the default-on restore. None added to commandPaletteDelegateChangesActiveSelection. Tests: +11 (palette builder coverage for Run/Stop visibility, Pin vs Unpin title flip, main-worktree gate, custom command filtering, plus delegate routing for all five new actions). Rename Branch (⌘⇧M) is intentionally deferred — it needs new view-side plumbing (FocusedSceneValue) to surface the popover that's currently driven by the title view's local @State. --- supacode/App/ContentView.swift | 2 + .../Features/App/Reducer/AppFeature.swift | 20 ++- .../CommandPalette/CommandPaletteItem.swift | 14 +- .../Reducer/CommandPaletteFeature.swift | 158 ++++++++++++++++-- .../Views/CommandPaletteOverlayView.swift | 28 +++- .../AppFeatureCommandPaletteTests.swift | 54 ++++++ .../CommandPaletteFeatureTests.swift | 135 ++++++++++++++- 7 files changed, 390 insertions(+), 21 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index ca6c34f4..7364bcba 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -87,6 +87,8 @@ struct ContentView: View { store: store.scope(state: \.commandPalette, action: \.commandPalette), items: CommandPaletteFeature.commandPaletteItems( from: store.repositories, + customCommands: store.selectedCustomCommands, + runScriptStatusByWorktreeID: store.runScriptStatusByWorktreeID, ghosttyCommands: ghosttyShortcuts.commandPaletteEntries ), resolvedKeybindings: store.resolvedKeybindings diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 29bd5fe4..ca7b1a12 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -376,7 +376,10 @@ struct AppFeature { case .repositories(.delegate(.repositoriesChanged(let repositories))): let archivedIDs = state.repositories.archivedWorktreeIDSet let ids = state.repositories.terminalStateIDs.subtracting(archivedIDs) - let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) + let recencyIDs = CommandPaletteFeature.recencyRetentionIDs( + from: repositories, + customCommands: state.selectedCustomCommands + ) let worktrees = state.repositories.worktreesForInfoWatcher() let shouldRestoreLayout = state.launchRestoreMode == .restoreLayout @@ -1094,6 +1097,21 @@ struct AppFeature { .send(.repositories(.revealSelectedWorktreeInSidebar)) ) + case .commandPalette(.delegate(.runScript)): + return .send(.runScript) + + case .commandPalette(.delegate(.stopRunScript)): + return .send(.stopRunScript) + + case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): + if isCurrentlyPinned { + return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) + } + return .send(.repositories(.worktreeOrdering(.pinWorktree(worktreeID)))) + + case .commandPalette(.delegate(.runCustomCommand(let index))): + return .send(.runCustomCommand(index)) + case .commandPalette(.delegate(.ghosttyCommand(let action))): guard let worktree = state.repositories.selectedTerminalWorktree else { return .none diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index ce243532..d71aa3bd 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -71,6 +71,11 @@ struct CommandPaletteItem: Identifiable, Equatable { case revealInFinder case copyPath case revealInSidebar + case runScript + case stopRunScript + case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) + case deleteWorktree(Worktree.ID, Repository.ID) + case runCustomCommand(index: Int, commandID: String, systemImage: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -120,6 +125,10 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.showDiff case .revealInSidebar: return AppShortcuts.CommandID.revealInSidebar + case .runScript: + return AppShortcuts.CommandID.runScript + case .stopRunScript: + return AppShortcuts.CommandID.stopScript case .ghosttyCommand, .markPullRequestReady, .mergePullRequest, @@ -134,7 +143,10 @@ struct CommandPaletteItem: Identifiable, Equatable { .archiveWorktree, .changeFocusedTabIcon, .revealInFinder, - .copyPath: + .copyPath, + .togglePinWorktree, + .deleteWorktree, + .runCustomCommand: return nil #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index a6685ab2..a8a78f76 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -60,6 +60,10 @@ struct CommandPaletteFeature { case revealInFinder case copyPath case revealInSidebar + case runScript + case stopRunScript + case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) + case runCustomCommand(Int) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -209,6 +213,8 @@ struct CommandPaletteFeature { static func commandPaletteItems( from repositories: RepositoriesFeature.State, + customCommands: [UserCustomCommand] = [], + runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:], ghosttyCommands: [GhosttyCommand] = [] ) -> [CommandPaletteItem] { let showsNewWorktreeAction = @@ -226,7 +232,14 @@ struct CommandPaletteFeature { ) ) items.append(contentsOf: worktreeNavigationCommandItems()) + items.append( + contentsOf: worktreeActionCommandItems( + repositories: repositories, + runScriptStatusByWorktreeID: runScriptStatusByWorktreeID + ) + ) } + items.append(contentsOf: customCommandItems(customCommands)) if let terminalWorktree = repositories.selectedTerminalWorktree { items.append( CommandPaletteItem( @@ -263,9 +276,11 @@ struct CommandPaletteFeature { } static func recencyRetentionIDs( - from repositories: IdentifiedArrayOf + from repositories: IdentifiedArrayOf, + customCommands: [UserCustomCommand] = [] ) -> [CommandPaletteItem.ID] { var ids = CommandPaletteItemID.globalIDs + ids.append(contentsOf: customCommands.map { CommandPaletteItemID.customCommand($0.id) }) for repository in repositories { ids.append(contentsOf: CommandPaletteItemID.pullRequestIDs(repositoryID: repository.id)) for worktree in repository.worktrees { @@ -352,6 +367,83 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette return items } +private func worktreeActionCommandItems( + repositories: RepositoriesFeature.State, + runScriptStatusByWorktreeID: [Worktree.ID: Bool] +) -> [CommandPaletteItem] { + guard let worktreeID = repositories.selectedWorktreeID else { return [] } + var items: [CommandPaletteItem] = [] + let isRunScriptRunning = runScriptStatusByWorktreeID[worktreeID] ?? false + if isRunScriptRunning { + items.append( + .appShortcut( + id: CommandPaletteItemID.globalStopRunScript, + title: "Stop Script", + category: .worktree, + kind: .stopRunScript, + keywords: ["stop", "kill", "cancel", "script"] + ) + ) + } else { + items.append( + .appShortcut( + id: CommandPaletteItemID.globalRunScript, + title: "Run Script", + category: .worktree, + kind: .runScript, + keywords: ["run", "script", "execute"] + ) + ) + } + if let row = repositories.selectedRow(for: worktreeID), !row.isMainWorktree { + let pinTitle = row.isPinned ? "Unpin Worktree" : "Pin Worktree" + let pinKeywords = + row.isPinned ? ["unpin", "favorite"] : ["pin", "favorite", "top"] + items.append( + .appShortcut( + id: CommandPaletteItemID.globalTogglePinWorktree, + title: pinTitle, + category: .worktree, + kind: .togglePinWorktree(worktreeID, isCurrentlyPinned: row.isPinned), + keywords: pinKeywords + ) + ) + if let repositoryID = repositories.repositoryID(containing: worktreeID) { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalDeleteWorktree, + title: "Delete Worktree", + subtitle: row.name, + kind: .deleteWorktree(worktreeID, repositoryID), + category: .worktree, + defaultSuggestion: false, + keywords: ["delete", "remove", "destroy"] + ) + ) + } + } + return items +} + +private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPaletteItem] { + commands.enumerated().compactMap { index, command in + guard command.hasRunnableCommand else { return nil } + return CommandPaletteItem( + id: CommandPaletteItemID.customCommand(command.id), + title: command.resolvedTitle, + subtitle: command.execution.title, + kind: .runCustomCommand( + index: index, + commandID: command.id, + systemImage: command.resolvedSystemImage + ), + category: .worktree, + defaultSuggestion: false, + keywords: ["custom", "command", "script"] + ) + } +} + private func worktreeNavigationCommandItems() -> [CommandPaletteItem] { [ .appShortcut( @@ -686,6 +778,14 @@ private enum CommandPaletteItemID { static let globalRevealInFinder = "global.reveal-in-finder" static let globalCopyPath = "global.copy-path" static let globalRevealInSidebar = "global.reveal-in-sidebar" + static let globalRunScript = "global.run-script" + static let globalStopRunScript = "global.stop-run-script" + static let globalTogglePinWorktree = "global.toggle-pin-worktree" + static let globalDeleteWorktree = "global.delete-worktree" + + static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { + "custom-command.\(commandID)" + } static var globalIDs: [CommandPaletteItem.ID] { [ @@ -705,6 +805,10 @@ private enum CommandPaletteItemID { globalRevealInFinder, globalCopyPath, globalRevealInSidebar, + globalRunScript, + globalStopRunScript, + globalTogglePinWorktree, + globalDeleteWorktree, ] } @@ -785,7 +889,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette switch kind { case .worktreeSelect(let id): return .selectWorktree(id) - case .removeWorktree(let worktreeID, let repositoryID): + case .removeWorktree(let worktreeID, let repositoryID), + .deleteWorktree(let worktreeID, let repositoryID): return .removeWorktree(worktreeID, repositoryID) case .archiveWorktree(let worktreeID, let repositoryID): return .archiveWorktree(worktreeID, repositoryID) @@ -793,6 +898,10 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette return .ghosttyCommand(action) case .changeFocusedTabIcon(let worktreeID): return .changeFocusedTabIcon(worktreeID) + case .togglePinWorktree(let worktreeID, let isCurrentlyPinned): + return .togglePinWorktree(worktreeID, isCurrentlyPinned: isCurrentlyPinned) + case .runCustomCommand(let index, _, _): + return .runCustomCommand(index) case .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, @@ -824,7 +933,9 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .showDiff, .revealInFinder, .copyPath, - .revealInSidebar: + .revealInSidebar, + .runScript, + .stopRunScript: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -833,6 +944,9 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale if let delegate = navigationDelegateAction(for: kind) { return delegate } + if let delegate = viewDelegateAction(for: kind) { + return delegate + } switch kind { case .checkForUpdates: return .checkForUpdates @@ -850,16 +964,10 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .jumpToLatestUnread case .installCLI: return .installCLI - case .toggleLeftSidebar: - return .toggleLeftSidebar - case .toggleActiveAgentsPanel: - return .toggleActiveAgentsPanel - case .toggleCanvas: - return .toggleCanvas - case .toggleShelf: - return .toggleShelf - case .showDiff: - return .showDiff + case .runScript: + return .runScript + case .stopRunScript: + return .stopRunScript default: return nil } @@ -878,6 +986,23 @@ private func navigationDelegateAction(for kind: CommandPaletteItem.Kind) -> Comm } } +private func viewDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .toggleLeftSidebar: + return .toggleLeftSidebar + case .toggleActiveAgentsPanel: + return .toggleActiveAgentsPanel + case .toggleCanvas: + return .toggleCanvas + case .toggleShelf: + return .toggleShelf + case .showDiff: + return .showDiff + default: + return nil + } +} + private func pullRequestDelegateAction( for kind: CommandPaletteItem.Kind ) -> CommandPaletteFeature.Delegate? { @@ -919,7 +1044,12 @@ private func pullRequestDelegateAction( .showDiff, .revealInFinder, .copyPath, - .revealInSidebar: + .revealInSidebar, + .runScript, + .stopRunScript, + .togglePinWorktree, + .deleteWorktree, + .runCustomCommand: return nil #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 8b4f56ba..5abc7f21 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -540,12 +540,15 @@ private struct CommandPaletteRowView: View { .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, - .revealInFinder, .copyPath, .revealInSidebar: + .revealInFinder, .copyPath, .revealInSidebar, + .runScript, .stopRunScript, .togglePinWorktree, .runCustomCommand: return nil case .removeWorktree: return "Remove" case .archiveWorktree: return "Archive" + case .deleteWorktree: + return "Delete" #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: return "Debug" @@ -613,6 +616,16 @@ private struct CommandPaletteRowView: View { return "doc.on.clipboard" case .revealInSidebar: return "sidebar.left.badge.dot" + case .runScript: + return "play.circle" + case .stopRunScript: + return "stop.circle" + case .togglePinWorktree(_, let isCurrentlyPinned): + return isCurrentlyPinned ? "pin.slash" : "pin" + case .deleteWorktree: + return "trash" + case .runCustomCommand(_, _, let systemImage): + return systemImage #if DEBUG case .debugTestToast: return "ladybug" @@ -631,7 +644,8 @@ private struct CommandPaletteRowView: View { .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, - .revealInFinder, .copyPath, .revealInSidebar: + .revealInFinder, .copyPath, .revealInSidebar, + .runScript, .stopRunScript, .togglePinWorktree, .deleteWorktree, .runCustomCommand: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false @@ -767,6 +781,16 @@ private struct CommandPaletteRowView: View { base = "Copy Path" case .revealInSidebar: base = "Reveal in Sidebar" + case .runScript: + base = "Run Script" + case .stopRunScript: + base = "Stop Script" + case .togglePinWorktree(_, let isCurrentlyPinned): + base = isCurrentlyPinned ? "Unpin Worktree" : "Pin Worktree" + case .deleteWorktree: + base = "Delete \(row.title)" + case .runCustomCommand: + base = "Run Custom Command: \(row.title)" #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: base = row.title diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 53c5f0c3..31df65d0 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -493,6 +493,60 @@ struct AppFeatureCommandPaletteTests { await store.finish() } + @Test(.dependencies) func runScriptDelegateDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.runScript))) + await store.receive(\.runScript) + } + + @Test(.dependencies) func stopRunScriptDelegateDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.stopRunScript))) + await store.receive(\.stopRunScript) + } + + @Test(.dependencies) func togglePinWorktreeWhenNotPinnedDispatchesPin() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send( + .commandPalette(.delegate(.togglePinWorktree("/tmp/repo/wt-1", isCurrentlyPinned: false))) + ) + await store.receive(\.repositories.worktreeOrdering.pinWorktree) + } + + @Test(.dependencies) func togglePinWorktreeWhenPinnedDispatchesUnpin() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send( + .commandPalette(.delegate(.togglePinWorktree("/tmp/repo/wt-1", isCurrentlyPinned: true))) + ) + await store.receive(\.repositories.worktreeOrdering.unpinWorktree) + } + + @Test(.dependencies) func runCustomCommandDelegateDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.runCustomCommand(3)))) + await store.receive(\.runCustomCommand) + } + @Test(.dependencies) func closePullRequestDispatchesAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 49955d7c..f09f23f6 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -72,6 +72,131 @@ struct CommandPaletteFeatureTests { #expect(!ids.contains("global.reveal-in-sidebar")) } + @Test func commandPaletteItems_includesRunScriptWhenIdle() { + let rootPath = "/tmp/repo-run" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let ids = Set(items.map(\.id)) + #expect(ids.contains("global.run-script")) + #expect(!ids.contains("global.stop-run-script")) + } + + @Test func commandPaletteItems_includesStopScriptWhenRunning() { + let rootPath = "/tmp/repo-run" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + runScriptStatusByWorktreeID: [worktree.id: true] + ) + let ids = Set(items.map(\.id)) + #expect(ids.contains("global.stop-run-script")) + #expect(!ids.contains("global.run-script")) + } + + @Test func commandPaletteItems_includesPinForNonPinnedWorktree() { + let rootPath = "/tmp/repo-pin" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let pinItem = items.first { $0.id == "global.toggle-pin-worktree" } + #expect(pinItem?.title == "Pin Worktree") + #expect(pinItem?.kind == .togglePinWorktree(worktree.id, isCurrentlyPinned: false)) + } + + @Test func commandPaletteItems_includesUnpinForPinnedWorktree() { + let rootPath = "/tmp/repo-pin" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + state.pinnedWorktreeIDs = [worktree.id] + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let pinItem = items.first { $0.id == "global.toggle-pin-worktree" } + #expect(pinItem?.title == "Unpin Worktree") + #expect(pinItem?.kind == .togglePinWorktree(worktree.id, isCurrentlyPinned: true)) + } + + @Test func commandPaletteItems_omitsPinAndDeleteForMainWorktree() { + let rootPath = "/tmp/repo-main" + // Main worktree: workingDirectory == repositoryRootURL → Worktree.isMain == true + let main = makeWorktree( + id: rootPath, + name: "main", + repoRoot: rootPath, + workingDirectory: rootPath + ) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [main]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(main.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let ids = Set(items.map(\.id)) + #expect(!ids.contains("global.toggle-pin-worktree")) + #expect(!ids.contains("global.delete-worktree")) + } + + @Test func commandPaletteItems_includesDeleteWorktreeForNonMain() { + let rootPath = "/tmp/repo-delete" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let deleteItem = items.first { $0.id == "global.delete-worktree" } + #expect(deleteItem?.title == "Delete Worktree") + #expect(deleteItem?.defaultSuggestion == false) + } + + @Test func commandPaletteItems_includesCustomCommands() { + let buildCmd = UserCustomCommand( + id: "cmd-build", + title: "Build", + systemImage: "hammer", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + let emptyCmd = UserCustomCommand( + id: "cmd-empty", + title: "Empty", + systemImage: "terminal", + command: " ", + execution: .shellScript, + shortcut: nil + ) + + let items = CommandPaletteFeature.commandPaletteItems( + from: RepositoriesFeature.State(), + customCommands: [buildCmd, emptyCmd] + ) + let ids = Set(items.map(\.id)) + #expect(ids.contains("custom-command.cmd-build")) + // Commands with empty body are filtered out. + #expect(!ids.contains("custom-command.cmd-empty")) + + let buildItem = items.first { $0.id == "custom-command.cmd-build" } + #expect(buildItem?.title == "Build") + #expect(buildItem?.subtitle == "New Tab") + #expect(buildItem?.defaultSuggestion == false) + #expect( + buildItem?.kind + == .runCustomCommand(index: 0, commandID: "cmd-build", systemImage: "hammer") + ) + } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) let item = items.first { $0.id == "global.jump-to-latest-unread" } @@ -1473,7 +1598,9 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .checkForUpdates, .openSettings, .openRepository, .installCLI: return .app case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, - .removeWorktree, .archiveWorktree, .changeFocusedTabIcon: + .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, + .runScript, .stopRunScript, .togglePinWorktree, + .deleteWorktree, .runCustomCommand: return .worktree case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation @@ -1499,10 +1626,12 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, - .revealInFinder, .copyPath, .revealInSidebar: + .revealInFinder, .copyPath, .revealInSidebar, + .runScript, .stopRunScript, .togglePinWorktree: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, - .ghosttyCommand, .openRepositoryOnCodeHost: + .ghosttyCommand, .openRepositoryOnCodeHost, + .deleteWorktree, .runCustomCommand: return false #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: -- 2.51.2 From 9d46815b82748b6048aec2bd0a2c887c27da254f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 20:51:50 +0900 Subject: [PATCH 143/528] fix(palette): more descriptive subtitle for custom command items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the subtitle was just the execution mode title ("New Tab" / "In Place" / "New Split"), which read as unrelated metadata. New format makes the scope and behavior explicit: "Custom command in this repo · Opens in a new tab" "Custom command in this repo · Runs in the focused terminal" "Custom command in this repo · Opens in a new split (right)" Split direction is included so multiple split commands stay distinguishable. --- .../Reducer/CommandPaletteFeature.swift | 17 ++++++- .../CommandPaletteFeatureTests.swift | 49 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index a8a78f76..06d600f4 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -431,7 +431,7 @@ private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPale return CommandPaletteItem( id: CommandPaletteItemID.customCommand(command.id), title: command.resolvedTitle, - subtitle: command.execution.title, + subtitle: customCommandSubtitle(for: command), kind: .runCustomCommand( index: index, commandID: command.id, @@ -444,6 +444,21 @@ private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPale } } +private func customCommandSubtitle(for command: UserCustomCommand) -> String { + "Custom command in this repo · \(customCommandExecutionDescription(for: command))" +} + +private func customCommandExecutionDescription(for command: UserCustomCommand) -> String { + switch command.execution { + case .shellScript: + return "Opens in a new tab" + case .terminalInput: + return "Runs in the focused terminal" + case .split: + return "Opens in a new split (\(command.splitDirection.title.lowercased()))" + } +} + private func worktreeNavigationCommandItems() -> [CommandPaletteItem] { [ .appShortcut( diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index f09f23f6..90f5e595 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -189,7 +189,7 @@ struct CommandPaletteFeatureTests { let buildItem = items.first { $0.id == "custom-command.cmd-build" } #expect(buildItem?.title == "Build") - #expect(buildItem?.subtitle == "New Tab") + #expect(buildItem?.subtitle == "Custom command in this repo · Opens in a new tab") #expect(buildItem?.defaultSuggestion == false) #expect( buildItem?.kind @@ -197,6 +197,53 @@ struct CommandPaletteFeatureTests { ) } + @Test func commandPaletteItems_customCommandSubtitleVariants() { + let shellCmd = UserCustomCommand( + id: "cmd-shell", + title: "Shell", + systemImage: "terminal", + command: "make", + execution: .shellScript, + shortcut: nil + ) + let inlineCmd = UserCustomCommand( + id: "cmd-inline", + title: "Inline", + systemImage: "terminal", + command: "ls", + execution: .terminalInput, + shortcut: nil + ) + let splitCmd = UserCustomCommand( + id: "cmd-split", + title: "Split", + systemImage: "terminal", + command: "watch", + execution: .split, + splitDirection: .down, + closeOnSuccess: false, + shortcut: nil + ) + + let items = CommandPaletteFeature.commandPaletteItems( + from: RepositoriesFeature.State(), + customCommands: [shellCmd, inlineCmd, splitCmd] + ) + + #expect( + items.first { $0.id == "custom-command.cmd-shell" }?.subtitle + == "Custom command in this repo · Opens in a new tab" + ) + #expect( + items.first { $0.id == "custom-command.cmd-inline" }?.subtitle + == "Custom command in this repo · Runs in the focused terminal" + ) + #expect( + items.first { $0.id == "custom-command.cmd-split" }?.subtitle + == "Custom command in this repo · Opens in a new split (down)" + ) + } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) let item = items.first { $0.id == "global.jump-to-latest-unread" } -- 2.51.2 From 9eafddb6b2e30524a949e4b3dd0614af64bb5e40 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 21:31:24 +0900 Subject: [PATCH 144/528] =?UTF-8?q?feat(palette):=20add=20Rename=20Branch?= =?UTF-8?q?=20(=E2=8C=98=E2=87=A7M)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename UI is a popover whose visibility lives in WorktreeDetailTitleView's local @State, so palette delegates can't just dispatch an existing reducer action. Instead this commit adds a small TCA-managed "request" channel between the palette and the title view, mirroring the existing pendingSidebarReveal pattern. State / reducer: - New PendingRenameBranchRequest struct (id + worktreeID). - RepositoriesFeature.State gains nextPendingRenameBranchRequestID + pendingRenameBranchRequest fields. - New RepositoriesFeature.Action cases: - requestRenameBranchPrompt(Worktree.ID): increments the id and publishes a fresh PendingRenameBranchRequest if the worktree exists. - consumePendingRenameBranchRequest(Int): clears the request if the id still matches. View: - WorktreeDetailTitleView takes externalRenamePrompt + a consume callback. A .task(id:) on the request id opens its existing popover and consumes the request, so the view code stays the single owner of isPresented. - WorktreeDetailView wires the request down through WorktreeToolbarContent, gating on the request's worktreeID matching the currently-selected worktree. Palette wiring: - New Kind/Delegate case .renameBranch (no associated values). - appShortcutCommandID maps to AppShortcuts.CommandID.renameBranch so the ⌘⇧M hint shows up in the palette row. - Builder appends "Rename Branch" alongside Pin/Delete in worktreeActionCommandItems, gated on the selected worktree being non-main (same gate as Pin). - AppFeature delegate handler dispatches .repositories(.requestRenameBranchPrompt(selectedWorktreeID)). Focus restore: - .renameBranch added to commandPaletteDelegateChangesActiveSelection so the post-delegate restore doesn't steal focus from the popover's TextField. Refactor: - Extracted worktreeToolbarContent helper from detailBody to keep the function under the 100-line lint threshold after adding the two new callbacks. Tests: +3 (Rename Branch appears on non-main worktree; delegate dispatches requestRenameBranchPrompt with the right pending request shape; delegate noops without a selected worktree). --- .../Features/App/Reducer/AppFeature.swift | 6 +- .../CommandPalette/CommandPaletteItem.swift | 3 + .../Reducer/CommandPaletteFeature.swift | 18 ++++- .../Views/CommandPaletteOverlayView.swift | 9 ++- .../Reducer/RepositoriesFeature.swift | 23 ++++++ .../Views/WorktreeDetailTitleView.swift | 15 +++- .../Views/WorktreeDetailView.swift | 80 +++++++++++++------ .../AppFeatureCommandPaletteTests.swift | 39 +++++++++ .../CommandPaletteFeatureTests.swift | 19 ++++- 9 files changed, 179 insertions(+), 33 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index ca7b1a12..a648e5e5 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -143,7 +143,7 @@ struct AppFeature { ) -> Bool { switch delegate { case .selectWorktree, .jumpToLatestUnread, .viewArchivedWorktrees, - .newWorktree, .toggleCanvas: + .newWorktree, .toggleCanvas, .renameBranch: return true default: return false @@ -1103,6 +1103,10 @@ struct AppFeature { case .commandPalette(.delegate(.stopRunScript)): return .send(.stopRunScript) + case .commandPalette(.delegate(.renameBranch)): + guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } + return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) + case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): if isCurrentlyPinned { return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index d71aa3bd..c4bc35c1 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -75,6 +75,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case deleteWorktree(Worktree.ID, Repository.ID) + case renameBranch case runCustomCommand(index: Int, commandID: String, systemImage: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -129,6 +130,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.runScript case .stopRunScript: return AppShortcuts.CommandID.stopScript + case .renameBranch: + return AppShortcuts.CommandID.renameBranch case .ghosttyCommand, .markPullRequestReady, .mergePullRequest, diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 06d600f4..54f2ae43 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -63,6 +63,7 @@ struct CommandPaletteFeature { case runScript case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) + case renameBranch case runCustomCommand(Int) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -408,6 +409,15 @@ private func worktreeActionCommandItems( keywords: pinKeywords ) ) + items.append( + .appShortcut( + id: CommandPaletteItemID.globalRenameBranch, + title: "Rename Branch", + category: .worktree, + kind: .renameBranch, + keywords: ["rename", "branch", "name"] + ) + ) if let repositoryID = repositories.repositoryID(containing: worktreeID) { items.append( CommandPaletteItem( @@ -796,6 +806,7 @@ private enum CommandPaletteItemID { static let globalRunScript = "global.run-script" static let globalStopRunScript = "global.stop-run-script" static let globalTogglePinWorktree = "global.toggle-pin-worktree" + static let globalRenameBranch = "global.rename-branch" static let globalDeleteWorktree = "global.delete-worktree" static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { @@ -823,6 +834,7 @@ private enum CommandPaletteItemID { globalRunScript, globalStopRunScript, globalTogglePinWorktree, + globalRenameBranch, globalDeleteWorktree, ] } @@ -950,7 +962,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .copyPath, .revealInSidebar, .runScript, - .stopRunScript: + .stopRunScript, + .renameBranch: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -983,6 +996,8 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .runScript case .stopRunScript: return .stopRunScript + case .renameBranch: + return .renameBranch default: return nil } @@ -1063,6 +1078,7 @@ private func pullRequestDelegateAction( .runScript, .stopRunScript, .togglePinWorktree, + .renameBranch, .deleteWorktree, .runCustomCommand: return nil diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 5abc7f21..91055631 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -541,7 +541,7 @@ private struct CommandPaletteRowView: View { .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .runCustomCommand: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .runCustomCommand: return nil case .removeWorktree: return "Remove" @@ -622,6 +622,8 @@ private struct CommandPaletteRowView: View { return "stop.circle" case .togglePinWorktree(_, let isCurrentlyPinned): return isCurrentlyPinned ? "pin.slash" : "pin" + case .renameBranch: + return "pencil" case .deleteWorktree: return "trash" case .runCustomCommand(_, _, let systemImage): @@ -645,7 +647,8 @@ private struct CommandPaletteRowView: View { .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .deleteWorktree, .runCustomCommand: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, + .deleteWorktree, .runCustomCommand: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: return false @@ -787,6 +790,8 @@ private struct CommandPaletteRowView: View { base = "Stop Script" case .togglePinWorktree(_, let isCurrentlyPinned): base = isCurrentlyPinned ? "Unpin Worktree" : "Pin Worktree" + case .renameBranch: + base = "Rename Branch" case .deleteWorktree: base = "Delete \(row.title)" case .runCustomCommand: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 90d298bd..ab3290d5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -50,6 +50,11 @@ struct PendingSidebarReveal: Equatable, Sendable { let worktreeID: Worktree.ID } +struct PendingRenameBranchRequest: Equatable, Sendable { + let id: Int + let worktreeID: Worktree.ID +} + @Reducer struct RepositoriesFeature { enum CancelID { @@ -238,6 +243,8 @@ struct RepositoriesFeature { var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? + var nextPendingRenameBranchRequestID = 0 + var pendingRenameBranchRequest: PendingRenameBranchRequest? var isSidebarDragActive = false var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] var activeAgents = ActiveAgentsFeature.State() @@ -313,6 +320,8 @@ struct RepositoriesFeature { case worktreeHistoryForward case revealSelectedWorktreeInSidebar case consumePendingSidebarReveal(Int) + case requestRenameBranchPrompt(Worktree.ID) + case consumePendingRenameBranchRequest(Int) case requestRenameBranch(Worktree.ID, String) case presentAlert(title: String, message: String) case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) @@ -945,6 +954,20 @@ struct RepositoriesFeature { state.pendingSidebarReveal = nil return .none + case .requestRenameBranchPrompt(let worktreeID): + guard state.worktree(for: worktreeID) != nil else { return .none } + state.nextPendingRenameBranchRequestID += 1 + state.pendingRenameBranchRequest = .init( + id: state.nextPendingRenameBranchRequestID, + worktreeID: worktreeID + ) + return .none + + case .consumePendingRenameBranchRequest(let requestID): + guard state.pendingRenameBranchRequest?.id == requestID else { return .none } + state.pendingRenameBranchRequest = nil + return .none + case .requestRenameBranch(let worktreeID, let branchName): guard let worktree = state.worktree(for: worktreeID) else { return .none } let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 5386db3d..0d36143f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -3,6 +3,8 @@ import SwiftUI struct WorktreeDetailTitleView: View { let title: DetailToolbarTitle let onSubmit: ((String) -> Void)? + let externalRenamePrompt: PendingRenameBranchRequest? + let onConsumeExternalRenamePrompt: (Int) -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var isPresented = false @@ -13,8 +15,7 @@ struct WorktreeDetailTitleView: View { Group { if title.supportsRename { Button { - draftName = title.text - isPresented = true + openRenamePopover() } label: { labelContent } @@ -48,6 +49,16 @@ struct WorktreeDetailTitleView: View { } ) } + .task(id: externalRenamePrompt?.id) { + guard let prompt = externalRenamePrompt, title.supportsRename else { return } + openRenamePopover() + onConsumeExternalRenamePrompt(prompt.id) + } + } + + private func openRenamePopover() { + draftName = title.text + isPresented = true } private var labelContent: some View { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 2b48669c..661ffa14 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -89,31 +89,12 @@ struct WorktreeDetailView: View { ) ) { - WorktreeToolbarContent( + worktreeToolbarContent( toolbarState: toolbarState, - onRenameBranch: { newBranch in - guard let selectedWorktree else { return } - store.send(.repositories(.requestRenameBranch(selectedWorktree.id, newBranch))) - }, - onOpenWorktree: { action in - store.send(.openWorktree(action)) - }, - onOpenActionSelectionChanged: { action in - store.send(.openActionSelectionChanged(action)) - }, - onCopyPath: { - guard let selectedTerminalWorktree else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(selectedTerminalWorktree.workingDirectory.path, forType: .string) - }, - onSelectNotification: selectToolbarNotification, - onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, - onRunScript: { store.send(.runScript) }, - onStopRunScript: { store.send(.stopRunScript) }, - onRunCustomCommand: { index in - store.send(.runCustomCommand(index)) - }, - onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + repositories: repositories, + selectedWorktree: selectedWorktree, + selectedTerminalWorktree: selectedTerminalWorktree, + notificationGroups: notificationGroups ) } } @@ -126,6 +107,49 @@ struct WorktreeDetailView: View { return applyFocusedActions(content: content, actions: actions) } + @ToolbarContentBuilder + private func worktreeToolbarContent( + toolbarState: WorktreeToolbarState, + repositories: RepositoriesFeature.State, + selectedWorktree: Worktree?, + selectedTerminalWorktree: Worktree?, + notificationGroups: [ToolbarNotificationRepositoryGroup] + ) -> some ToolbarContent { + WorktreeToolbarContent( + toolbarState: toolbarState, + onRenameBranch: { newBranch in + guard let selectedWorktree else { return } + store.send(.repositories(.requestRenameBranch(selectedWorktree.id, newBranch))) + }, + externalRenamePrompt: repositories.pendingRenameBranchRequest + .flatMap { request in + request.worktreeID == selectedWorktree?.id ? request : nil + }, + onConsumeExternalRenamePrompt: { requestID in + store.send(.repositories(.consumePendingRenameBranchRequest(requestID))) + }, + onOpenWorktree: { action in + store.send(.openWorktree(action)) + }, + onOpenActionSelectionChanged: { action in + store.send(.openActionSelectionChanged(action)) + }, + onCopyPath: { + guard let selectedTerminalWorktree else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selectedTerminalWorktree.workingDirectory.path, forType: .string) + }, + onSelectNotification: selectToolbarNotification, + onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, + onRunScript: { store.send(.runScript) }, + onStopRunScript: { store.send(.stopRunScript) }, + onRunCustomCommand: { index in + store.send(.runCustomCommand(index)) + }, + onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + ) + } + @ToolbarContentBuilder private func canvasToolbarContent( notificationGroups: [ToolbarNotificationRepositoryGroup], @@ -477,6 +501,8 @@ struct WorktreeDetailView: View { fileprivate struct WorktreeToolbarContent: ToolbarContent { let toolbarState: WorktreeToolbarState let onRenameBranch: (String) -> Void + let externalRenamePrompt: PendingRenameBranchRequest? + let onConsumeExternalRenamePrompt: (Int) -> Void let onOpenWorktree: (OpenWorktreeAction) -> Void let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void let onCopyPath: () -> Void @@ -492,7 +518,9 @@ struct WorktreeDetailView: View { ToolbarItem { WorktreeDetailTitleView( title: toolbarState.title, - onSubmit: toolbarState.title.supportsRename ? onRenameBranch : nil + onSubmit: toolbarState.title.supportsRename ? onRenameBranch : nil, + externalRenamePrompt: externalRenamePrompt, + onConsumeExternalRenamePrompt: onConsumeExternalRenamePrompt ) } @@ -975,6 +1003,8 @@ private struct WorktreeToolbarPreview: View { WorktreeDetailView.WorktreeToolbarContent( toolbarState: toolbarState, onRenameBranch: { _ in }, + externalRenamePrompt: nil, + onConsumeExternalRenamePrompt: { _ in }, onOpenWorktree: { _ in }, onOpenActionSelectionChanged: { _ in }, onCopyPath: {}, diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 31df65d0..ecd456ac 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -537,6 +537,45 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.repositories.worktreeOrdering.unpinWorktree) } + @Test(.dependencies) func renameBranchDelegateDispatchesRequestPrompt() async { + let worktree = makeWorktree( + id: "/tmp/repo-rename/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-rename" + ) + let repository = makeRepository(id: "/tmp/repo-rename", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.renameBranch))) + await store.receive(\.repositories.requestRenameBranchPrompt) { + $0.repositories.nextPendingRenameBranchRequestID = 1 + $0.repositories.pendingRenameBranchRequest = PendingRenameBranchRequest( + id: 1, + worktreeID: worktree.id + ) + } + } + + @Test(.dependencies) func renameBranchDelegateNoopsWithoutSelectedWorktree() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.renameBranch))) + await store.finish() + } + @Test(.dependencies) func runCustomCommandDelegateDispatchesAppAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 90f5e595..20f70fae 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -128,6 +128,20 @@ struct CommandPaletteFeatureTests { #expect(pinItem?.kind == .togglePinWorktree(worktree.id, isCurrentlyPinned: true)) } + @Test func commandPaletteItems_includesRenameBranchForNonMainWorktree() { + let rootPath = "/tmp/repo-rename" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let renameItem = items.first { $0.id == "global.rename-branch" } + #expect(renameItem?.title == "Rename Branch") + #expect(renameItem?.kind == .renameBranch) + #expect(renameItem?.appShortcutCommandID == AppShortcuts.CommandID.renameBranch) + } + @Test func commandPaletteItems_omitsPinAndDeleteForMainWorktree() { let rootPath = "/tmp/repo-main" // Main worktree: workingDirectory == repositoryRootURL → Worktree.isMain == true @@ -145,6 +159,7 @@ struct CommandPaletteFeatureTests { let ids = Set(items.map(\.id)) #expect(!ids.contains("global.toggle-pin-worktree")) #expect(!ids.contains("global.delete-worktree")) + #expect(!ids.contains("global.rename-branch")) } @Test func commandPaletteItems_includesDeleteWorktreeForNonMain() { @@ -1647,7 +1662,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .runScript, .stopRunScript, .togglePinWorktree, - .deleteWorktree, .runCustomCommand: + .renameBranch, .deleteWorktree, .runCustomCommand: return .worktree case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation @@ -1674,7 +1689,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost, -- 2.51.2 From a167be10f9d0bef16d1cf239d5a7abf4dcdb9071 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 21:54:56 +0900 Subject: [PATCH 145/528] fix(palette): show Rename Branch on the main worktree too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR6.5 incorrectly bundled Rename Branch into the `!isMainWorktree` gate alongside Pin and Delete. Renaming the branch on a main worktree is perfectly valid (`git branch -m` works there too), and DetailToolbarTitle.supportsRename returns true for any branch-kind selection — including the main worktree — so ⌘⇧M via the title bar already worked there. Hoist Rename Branch above the non-main guard. Pin/Unpin/Delete stay gated as before. --- .../Reducer/CommandPaletteFeature.swift | 62 ++++++++++--------- .../CommandPaletteFeatureTests.swift | 4 +- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 54f2ae43..716d07ec 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -396,41 +396,43 @@ private func worktreeActionCommandItems( ) ) } - if let row = repositories.selectedRow(for: worktreeID), !row.isMainWorktree { - let pinTitle = row.isPinned ? "Unpin Worktree" : "Pin Worktree" - let pinKeywords = - row.isPinned ? ["unpin", "favorite"] : ["pin", "favorite", "top"] - items.append( - .appShortcut( - id: CommandPaletteItemID.globalTogglePinWorktree, - title: pinTitle, - category: .worktree, - kind: .togglePinWorktree(worktreeID, isCurrentlyPinned: row.isPinned), - keywords: pinKeywords - ) + guard let row = repositories.selectedRow(for: worktreeID) else { return items } + // Rename Branch works on any worktree (main included). + items.append( + .appShortcut( + id: CommandPaletteItemID.globalRenameBranch, + title: "Rename Branch", + category: .worktree, + kind: .renameBranch, + keywords: ["rename", "branch", "name"] ) + ) + // Pin / Unpin / Delete only apply to non-main worktrees. + guard !row.isMainWorktree else { return items } + let pinTitle = row.isPinned ? "Unpin Worktree" : "Pin Worktree" + let pinKeywords = + row.isPinned ? ["unpin", "favorite"] : ["pin", "favorite", "top"] + items.append( + .appShortcut( + id: CommandPaletteItemID.globalTogglePinWorktree, + title: pinTitle, + category: .worktree, + kind: .togglePinWorktree(worktreeID, isCurrentlyPinned: row.isPinned), + keywords: pinKeywords + ) + ) + if let repositoryID = repositories.repositoryID(containing: worktreeID) { items.append( - .appShortcut( - id: CommandPaletteItemID.globalRenameBranch, - title: "Rename Branch", + CommandPaletteItem( + id: CommandPaletteItemID.globalDeleteWorktree, + title: "Delete Worktree", + subtitle: row.name, + kind: .deleteWorktree(worktreeID, repositoryID), category: .worktree, - kind: .renameBranch, - keywords: ["rename", "branch", "name"] + defaultSuggestion: false, + keywords: ["delete", "remove", "destroy"] ) ) - if let repositoryID = repositories.repositoryID(containing: worktreeID) { - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.globalDeleteWorktree, - title: "Delete Worktree", - subtitle: row.name, - kind: .deleteWorktree(worktreeID, repositoryID), - category: .worktree, - defaultSuggestion: false, - keywords: ["delete", "remove", "destroy"] - ) - ) - } } return items } diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 20f70fae..7952ccb8 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -159,7 +159,9 @@ struct CommandPaletteFeatureTests { let ids = Set(items.map(\.id)) #expect(!ids.contains("global.toggle-pin-worktree")) #expect(!ids.contains("global.delete-worktree")) - #expect(!ids.contains("global.rename-branch")) + // Rename Branch is still available on the main worktree — `git branch -m` + // works on the main branch the same as any other. + #expect(ids.contains("global.rename-branch")) } @Test func commandPaletteItems_includesDeleteWorktreeForNonMain() { -- 2.51.2 From d02bfe8489881c557cd5c70ba0f41834a0981f30 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 23:08:08 +0900 Subject: [PATCH 146/528] feat(palette): add New Tab + Repo Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR7 of the command-palette buildout, scoped down to two commands after the audit: most of the original Terminal/Tab/Pane / Find / Font Size items are already covered (Ghostty-bridged or unwanted), and PR8's Shelf navigation was dropped. New Tab: - Wraps AppFeature.newTerminal, which Prowl's existing menu confusingly labels "New Terminal" — semantically it creates a new tab inside the selected worktree's terminal group (1 worktree → N tabs → 1+ surfaces). The palette uses the more honest "New Tab" name. - Gated on selectedTerminalWorktree != nil, so it appears alongside the ghostty bridge commands inside the existing terminal-worktree block. - No hotkey hint: the binding (⌘T) is read from Ghostty config rather than AppShortcuts, and the palette doesn't currently bridge that display path. Users still see ⌘T in the File menu. Repo Settings: - New per-repo command that opens the Settings window and navigates to SettingsSection.repository(repoID). - Resolution: prefer an explicitly selected repository; otherwise fall back to the repo that owns the currently-selected worktree (via the new private activeRepository(in:) helper). - Subtitle shows the repo name so multi-repo users can tell which one the command targets. - Per-repo stable ID (CommandPaletteItemID.openRepositorySettings(_:)) so recency carries across sessions; added to recencyRetentionIDs. Focus restore: both new delegates fall through to the default-on restore — New Tab's new surface takes focus naturally, Repo Settings spawns a window that handles its own focus. Tests: +7 (New Tab present/absent gating, Repo Settings appearing for selected worktree vs selected repository vs nothing, delegate routing for both commands). --- .../Features/App/Reducer/AppFeature.swift | 11 ++++ .../CommandPalette/CommandPaletteItem.swift | 4 ++ .../Reducer/CommandPaletteFeature.swift | 58 ++++++++++++++++++- .../Views/CommandPaletteOverlayView.swift | 12 +++- .../AppFeatureCommandPaletteTests.swift | 27 +++++++++ .../CommandPaletteFeatureTests.swift | 58 ++++++++++++++++++- 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a648e5e5..3b005cb2 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1107,6 +1107,17 @@ struct AppFeature { guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) + case .commandPalette(.delegate(.newTab)): + return .send(.newTerminal) + + case .commandPalette(.delegate(.openRepositorySettings(let repositoryID))): + return .merge( + .send(.settings(.setSelection(.repository(repositoryID)))), + .run { _ in + await settingsWindowClient.show() + } + ) + case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): if isCurrentlyPinned { return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index c4bc35c1..e4a89ce1 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -76,6 +76,8 @@ struct CommandPaletteItem: Identifiable, Equatable { case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case deleteWorktree(Worktree.ID, Repository.ID) case renameBranch + case newTab + case openRepositorySettings(Repository.ID) case runCustomCommand(index: Int, commandID: String, systemImage: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -149,6 +151,8 @@ struct CommandPaletteItem: Identifiable, Equatable { .copyPath, .togglePinWorktree, .deleteWorktree, + .newTab, + .openRepositorySettings, .runCustomCommand: return nil #if DEBUG diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 716d07ec..a7c0d47b 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -64,6 +64,8 @@ struct CommandPaletteFeature { case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case renameBranch + case newTab + case openRepositorySettings(Repository.ID) case runCustomCommand(Int) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) @@ -242,6 +244,15 @@ struct CommandPaletteFeature { } items.append(contentsOf: customCommandItems(customCommands)) if let terminalWorktree = repositories.selectedTerminalWorktree { + items.append( + .appShortcut( + id: CommandPaletteItemID.globalNewTab, + title: "New Tab", + category: .worktree, + kind: .newTab, + keywords: ["new", "tab", "terminal"] + ) + ) items.append( CommandPaletteItem( id: CommandPaletteItemID.changeFocusedTabIcon(terminalWorktree.id), @@ -254,6 +265,19 @@ struct CommandPaletteFeature { ) items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) } + if let repository = activeRepository(in: repositories) { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.openRepositorySettings(repository.id), + title: "Repo Settings", + subtitle: repository.name, + kind: .openRepositorySettings(repository.id), + category: .app, + defaultSuggestion: true, + keywords: ["repo", "settings", "configure", "preferences"] + ) + ) + } items.append(contentsOf: selectedCodeHostItems(from: repositories)) #if DEBUG items.append(contentsOf: debugToastItems()) @@ -284,6 +308,7 @@ struct CommandPaletteFeature { ids.append(contentsOf: customCommands.map { CommandPaletteItemID.customCommand($0.id) }) for repository in repositories { ids.append(contentsOf: CommandPaletteItemID.pullRequestIDs(repositoryID: repository.id)) + ids.append(CommandPaletteItemID.openRepositorySettings(repository.id)) for worktree in repository.worktrees { ids.append(CommandPaletteItemID.worktreeSelect(worktree.id)) ids.append(CommandPaletteItemID.changeFocusedTabIcon(worktree.id)) @@ -437,6 +462,24 @@ private func worktreeActionCommandItems( return items } +/// Resolves the "active" repository for repo-scoped palette commands: +/// prefers an explicitly selected repo, then falls back to the repo that +/// owns the currently-selected worktree. Returns nil when nothing relevant +/// is selected (e.g., archived view, empty palette). +private func activeRepository( + in repositories: RepositoriesFeature.State +) -> Repository? { + if let repository = repositories.selectedRepository { + return repository + } + guard let worktreeID = repositories.selectedWorktreeID, + let repositoryID = repositories.repositoryID(containing: worktreeID) + else { + return nil + } + return repositories.repositories[id: repositoryID] +} + private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPaletteItem] { commands.enumerated().compactMap { index, command in guard command.hasRunnableCommand else { return nil } @@ -810,6 +853,11 @@ private enum CommandPaletteItemID { static let globalTogglePinWorktree = "global.toggle-pin-worktree" static let globalRenameBranch = "global.rename-branch" static let globalDeleteWorktree = "global.delete-worktree" + static let globalNewTab = "global.new-tab" + + static func openRepositorySettings(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "repo.\(repositoryID).open-settings" + } static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { "custom-command.\(commandID)" @@ -838,6 +886,7 @@ private enum CommandPaletteItemID { globalTogglePinWorktree, globalRenameBranch, globalDeleteWorktree, + globalNewTab, ] } @@ -929,6 +978,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette return .changeFocusedTabIcon(worktreeID) case .togglePinWorktree(let worktreeID, let isCurrentlyPinned): return .togglePinWorktree(worktreeID, isCurrentlyPinned: isCurrentlyPinned) + case .openRepositorySettings(let repositoryID): + return .openRepositorySettings(repositoryID) case .runCustomCommand(let index, _, _): return .runCustomCommand(index) case .openPullRequest, @@ -965,7 +1016,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .revealInSidebar, .runScript, .stopRunScript, - .renameBranch: + .renameBranch, + .newTab: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -1000,6 +1052,8 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .stopRunScript case .renameBranch: return .renameBranch + case .newTab: + return .newTab default: return nil } @@ -1082,6 +1136,8 @@ private func pullRequestDelegateAction( .togglePinWorktree, .renameBranch, .deleteWorktree, + .newTab, + .openRepositorySettings, .runCustomCommand: return nil #if DEBUG diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 91055631..7cd32d3a 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -541,7 +541,8 @@ private struct CommandPaletteRowView: View { .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .runCustomCommand: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, + .newTab, .openRepositorySettings, .runCustomCommand: return nil case .removeWorktree: return "Remove" @@ -624,6 +625,10 @@ private struct CommandPaletteRowView: View { return isCurrentlyPinned ? "pin.slash" : "pin" case .renameBranch: return "pencil" + case .newTab: + return "plus.rectangle.on.rectangle" + case .openRepositorySettings: + return "gearshape" case .deleteWorktree: return "trash" case .runCustomCommand(_, _, let systemImage): @@ -648,6 +653,7 @@ private struct CommandPaletteRowView: View { .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, + .newTab, .openRepositorySettings, .deleteWorktree, .runCustomCommand: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: @@ -792,6 +798,10 @@ private struct CommandPaletteRowView: View { base = isCurrentlyPinned ? "Unpin Worktree" : "Pin Worktree" case .renameBranch: base = "Rename Branch" + case .newTab: + base = "New Tab" + case .openRepositorySettings: + base = "Open Repo Settings" case .deleteWorktree: base = "Delete \(row.title)" case .runCustomCommand: diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index ecd456ac..b99730e0 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -576,6 +576,33 @@ struct AppFeatureCommandPaletteTests { await store.finish() } + @Test(.dependencies) func newTabDelegateDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.newTab))) + await store.receive(\.newTerminal) + } + + @Test(.dependencies) func openRepositorySettingsDelegateNavigatesAndShowsWindow() async { + let shown = LockIsolated(false) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.settingsWindowClient.show = { shown.withValue { $0 = true } } + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.openRepositorySettings("/tmp/repo-x")))) + await store.receive(\.settings.setSelection) { + $0.settings.selection = .repository("/tmp/repo-x") + } + await store.finish() + #expect(shown.value) + } + @Test(.dependencies) func runCustomCommandDelegateDispatchesAppAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 7952ccb8..037853e7 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -214,6 +214,56 @@ struct CommandPaletteFeatureTests { ) } + @Test func commandPaletteItems_includesNewTabWhenWorktreeSelected() { + let rootPath = "/tmp/repo-newtab" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let item = items.first { $0.id == "global.new-tab" } + #expect(item?.title == "New Tab") + #expect(item?.kind == .newTab) + #expect(item?.defaultSuggestion == true) + } + + @Test func commandPaletteItems_omitsNewTabWithoutSelectedWorktree() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + #expect(!items.contains { $0.id == "global.new-tab" }) + } + + @Test func commandPaletteItems_includesRepoSettingsForSelectedWorktree() { + let rootPath = "/tmp/repo-settings" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .worktree(worktree.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + let item = items.first { $0.id == "repo.\(repository.id).open-settings" } + #expect(item?.title == "Repo Settings") + #expect(item?.subtitle == "Repo") + #expect(item?.kind == .openRepositorySettings(repository.id)) + #expect(item?.category == .app) + } + + @Test func commandPaletteItems_includesRepoSettingsForSelectedRepository() { + let rootPath = "/tmp/repo-settings-direct" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "RepoDirect", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .repository(repository.id) + + let items = CommandPaletteFeature.commandPaletteItems(from: state) + #expect(items.contains { $0.id == "repo.\(repository.id).open-settings" }) + } + + @Test func commandPaletteItems_omitsRepoSettingsWithoutSelection() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + #expect(!items.contains { $0.id.hasPrefix("repo.") }) + } + @Test func commandPaletteItems_customCommandSubtitleVariants() { let shellCmd = UserCustomCommand( id: "cmd-shell", @@ -1659,12 +1709,13 @@ private func makeItem( private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteItem.Category { switch kind { - case .checkForUpdates, .openSettings, .openRepository, .installCLI: + case .checkForUpdates, .openSettings, .openRepository, .installCLI, + .openRepositorySettings: return .app case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .runScript, .stopRunScript, .togglePinWorktree, - .renameBranch, .deleteWorktree, .runCustomCommand: + .renameBranch, .deleteWorktree, .newTab, .runCustomCommand: return .worktree case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation @@ -1691,7 +1742,8 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, - .runScript, .stopRunScript, .togglePinWorktree, .renameBranch: + .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, + .newTab, .openRepositorySettings: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost, -- 2.51.2 From b7786fac9cc1103305a6577dc5f48f64a2b2659f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 23:16:08 +0900 Subject: [PATCH 147/528] revert: drop Prowl-native New Tab; keep Ghostty bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial commit added a Prowl-native New Tab palette item that duplicated Ghostty's auto-bridged `new_tab` entry (filter was masking the Ghostty version to hide the dupe). Both paths converge functionally: Ghostty's `new_tab` action routes through GhosttySurfaceBridge.onNewTab → WorktreeTerminalState.createTab, the same backend Prowl-native AppFeature.newTerminal uses. The only differences are an analytics ping and a setup-script flag that's no-op once a worktree already has a focused surface (always true when the palette can open). Removing the native item to stay consistent with every other Prowl terminal lifecycle command in the palette (Close Tab / Close Surface / font size / find), which are all auto-bridged from Ghostty. Less code, less maintenance, evolves with Ghostty upstream automatically. Restores Ghostty's "New Tab — Open a new tab." entry by dropping `new_tab` from filteredGhosttyActionKeys. Repo Settings is unaffected — it has no Ghostty equivalent. --- .../Features/App/Reducer/AppFeature.swift | 3 --- .../CommandPalette/CommandPaletteItem.swift | 2 -- .../Reducer/CommandPaletteFeature.swift | 18 +-------------- .../Views/CommandPaletteOverlayView.swift | 8 ++----- .../AppFeatureCommandPaletteTests.swift | 10 -------- .../CommandPaletteFeatureTests.swift | 23 ++----------------- 6 files changed, 5 insertions(+), 59 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 3b005cb2..13822831 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1107,9 +1107,6 @@ struct AppFeature { guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) - case .commandPalette(.delegate(.newTab)): - return .send(.newTerminal) - case .commandPalette(.delegate(.openRepositorySettings(let repositoryID))): return .merge( .send(.settings(.setSelection(.repository(repositoryID)))), diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index e4a89ce1..fd5c04e6 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -76,7 +76,6 @@ struct CommandPaletteItem: Identifiable, Equatable { case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case deleteWorktree(Worktree.ID, Repository.ID) case renameBranch - case newTab case openRepositorySettings(Repository.ID) case runCustomCommand(index: Int, commandID: String, systemImage: String) #if DEBUG @@ -151,7 +150,6 @@ struct CommandPaletteItem: Identifiable, Equatable { .copyPath, .togglePinWorktree, .deleteWorktree, - .newTab, .openRepositorySettings, .runCustomCommand: return nil diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index a7c0d47b..1be4dd62 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -64,7 +64,6 @@ struct CommandPaletteFeature { case stopRunScript case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case renameBranch - case newTab case openRepositorySettings(Repository.ID) case runCustomCommand(Int) #if DEBUG @@ -244,15 +243,6 @@ struct CommandPaletteFeature { } items.append(contentsOf: customCommandItems(customCommands)) if let terminalWorktree = repositories.selectedTerminalWorktree { - items.append( - .appShortcut( - id: CommandPaletteItemID.globalNewTab, - title: "New Tab", - category: .worktree, - kind: .newTab, - keywords: ["new", "tab", "terminal"] - ) - ) items.append( CommandPaletteItem( id: CommandPaletteItemID.changeFocusedTabIcon(terminalWorktree.id), @@ -853,7 +843,6 @@ private enum CommandPaletteItemID { static let globalTogglePinWorktree = "global.toggle-pin-worktree" static let globalRenameBranch = "global.rename-branch" static let globalDeleteWorktree = "global.delete-worktree" - static let globalNewTab = "global.new-tab" static func openRepositorySettings(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { "repo.\(repositoryID).open-settings" @@ -886,7 +875,6 @@ private enum CommandPaletteItemID { globalTogglePinWorktree, globalRenameBranch, globalDeleteWorktree, - globalNewTab, ] } @@ -1016,8 +1004,7 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .revealInSidebar, .runScript, .stopRunScript, - .renameBranch, - .newTab: + .renameBranch: fatalError("appDelegateAction should handle app-level command palette actions") } } @@ -1052,8 +1039,6 @@ private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPale return .stopRunScript case .renameBranch: return .renameBranch - case .newTab: - return .newTab default: return nil } @@ -1136,7 +1121,6 @@ private func pullRequestDelegateAction( .togglePinWorktree, .renameBranch, .deleteWorktree, - .newTab, .openRepositorySettings, .runCustomCommand: return nil diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 7cd32d3a..5a337097 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -542,7 +542,7 @@ private struct CommandPaletteRowView: View { .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, - .newTab, .openRepositorySettings, .runCustomCommand: + .openRepositorySettings, .runCustomCommand: return nil case .removeWorktree: return "Remove" @@ -625,8 +625,6 @@ private struct CommandPaletteRowView: View { return isCurrentlyPinned ? "pin.slash" : "pin" case .renameBranch: return "pencil" - case .newTab: - return "plus.rectangle.on.rectangle" case .openRepositorySettings: return "gearshape" case .deleteWorktree: @@ -653,7 +651,7 @@ private struct CommandPaletteRowView: View { .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, - .newTab, .openRepositorySettings, + .openRepositorySettings, .deleteWorktree, .runCustomCommand: return true case .worktreeSelect, .removeWorktree, .archiveWorktree: @@ -798,8 +796,6 @@ private struct CommandPaletteRowView: View { base = isCurrentlyPinned ? "Unpin Worktree" : "Pin Worktree" case .renameBranch: base = "Rename Branch" - case .newTab: - base = "New Tab" case .openRepositorySettings: base = "Open Repo Settings" case .deleteWorktree: diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index b99730e0..5ffeec16 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -576,16 +576,6 @@ struct AppFeatureCommandPaletteTests { await store.finish() } - @Test(.dependencies) func newTabDelegateDispatchesAppAction() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } - store.exhaustivity = .off - - await store.send(.commandPalette(.delegate(.newTab))) - await store.receive(\.newTerminal) - } - @Test(.dependencies) func openRepositorySettingsDelegateNavigatesAndShowsWindow() async { let shown = LockIsolated(false) let store = TestStore(initialState: AppFeature.State()) { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 037853e7..215b18da 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -214,25 +214,6 @@ struct CommandPaletteFeatureTests { ) } - @Test func commandPaletteItems_includesNewTabWhenWorktreeSelected() { - let rootPath = "/tmp/repo-newtab" - let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) - let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) - var state = RepositoriesFeature.State(repositories: [repository]) - state.selection = .worktree(worktree.id) - - let items = CommandPaletteFeature.commandPaletteItems(from: state) - let item = items.first { $0.id == "global.new-tab" } - #expect(item?.title == "New Tab") - #expect(item?.kind == .newTab) - #expect(item?.defaultSuggestion == true) - } - - @Test func commandPaletteItems_omitsNewTabWithoutSelectedWorktree() { - let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) - #expect(!items.contains { $0.id == "global.new-tab" }) - } - @Test func commandPaletteItems_includesRepoSettingsForSelectedWorktree() { let rootPath = "/tmp/repo-settings" let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) @@ -1715,7 +1696,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .runScript, .stopRunScript, .togglePinWorktree, - .renameBranch, .deleteWorktree, .newTab, .runCustomCommand: + .renameBranch, .deleteWorktree, .runCustomCommand: return .worktree case .jumpToLatestUnread, .worktreeSelect, .revealInFinder, .copyPath, .revealInSidebar: return .navigation @@ -1743,7 +1724,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, - .newTab, .openRepositorySettings: + .openRepositorySettings: return true case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost, -- 2.51.2 From 11df3d7d71362f08c83c4b0867186acdb7a26691 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 23:33:05 +0900 Subject: [PATCH 148/528] Fix settings window shortcuts and repo reveal --- .../BusinessLogic/SettingsWindowManager.swift | 22 ++++++ .../Settings/Views/SettingsView.swift | 76 ++++++++++++------- .../WindowCloseShortcutPolicyTests.swift | 30 ++++++++ 3 files changed, 102 insertions(+), 26 deletions(-) diff --git a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift index 2096e55a..7a9c7483 100644 --- a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift +++ b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift @@ -13,6 +13,7 @@ final class SettingsWindowManager { @ObservationIgnored private var store: StoreOf? @ObservationIgnored private var ghosttyShortcuts: GhosttyShortcutManager? @ObservationIgnored private var commandKeyObserver: CommandKeyObserver? + @ObservationIgnored private var localEventMonitor: Any? @ObservationIgnored private var willCloseObserver: NSObjectProtocol? private init() {} @@ -76,6 +77,27 @@ final class SettingsWindowManager { window.makeKeyAndOrderFront(nil) settingsWindow = window + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak window] event in + guard let window, event.window === window else { return event } + if SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: event.modifierFlags, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) { + window.performClose(nil) + return nil + } + return event + } isOpen = true } } + +enum SettingsWindowKeyboardShortcutPolicy { + static func isCloseWindowShortcut( + modifierFlags: NSEvent.ModifierFlags, + charactersIgnoringModifiers: String? + ) -> Bool { + modifierFlags.intersection(.deviceIndependentFlagsMask) == .command + && charactersIgnoringModifiers == "w" + } +} diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 8b891f67..4e75eb9d 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -29,36 +29,53 @@ struct SettingsView: View { NavigationSplitView(columnVisibility: .constant(.all)) { VStack(spacing: 0) { - List(selection: $settingsStore.selection.sending(\.setSelection)) { - Label("General", systemImage: "gearshape") - .tag(SettingsSection.general) - Label("Notifications", systemImage: "bell") - .tag(SettingsSection.notifications) - Label("Shortcuts", systemImage: "keyboard") - .tag(SettingsSection.shortcuts) - Label("Worktree", systemImage: "archivebox") - .tag(SettingsSection.worktree) - Label("Updates", systemImage: "arrow.down.circle") - .tag(SettingsSection.updates) - Label("Advanced", systemImage: "gearshape.2") - .tag(SettingsSection.advanced) - Label("GitHub", systemImage: "arrow.triangle.branch") - .tag(SettingsSection.github) + ScrollViewReader { scrollProxy in + List(selection: $settingsStore.selection.sending(\.setSelection)) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.general) + .id(SettingsSection.general) + Label("Notifications", systemImage: "bell") + .tag(SettingsSection.notifications) + .id(SettingsSection.notifications) + Label("Shortcuts", systemImage: "keyboard") + .tag(SettingsSection.shortcuts) + .id(SettingsSection.shortcuts) + Label("Worktree", systemImage: "archivebox") + .tag(SettingsSection.worktree) + .id(SettingsSection.worktree) + Label("Updates", systemImage: "arrow.down.circle") + .tag(SettingsSection.updates) + .id(SettingsSection.updates) + Label("Advanced", systemImage: "gearshape.2") + .tag(SettingsSection.advanced) + .id(SettingsSection.advanced) + Label("GitHub", systemImage: "arrow.triangle.branch") + .tag(SettingsSection.github) + .id(SettingsSection.github) - Section("Repositories") { - ForEach(repositories) { repository in - RepoDisplayName( - fallbackName: repository.name, - customTitle: customTitles[repository.id] - ) - .tag(SettingsSection.repository(repository.id)) + Section("Repositories") { + ForEach(repositories) { repository in + let repositorySection = SettingsSection.repository(repository.id) + RepoDisplayName( + fallbackName: repository.name, + customTitle: customTitles[repository.id] + ) + .tag(repositorySection) + .id(repositorySection) + } } } + .listStyle(.sidebar) + .frame(minWidth: 220, maxHeight: .infinity) + .navigationSplitViewColumnWidth(220) + .removingSidebarToggle() + .onAppear { + scrollToRepositorySelection(settingsStore.selection, proxy: scrollProxy) + } + .onChange(of: settingsStore.selection) { _, selection in + scrollToRepositorySelection(selection, proxy: scrollProxy) + } } - .listStyle(.sidebar) - .frame(minWidth: 220, maxHeight: .infinity) - .navigationSplitViewColumnWidth(220) - .removingSidebarToggle() } } detail: { switch selection { @@ -135,4 +152,11 @@ struct SettingsView: View { } .ignoresSafeArea(.container, edges: .top) } + + private func scrollToRepositorySelection(_ selection: SettingsSection?, proxy: ScrollViewProxy) { + guard case .repository(let repositoryID) = selection else { return } + withAnimation { + proxy.scrollTo(SettingsSection.repository(repositoryID), anchor: .center) + } + } } diff --git a/supacodeTests/WindowCloseShortcutPolicyTests.swift b/supacodeTests/WindowCloseShortcutPolicyTests.swift index 2604dbf1..9a289f71 100644 --- a/supacodeTests/WindowCloseShortcutPolicyTests.swift +++ b/supacodeTests/WindowCloseShortcutPolicyTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import Testing @@ -86,3 +87,32 @@ struct WindowCloseShortcutPolicyTests { #expect(shortcut?.modifiers == .command) } } + +struct SettingsWindowShortcutPolicyTests { + @Test func commandWClosesSettingsWindow() { + #expect( + SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: .command, + charactersIgnoringModifiers: "w" + ) + ) + } + + @Test func modifiedCommandWDoesNotCloseSettingsWindow() { + #expect( + !SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: [.command, .shift], + charactersIgnoringModifiers: "w" + ) + ) + } + + @Test func commandOtherKeyDoesNotCloseSettingsWindow() { + #expect( + !SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: .command, + charactersIgnoringModifiers: "q" + ) + ) + } +} -- 2.51.2 From 98eba82d4b15e36c31e44298f29b81dce3230a4a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 18 May 2026 23:49:38 +0900 Subject: [PATCH 149/528] chore(palette): post-buildout cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three loose ends from the command-palette buildout, swept together. 1. Simplify Repo Settings palette handler AppFeature had two near-identical handlers landing on the settings window — the existing repo right-click path (.repositories(.delegate(.openRepositorySettings(_)))) and PR7's palette path (.commandPalette(.delegate(.openRepositorySettings(_)))). Funnel the palette path through the existing repositories pipeline so the repo-existence guard and settingsWindowClient.show() call live in one place. 2. Delete the dead copyPath ⌘⇧C shortcut (closes #295) The binding was never wired to any Button + KeyboardShortcutModifier, so pressing ⌘⇧C did nothing. The two real Copy Path entry points (right-click context menu in WorktreeRowsView, "Open in..." dropdown in WorktreeDetailView) carry no shortcut, and the palette item already hides the (dead) hint per Option A from the original triage. Removes: - AppShortcuts.CommandID.copyPath - AppShortcuts.copyPath - The configurableAppAction entry binding the two together - copyPath in AppShortcuts.all 3. Remove dead .removeWorktree / .archiveWorktree Kind cases Both Kinds were declared in CommandPaletteItem.Kind but never constructed anywhere — leftovers from an earlier design that anticipated right-click-on-row palette items. PR6 introduced .deleteWorktree as the actual constructed Kind for the "Delete Worktree" palette command (routed through the .removeWorktree Delegate for historical reasons). This commit: - Deletes both unused Kinds. - Renames CommandPaletteFeature.Delegate.removeWorktree → .deleteWorktree so the delegate name matches the user-facing command. - Deletes CommandPaletteFeature.Delegate.archiveWorktree (no sender after the Kind goes). - Cascades through AppFeature handlers, overlay view branches (badge/leadingIcon/emphasis/accessibility title), and the pullRequestDelegateAction exhaustiveness list. Tests: renames removeWorktreeDispatchesRequest → deleteWorktreeDispatchesRequest, deletes archiveWorktreeDispatchesRequest and the now-tautological commandPaletteItems_omitsSubActionsFor* assertions, and reuses .deleteWorktree + .changeFocusedTabIcon as non-suggested filler in showsGlobalItemsWhenQueryEmpty. Net: -117 lines across 8 files. make check / build-app / test (1124 passed) clean. --- supacode/App/AppShortcuts.swift | 9 --- .../Features/App/Reducer/AppFeature.swift | 15 ++-- .../CommandPalette/CommandPaletteItem.swift | 4 - .../Reducer/CommandPaletteFeature.swift | 12 +-- .../Views/CommandPaletteOverlayView.swift | 14 +--- .../AppFeatureCommandPaletteTests.swift | 55 ++++--------- supacodeTests/AppShortcutsTests.swift | 2 +- .../CommandPaletteFeatureTests.swift | 80 +++---------------- 8 files changed, 37 insertions(+), 154 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index f654be1c..0280cd5e 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -89,7 +89,6 @@ enum AppShortcuts { static let quitApplication = "quit_application" static let openSettings = "open_settings" static let openWorktree = "open_worktree" - static let copyPath = "copy_path" static let openRepository = "open_repository" static let openPullRequest = "open_pull_request" static let toggleLeftSidebar = "toggle_left_sidebar" @@ -179,7 +178,6 @@ enum AppShortcuts { static let quitApplication = AppShortcut(key: "q", modifiers: .command) static let openSettings = AppShortcut(key: ",", modifiers: .command) static let openFinder = AppShortcut(key: "o", modifiers: .command) - static let copyPath = AppShortcut(key: "c", modifiers: [.command, .shift]) static let openRepository = AppShortcut(key: "o", modifiers: [.command, .shift]) static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) static let toggleLeftSidebar = AppShortcut(key: "s", modifiers: [.command, .control]) @@ -377,12 +375,6 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: openFinder ), - .init( - id: CommandID.copyPath, - title: "Copy Path", - scope: .configurableAppAction, - shortcut: copyPath - ), .init( id: CommandID.openRepository, title: "Open Repository", @@ -868,7 +860,6 @@ enum AppShortcuts { newWorktree, openSettings, openFinder, - copyPath, openRepository, openPullRequest, toggleLeftSidebar, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 13822831..e62217d6 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1028,12 +1028,9 @@ struct AppFeature { case .commandPalette(.delegate(.openRepository)): return .send(.repositories(.setOpenPanelPresented(true))) - case .commandPalette(.delegate(.removeWorktree(let worktreeID, let repositoryID))): + case .commandPalette(.delegate(.deleteWorktree(let worktreeID, let repositoryID))): return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) - case .commandPalette(.delegate(.archiveWorktree(let worktreeID, let repositoryID))): - return .send(.repositories(.worktreeLifecycle(.requestArchiveWorktree(worktreeID, repositoryID)))) - case .commandPalette(.delegate(.viewArchivedWorktrees)): return .send(.repositories(.selectArchivedWorktrees)) @@ -1108,12 +1105,10 @@ struct AppFeature { return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) case .commandPalette(.delegate(.openRepositorySettings(let repositoryID))): - return .merge( - .send(.settings(.setSelection(.repository(repositoryID)))), - .run { _ in - await settingsWindowClient.show() - } - ) + // Reuse the existing repo-side flow so the repo-existence guard and + // settingsWindowClient.show() live in one place + // (.repositories(.delegate(.openRepositorySettings(_))) at line ~441). + return .send(.repositories(.repositoryManagement(.openRepositorySettings(repositoryID)))) case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): if isCurrentlyPinned { diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index fd5c04e6..8c397394 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -46,8 +46,6 @@ struct CommandPaletteItem: Identifiable, Equatable { case worktreeSelect(Worktree.ID) case openSettings case newWorktree - case removeWorktree(Worktree.ID, Repository.ID) - case archiveWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees case jumpToLatestUnread @@ -143,8 +141,6 @@ struct CommandPaletteItem: Identifiable, Equatable { .openFailingCheckDetails, .installCLI, .worktreeSelect, - .removeWorktree, - .archiveWorktree, .changeFocusedTabIcon, .revealInFinder, .copyPath, diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 1be4dd62..6993ba2e 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -36,8 +36,7 @@ struct CommandPaletteFeature { case openSettings case newWorktree case openRepository - case removeWorktree(Worktree.ID, Repository.ID) - case archiveWorktree(Worktree.ID, Repository.ID) + case deleteWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees case jumpToLatestUnread @@ -955,11 +954,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette switch kind { case .worktreeSelect(let id): return .selectWorktree(id) - case .removeWorktree(let worktreeID, let repositoryID), - .deleteWorktree(let worktreeID, let repositoryID): - return .removeWorktree(worktreeID, repositoryID) - case .archiveWorktree(let worktreeID, let repositoryID): - return .archiveWorktree(worktreeID, repositoryID) + case .deleteWorktree(let worktreeID, let repositoryID): + return .deleteWorktree(worktreeID, repositoryID) case .ghosttyCommand(let action): return .ghosttyCommand(action) case .changeFocusedTabIcon(let worktreeID): @@ -1100,8 +1096,6 @@ private func pullRequestDelegateAction( .openSettings, .newWorktree, .openRepository, - .removeWorktree, - .archiveWorktree, .viewArchivedWorktrees, .refreshWorktrees, .jumpToLatestUnread, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 5a337097..0837dc43 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -544,10 +544,6 @@ private struct CommandPaletteRowView: View { .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings, .runCustomCommand: return nil - case .removeWorktree: - return "Remove" - case .archiveWorktree: - return "Archive" case .deleteWorktree: return "Delete" #if DEBUG @@ -597,10 +593,6 @@ private struct CommandPaletteRowView: View { return nil case .changeFocusedTabIcon: return "rectangle.on.rectangle" - case .removeWorktree: - return "trash" - case .archiveWorktree: - return "archivebox" case .toggleLeftSidebar: return "sidebar.left" case .toggleActiveAgentsPanel: @@ -654,7 +646,7 @@ private struct CommandPaletteRowView: View { .openRepositorySettings, .deleteWorktree, .runCustomCommand: return true - case .worktreeSelect, .removeWorktree, .archiveWorktree: + case .worktreeSelect: return false #if DEBUG case .debugTestToast, .debugSimulateUpdateFound: @@ -748,10 +740,6 @@ private struct CommandPaletteRowView: View { base = "Jump to Latest Unread" case .ghosttyCommand: base = row.title - case .removeWorktree: - base = "Remove \(row.title)" - case .archiveWorktree: - base = "Archive \(row.title)" case .openPullRequest, .openRepositoryOnCodeHost: base = row.title case .markPullRequestReady: diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 5ffeec16..deccdd61 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -577,8 +577,19 @@ struct AppFeatureCommandPaletteTests { } @Test(.dependencies) func openRepositorySettingsDelegateNavigatesAndShowsWindow() async { + // Palette handler funnels through the existing + // repositories.repositoryManagement.openRepositorySettings flow, which + // guards on the repo actually existing. + let repository = makeRepository(id: "/tmp/repo-x", worktrees: []) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] let shown = LockIsolated(false) - let store = TestStore(initialState: AppFeature.State()) { + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { AppFeature() } withDependencies: { $0.settingsWindowClient.show = { shown.withValue { $0 = true } } @@ -613,7 +624,7 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.repositories.githubIntegration.pullRequestAction) } - @Test(.dependencies) func removeWorktreeDispatchesRequest() async { + @Test(.dependencies) func deleteWorktreeDispatchesRequest() async { let worktree = makeWorktree( id: "/tmp/repo-run/wt-1", name: "wt-1", @@ -644,50 +655,12 @@ struct AppFeatureCommandPaletteTests { TextState("Delete \(worktree.name)? This deletes the worktree directory and its local branch.") } - await store.send(.commandPalette(.delegate(.removeWorktree(worktree.id, repository.id)))) + await store.send(.commandPalette(.delegate(.deleteWorktree(worktree.id, repository.id)))) await store.receive(\.repositories.worktreeLifecycle.requestDeleteWorktree) { $0.repositories.alert = expectedAlert } } - @Test(.dependencies) func archiveWorktreeDispatchesRequest() async { - let worktree = makeWorktree( - id: "/tmp/repo-archive/wt-1", - name: "wt-1", - repoRoot: "/tmp/repo-archive" - ) - let repository = makeRepository(id: "/tmp/repo-archive", worktrees: [worktree]) - var repositoriesState = RepositoriesFeature.State() - repositoriesState.repositories = [repository] - let store = TestStore( - initialState: AppFeature.State( - repositories: repositoriesState, - settings: SettingsFeature.State() - ) - ) { - AppFeature() - } - - let archivedDisplay = AppShortcuts.archivedWorktrees.display - let expectedAlert = AlertState { - TextState("Archive worktree?") - } actions: { - ButtonState(role: .destructive, action: .confirmArchiveWorktree(worktree.id, repository.id)) { - TextState("Archive (⌘↩)") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState("Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") - } - - await store.send(.commandPalette(.delegate(.archiveWorktree(worktree.id, repository.id)))) - await store.receive(\.repositories.worktreeLifecycle.requestArchiveWorktree) { - $0.repositories.alert = expectedAlert - } - } - } private func makeWorktree(id: String, name: String, repoRoot: String = "/tmp/repo") -> Worktree { diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 867ed71d..7155905f 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -10,7 +10,7 @@ struct AppShortcutsTests { let shortcuts: [AppShortcut] = [ AppShortcuts.openSettings, AppShortcuts.newWorktree, - AppShortcuts.copyPath, + AppShortcuts.openRepository, ] for shortcut in shortcuts { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 215b18da..86978a49 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -785,22 +785,6 @@ struct CommandPaletteFeatureTests { from: RepositoriesFeature.State(repositories: [repository]) ) - #expect( - items.contains { - if case .removeWorktree = $0.kind { - return true - } - return false - } == false - ) - #expect( - items.contains { - if case .archiveWorktree = $0.kind { - return true - } - return false - } == false - ) #expect( items.filter { if case .worktreeSelect = $0.kind { @@ -811,44 +795,6 @@ struct CommandPaletteFeatureTests { ) } - @Test func commandPaletteItems_omitsSubActionsForNonMainWorktree() { - let rootPath = "/tmp/repo" - let main = makeWorktree( - id: rootPath, - name: "repo", - detail: "main", - repoRoot: rootPath, - workingDirectory: rootPath - ) - let feature = makeWorktree( - id: "\(rootPath)/wt-feature", - name: "feature", - detail: "feature", - repoRoot: rootPath - ) - let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [main, feature]) - let items = CommandPaletteFeature.commandPaletteItems( - from: RepositoriesFeature.State(repositories: [repository]) - ) - - #expect( - items.contains { - if case .removeWorktree = $0.kind { - return true - } - return false - } == false - ) - #expect( - items.contains { - if case .archiveWorktree = $0.kind { - return true - } - return false - } == false - ) - } - @Test func commandPaletteItems_trimsDetailToNil() { let rootPath = "/tmp/repo" let worktree = makeWorktree( @@ -984,21 +930,21 @@ struct CommandPaletteFeatureTests { subtitle: "main", kind: .worktreeSelect("wt-fox") ) - let archiveFox = makeItem( - id: "worktree.fox.archive", - title: "Repo / fox", - subtitle: "Archive Worktree - main", - kind: .archiveWorktree("wt-fox", "repo-fox") + let deleteFox = makeItem( + id: "worktree.fox.delete", + title: "Delete Worktree", + subtitle: "fox", + kind: .deleteWorktree("wt-fox", "repo-fox") ) - let removeFox = makeItem( - id: "worktree.fox.remove", - title: "Repo / fox", - subtitle: "Remove Worktree - main", - kind: .removeWorktree("wt-fox", "repo-fox") + let changeFoxIcon = makeItem( + id: "worktree.fox.change-icon", + title: "Change Tab Icon...", + subtitle: "fox", + kind: .changeFocusedTabIcon("wt-fox") ) let result = CommandPaletteFeature.filterItems( - items: [openSettings, newWorktree, selectFox, archiveFox, removeFox], + items: [openSettings, newWorktree, selectFox, deleteFox, changeFoxIcon], query: "" ) expectNoDifference(result.map(\.id), [openSettings.id, newWorktree.id]) @@ -1694,7 +1640,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt .openRepositorySettings: return .app case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, - .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, + .changeFocusedTabIcon, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .deleteWorktree, .runCustomCommand: return .worktree @@ -1726,7 +1672,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings: return true - case .worktreeSelect, .removeWorktree, .archiveWorktree, .changeFocusedTabIcon, + case .worktreeSelect, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost, .deleteWorktree, .runCustomCommand: return false -- 2.51.2 From 32d2497e8abe945f23055890aa29c1eb545aa459 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 00:11:08 +0900 Subject: [PATCH 150/528] Revert auto-scroll for Settings repository selection The auto-scroll behavior added in #301 doesn't feel right; drop the ScrollViewReader wiring and revert the sidebar list to its prior shape. The Cmd-W close shortcut from the same PR is kept intact. --- .../Settings/Views/SettingsView.swift | 76 +++++++------------ 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 4e75eb9d..8b891f67 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -29,53 +29,36 @@ struct SettingsView: View { NavigationSplitView(columnVisibility: .constant(.all)) { VStack(spacing: 0) { - ScrollViewReader { scrollProxy in - List(selection: $settingsStore.selection.sending(\.setSelection)) { - Label("General", systemImage: "gearshape") - .tag(SettingsSection.general) - .id(SettingsSection.general) - Label("Notifications", systemImage: "bell") - .tag(SettingsSection.notifications) - .id(SettingsSection.notifications) - Label("Shortcuts", systemImage: "keyboard") - .tag(SettingsSection.shortcuts) - .id(SettingsSection.shortcuts) - Label("Worktree", systemImage: "archivebox") - .tag(SettingsSection.worktree) - .id(SettingsSection.worktree) - Label("Updates", systemImage: "arrow.down.circle") - .tag(SettingsSection.updates) - .id(SettingsSection.updates) - Label("Advanced", systemImage: "gearshape.2") - .tag(SettingsSection.advanced) - .id(SettingsSection.advanced) - Label("GitHub", systemImage: "arrow.triangle.branch") - .tag(SettingsSection.github) - .id(SettingsSection.github) + List(selection: $settingsStore.selection.sending(\.setSelection)) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.general) + Label("Notifications", systemImage: "bell") + .tag(SettingsSection.notifications) + Label("Shortcuts", systemImage: "keyboard") + .tag(SettingsSection.shortcuts) + Label("Worktree", systemImage: "archivebox") + .tag(SettingsSection.worktree) + Label("Updates", systemImage: "arrow.down.circle") + .tag(SettingsSection.updates) + Label("Advanced", systemImage: "gearshape.2") + .tag(SettingsSection.advanced) + Label("GitHub", systemImage: "arrow.triangle.branch") + .tag(SettingsSection.github) - Section("Repositories") { - ForEach(repositories) { repository in - let repositorySection = SettingsSection.repository(repository.id) - RepoDisplayName( - fallbackName: repository.name, - customTitle: customTitles[repository.id] - ) - .tag(repositorySection) - .id(repositorySection) - } + Section("Repositories") { + ForEach(repositories) { repository in + RepoDisplayName( + fallbackName: repository.name, + customTitle: customTitles[repository.id] + ) + .tag(SettingsSection.repository(repository.id)) } } - .listStyle(.sidebar) - .frame(minWidth: 220, maxHeight: .infinity) - .navigationSplitViewColumnWidth(220) - .removingSidebarToggle() - .onAppear { - scrollToRepositorySelection(settingsStore.selection, proxy: scrollProxy) - } - .onChange(of: settingsStore.selection) { _, selection in - scrollToRepositorySelection(selection, proxy: scrollProxy) - } } + .listStyle(.sidebar) + .frame(minWidth: 220, maxHeight: .infinity) + .navigationSplitViewColumnWidth(220) + .removingSidebarToggle() } } detail: { switch selection { @@ -152,11 +135,4 @@ struct SettingsView: View { } .ignoresSafeArea(.container, edges: .top) } - - private func scrollToRepositorySelection(_ selection: SettingsSection?, proxy: ScrollViewProxy) { - guard case .repository(let repositoryID) = selection else { return } - withAnimation { - proxy.scrollTo(SettingsSection.repository(repositoryID), anchor: .center) - } - } } -- 2.51.2 From 29566ba0304456ce34ffda2df4cea988b683b4c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 01:45:46 +0900 Subject: [PATCH 151/528] bump v2026.5.19 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 8c4efa60..a55d7a12 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260515; + CURRENT_PROJECT_VERSION = 20260519; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -487,7 +487,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.15; + MARKETING_VERSION = 2026.5.19; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -522,7 +522,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260515; + CURRENT_PROJECT_VERSION = 20260519; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -545,7 +545,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.15; + MARKETING_VERSION = 2026.5.19; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -575,12 +575,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260515; + CURRENT_PROJECT_VERSION = 20260519; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.15; + MARKETING_VERSION = 2026.5.19; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -597,12 +597,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260515; + CURRENT_PROJECT_VERSION = 20260519; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.15; + MARKETING_VERSION = 2026.5.19; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 35712a1e..e7602069 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.15" + public static let current = "2026.5.19" } -- 2.51.2 From f25975d841151e91ab1d90f1e26034d502a0765d Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 01:45:47 +0900 Subject: [PATCH 152/528] Update CHANGELOG for 2026.5.19 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65be8d9..77732734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [2026.5.19](https://github.com/onevcat/Prowl/releases/tag/v2026.5.19) + +This release significantly expands the command palette, making it the central hub for navigating and controlling Prowl. + +### New + +- **Command palette now shows content on open.** Pressing Cmd+P with no query displays a Recent section (commands used in the past month) and a Suggested section (commonly useful app-level commands), so you no longer need to know what to type first. +- **View toggle commands in the palette.** Toggle Sidebar (⌘⌃S), Toggle Active Agents Panel (⌘⌥P), Toggle Canvas (⌘⌥↩), Toggle Shelf (⌘⇧↩), and Show Diff (⌘⇧Y) are now searchable and appear in Suggested when a worktree is active. +- **Navigation commands.** Reveal in Finder, Copy Path, and Reveal in Sidebar are available from the palette when a worktree is selected. +- **Worktree action commands.** Run Script (⌘R), Stop Script (⌘.), Pin/Unpin Worktree, Delete Worktree, and Rename Branch (⌘⇧M) are now accessible from the palette. Run/Stop Script toggles automatically based on the current script state. +- **Repo Settings command.** Open the Settings window navigated directly to the current repository by searching "settings" or the repo name in the palette. +- **Custom commands appear in the palette.** Repository-defined custom commands are searchable by name; their subtitle shows the execution mode (new tab, focused terminal, or split direction). +- **Keyword aliases for app-level commands.** Terms like `preferences`, `config`, `update`, `cli`, `repo`, and `worktree` match their corresponding commands even when the exact title isn't typed. + +### Fixed + +- Cmd+W now closes the Settings window when it is focused. +- Diff badge in the sidebar no longer truncates on narrow layouts. +- The "Check for Updates" menu item now has an icon, consistent with other app menu entries. + ## [2026.5.15](https://github.com/onevcat/Prowl/releases/tag/v2026.5.15) ### Fixed -- 2.51.2 From 5bf558c29f66aab6eb7ecc94a47d217e65aacc53 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 18:50:16 +0900 Subject: [PATCH 153/528] Fix stale PR chip on main worktree --- ...epositoriesFeature+GithubIntegration.swift | 13 +++++- supacodeTests/RepositoriesFeatureTests.swift | 42 ++++++++++++------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 1458d7c9..3ac455fe 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -197,7 +197,8 @@ extension RepositoriesFeature { guard let worktree = repository.worktrees[id: worktreeID] else { continue } - let pullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil + let loadedPullRequest = pullRequestsByWorktreeID[worktreeID] ?? nil + let pullRequest = Self.displayPullRequest(loadedPullRequest, for: worktree) let previousPullRequest = state.worktreeInfoByID[worktreeID]?.pullRequest guard previousPullRequest != pullRequest else { continue @@ -626,6 +627,16 @@ extension RepositoriesFeature { } } + nonisolated private static func displayPullRequest( + _ pullRequest: GithubPullRequest?, + for worktree: Worktree + ) -> GithubPullRequest? { + if worktree.isMain, pullRequest?.state.uppercased() == "MERGED" { + return nil + } + return pullRequest + } + nonisolated private static func validWebURL(_ raw: String) -> URL? { guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 83993f2f..b11bbc55 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3212,13 +3212,7 @@ struct RepositoriesFeatureTests { repositoryID: repository.id, pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] )) - ) { - $0.worktreeInfoByID[mainWorktree.id] = WorktreeInfoEntry( - addedLines: nil, - removedLines: nil, - pullRequest: mergedPullRequest - ) - } + ) await store.finish() } @@ -3276,13 +3270,7 @@ struct RepositoriesFeatureTests { repositoryID: repository.id, pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] )) - ) { - $0.worktreeInfoByID[mainWorktree.id] = WorktreeInfoEntry( - addedLines: nil, - removedLines: nil, - pullRequest: mergedPullRequest - ) - } + ) await store.finish() } @@ -4042,6 +4030,32 @@ struct RepositoriesFeatureTests { } } + @Test func repositoryPullRequestsLoadedClearsMergedPullRequestForMainWorktree() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) + let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: mainWorktree.name) + var state = makeState(repositories: [repository]) + state.worktreeInfoByID[mainWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: mergedPullRequest + ) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repository.id, + pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] + )) + ) { + $0.worktreeInfoByID.removeValue(forKey: mainWorktree.id) + } + } + @Test func unarchiveWorktreeNoopsWhenNotArchived() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) -- 2.51.2 From 71789614706a0c8a3d14418f4825d384fa169451 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 22:09:52 +0900 Subject: [PATCH 154/528] Run CI test steps in parallel --- .github/workflows/test.yml | 37 ++++++++++++++++++++++++++++++++++--- Makefile | 8 +++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65ca73c7..0ff47a03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,9 +26,40 @@ jobs: - uses: ./.github/actions/setup-macos - run: make lint - run: make build-app - - run: make test - - run: make test-cli-smoke - - run: make test-cli-integration + - name: Run tests in parallel + shell: bash + run: | + set -euo pipefail + mkdir -p build/ci-logs + + run_task() { + local name="$1" + shift + local log="build/ci-logs/${name}.log" + echo "::group::${name}" + if "$@" >"$log" 2>&1; then + cat "$log" + echo "::endgroup::" + return 0 + fi + local status=$? + cat "$log" + echo "::endgroup::" + return "$status" + } + + run_task app-tests make test-app & + pid_app=$! + run_task cli-smoke make test-cli-smoke & + pid_smoke=$! + run_task cli-integration make test-cli-integration & + pid_cli=$! + + failed=0 + wait "$pid_app" || failed=1 + wait "$pid_smoke" || failed=1 + wait "$pid_cli" || failed=1 + exit "$failed" - name: Upload xcresult bundle (on failure) if: failure() uses: actions/upload-artifact@v4 diff --git a/Makefile b/Makefile index 10123f2c..21b17ac3 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -71,7 +71,7 @@ _check-ghostty-hash: done; \ if [ "$$current_sha" != "$$last_sha" ] || [ "$$artifacts_ok" -ne 1 ]; then \ echo "Syncing GhosttyKit for submodule $$current_sha"; \ - $(MAKE) -B build-ghostty-xcframework; \ + $(MAKE) -B build-ghostty-xcframework || exit $$?; \ if [ "$$current_sha" != "$$last_sha" ]; then \ rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-*; \ echo "Cleared Xcode DerivedData for ghostty header/module changes"; \ @@ -276,7 +276,9 @@ archive: build-ghostty-xcframework embed-cli # Archive Release build for distrib export-archive: # Export xarchive 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' -test: ensure-ghostty +test: ensure-ghostty embed-cli-debug test-app + +test-app: ensure-ghostty # Run app/unit tests via xcodebuild @set -euo pipefail; \ result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/supacode-tests.xcresult"; \ mkdir -p "$$(dirname "$$result_bundle")"; \ -- 2.51.2 From c83d5fedb23a54283b614bfe59a954f2f027ce17 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 23:21:19 +0900 Subject: [PATCH 155/528] Speed up Debug builds by trimming type-checker hotspots and caching SPM Profile diagnostics from `xcodebuild ... OTHER_SWIFT_FLAGS=-Xfrontend -warn-long-function-bodies=100 -Xfrontend -warn-long-expression-type-checking=100` surfaced ~14s of cumulative type-checking concentrated in a handful of files, plus the project-level compilation cache being limited to the main target only. This pass attacks both. Type-checker hotspots refactored: - ArchivedWorktreesDetailView body (4312ms -> 346ms): hoist data prep into a typed snapshot, split branches/sections into helpers, and promote the inline (repository:, worktrees:) tuple to a nominal ArchivedWorktreeGroup struct. - CanvasView body (1639ms -> below threshold): pull the per-card view out into a private `cardView(for:in:activeStates:)` helper. - WorktreeTerminalTabsView / ShelfOpenBookView bodies: factor out the tab bar, content stack, and icon-picker sheet into private helpers. - ListRuntimeSnapshotBuilder / TargetResolver / ToolbarNotificationGroup: replace `Dictionary(uniqueKeysWithValues:)` and chained higher-order closures with typed for-loops that the type-checker resolves cheaply. - supacodeApp.init: extract MemoryWatchdog wiring into a static helper to break the long property chain. Build settings: - COMPILATION_CACHE_ENABLE_CACHING = YES at the project Debug level so SPM packages (TCA, Sharing, Dependencies, PostHog, Sentry, ...) hit the same Xcode compilation cache the main target already uses. With a warm cache this drops `make build-app` clean wall time from ~44s to ~26s on an M2 Pro (~40% reduction). - EAGER_LINKING = YES for Debug to overlap linking with compilation. Verified: `make build-app`, `make lint`, swift-format strict lint, and ToolbarNotificationGroupingTests all pass. --- .gitignore | 1 + supacode.xcodeproj/project.pbxproj | 2 + supacode/App/supacodeApp.swift | 34 ++- .../ListRuntimeSnapshotBuilder.swift | 10 +- supacode/CLIService/TargetResolver.swift | 10 +- .../Features/Canvas/Views/CanvasView.swift | 198 ++++++++-------- .../Models/ToolbarNotificationGroup.swift | 52 +++-- .../Reducer/RepositoriesFeature.swift | 11 +- .../Views/ArchivedWorktreesDetailView.swift | 217 +++++++++++------- .../Shelf/Views/ShelfOpenBookView.swift | 143 ++++++------ .../Views/WorktreeTerminalTabsView.swift | 141 ++++++------ 11 files changed, 476 insertions(+), 343 deletions(-) diff --git a/.gitignore b/.gitignore index 38bbdb95..454ad20e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ build/ # Release-only credentials (analytics, crash reporting) Config/Secrets.env +.build-benchmark/ diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index a55d7a12..92ac81f8 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -357,10 +357,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COMPILATION_CACHE_ENABLE_CACHING = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 9ZLSJ2GN2B; + EAGER_LINKING = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index a98c34a2..1b99106a 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -201,17 +201,7 @@ struct SupacodeApp: App { let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) _cliSocketServer = State(initialValue: cliServer) - let watchdog = MemoryWatchdog( - analyticsCapture: AnalyticsClient.liveValue.capture, - contextProvider: { [appStore, terminalManager] in - let state = appStore.state - return MemoryWatchdog.Context( - repositoryCount: state.repositories.repositories.count, - openedWorktreeCount: state.repositories.repositories.flatMap(\.worktrees).count, - terminalTabCount: terminalManager.activeWorktreeStates.flatMap(\.tabManager.tabs).count - ) - } - ) + let watchdog = Self.makeMemoryWatchdog(appStore: appStore, terminalManager: terminalManager) #if !DEBUG watchdog.start() #endif @@ -233,6 +223,28 @@ struct SupacodeApp: App { #endif } + private static func makeMemoryWatchdog( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> MemoryWatchdog { + MemoryWatchdog( + analyticsCapture: AnalyticsClient.liveValue.capture, + contextProvider: { [appStore, terminalManager] in + let repositoriesState = appStore.state.repositories + let repositories = repositoriesState.repositories + let repositoryCount = repositories.count + let openedWorktreeCount = repositories.reduce(0) { $0 + $1.worktrees.count } + let activeStates = terminalManager.activeWorktreeStates + let terminalTabCount = activeStates.reduce(0) { $0 + $1.tabManager.tabs.count } + return MemoryWatchdog.Context( + repositoryCount: repositoryCount, + openedWorktreeCount: openedWorktreeCount, + terminalTabCount: terminalTabCount + ) + } + ) + } + private static func makeTerminalClient(terminalManager: WorktreeTerminalManager) -> TerminalClient { TerminalClient( send: { command in diff --git a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift index 3d0f6e1f..720a2fc1 100644 --- a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift +++ b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift @@ -14,10 +14,12 @@ enum ListRuntimeSnapshotBuilder { repositoriesState: RepositoriesFeature.State, terminalManager: WorktreeTerminalManager ) -> ListRuntimeSnapshot { - let activeSnapshots = Dictionary( - uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { - ($0.worktreeID, $0.makeCLIListSnapshot()) - }) + let activeStates = terminalManager.activeWorktreeStates + var activeSnapshots: [String: CLIWorktreeTerminalSnapshot] = [:] + activeSnapshots.reserveCapacity(activeStates.count) + for state in activeStates { + activeSnapshots[state.worktreeID] = state.makeCLIListSnapshot() + } let orderedContexts = orderedWorktreeContexts(from: repositoriesState) let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index 12084940..9e5da629 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -243,11 +243,11 @@ enum TargetResolutionSnapshotBuilder { repositoriesState: RepositoriesFeature.State, terminalManager: WorktreeTerminalManager ) -> TargetResolutionSnapshot { - let activeSnapshots = Dictionary( - uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { - ($0.worktreeID, $0) - } - ) + var activeSnapshots: [String: WorktreeTerminalState] = [:] + activeSnapshots.reserveCapacity(terminalManager.activeWorktreeStates.count) + for state in terminalManager.activeWorktreeStates { + activeSnapshots[state.worktreeID] = state + } let orderedContexts = ListRuntimeSnapshotBuilder.orderedWorktreeContexts(from: repositoriesState) let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 559011fd..43f6887f 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -78,97 +78,7 @@ struct CanvasView: View { .onTapGesture { clearSelection(states: activeStates) } .gesture(canvasPanGesture) - // Cards layer: one card per open tab across all worktrees. - // Uses .offset() (not .position()) to avoid parent size proposals - // reaching the NSView, keeping terminal grid stable during zoom. - ForEach(activeStates, id: \.worktreeID) { state in - ForEach(state.tabManager.tabs) { tab in - if state.surfaceView(for: tab.id) != nil { - let tree = state.splitTree(for: tab.id) - let cardKey = tab.id.rawValue.uuidString - let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) - let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) - let screenCenter = screenPosition(for: resized.center) - let cardTotalHeight = resized.size.height + titleBarHeight - let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() - let splitDivider = terminalManager.splitDividerAppearance() - - let repositoryAppearance = appearance(for: state.repositoryRootURL) - let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) - CanvasCardView( - repositoryName: resolvedRepositoryName, - worktreeName: tab.displayTitle, - repositoryIcon: repositoryAppearance.icon, - repositoryColor: repositoryAppearance.color?.color, - repositoryRootURL: state.repositoryRootURL, - tree: tree, - activeSurfaceID: state.activeSurfaceID(for: tab.id), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - isFocused: selectionState.primaryTabID == tab.id, - isSelected: selectionState.selectedTabIDs.contains(tab.id), - hasUnseenNotification: state.hasUnseenNotification(for: tab.id), - cardSize: resized.size, - canvasScale: canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id), - onTap: { - let cmdHeld = NSEvent.modifierFlags.contains(.command) - if cmdHeld { - handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - } else { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - } - }, - onSelectionTap: { - handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - }, - onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, - onResize: { edge, translation in - activeResize[tab.id] = ActiveResize( - edge: edge, - translation: CGSize( - width: translation.width / canvasScale, - height: translation.height / canvasScale - ) - ) - }, - onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, - onSplitOperation: { operation in - state.performSplitOperation(operation, in: tab.id) - if selectionState.isBroadcasting { - syncBroadcastCallbacks(states: activeStates) - } - }, - onTitleBarTap: { - let wasAlreadyFocused = - selectionState.primaryTabID == tab.id - && selectionState.selectedTabIDs.count <= 1 - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - let now = Date() - if wasAlreadyFocused, - now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval - { - onExitToTab() - } - lastTitleBarTapDate = now - }, - onExpand: { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - onExitToTab() - }, - onClose: { - state.closeTab(tab.id) - } - ) - .scaleEffect(canvasScale, anchor: .center) - .offset( - x: screenCenter.x - resized.size.width / 2, - y: screenCenter.y - cardTotalHeight / 2 - ) - .zIndex(zIndex(for: tab.id)) - } - } - } + cardsLayer(activeStates: activeStates) } .contentShape(.rect) .simultaneousGesture(canvasZoomGesture) @@ -220,6 +130,112 @@ struct CanvasView: View { return false } + // MARK: - Cards Layer + + /// Cards layer: one card per open tab across all worktrees. + /// Uses .offset() (not .position()) to avoid parent size proposals + /// reaching the NSView, keeping terminal grid stable during zoom. + @ViewBuilder + private func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { + ForEach(activeStates, id: \.worktreeID) { state in + ForEach(state.tabManager.tabs) { tab in + if state.surfaceView(for: tab.id) != nil { + cardView(for: tab, in: state, activeStates: activeStates) + } + } + } + } + + @ViewBuilder + private func cardView( + for tab: TerminalTabItem, + in state: WorktreeTerminalState, + activeStates: [WorktreeTerminalState] + ) -> some View { + let tree = state.splitTree(for: tab.id) + let cardKey = tab.id.rawValue.uuidString + let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) + let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) + let screenCenter = screenPosition(for: resized.center) + let cardTotalHeight = resized.size.height + titleBarHeight + let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() + let splitDivider = terminalManager.splitDividerAppearance() + let repositoryAppearance = appearance(for: state.repositoryRootURL) + let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) + + CanvasCardView( + repositoryName: resolvedRepositoryName, + worktreeName: tab.displayTitle, + repositoryIcon: repositoryAppearance.icon, + repositoryColor: repositoryAppearance.color?.color, + repositoryRootURL: state.repositoryRootURL, + tree: tree, + activeSurfaceID: state.activeSurfaceID(for: tab.id), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + isFocused: selectionState.primaryTabID == tab.id, + isSelected: selectionState.selectedTabIDs.contains(tab.id), + hasUnseenNotification: state.hasUnseenNotification(for: tab.id), + cardSize: resized.size, + canvasScale: canvasScale, + showsSelectionShield: showsSelectionShield(for: tab.id), + onTap: { + let cmdHeld = NSEvent.modifierFlags.contains(.command) + if cmdHeld { + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) + } else { + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + } + }, + onSelectionTap: { + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) + }, + onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, + onResize: { edge, translation in + activeResize[tab.id] = ActiveResize( + edge: edge, + translation: CGSize( + width: translation.width / canvasScale, + height: translation.height / canvasScale + ) + ) + }, + onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, + onSplitOperation: { operation in + state.performSplitOperation(operation, in: tab.id) + if selectionState.isBroadcasting { + syncBroadcastCallbacks(states: activeStates) + } + }, + onTitleBarTap: { + let wasAlreadyFocused = + selectionState.primaryTabID == tab.id + && selectionState.selectedTabIDs.count <= 1 + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + let now = Date() + if wasAlreadyFocused, + now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval + { + onExitToTab() + } + lastTitleBarTapDate = now + }, + onExpand: { + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + onExitToTab() + }, + onClose: { + state.closeTab(tab.id) + } + ) + .scaleEffect(canvasScale, anchor: .center) + .offset( + x: screenCenter.x - resized.size.width / 2, + y: screenCenter.y - cardTotalHeight / 2 + ) + .zIndex(zIndex(for: tab.id)) + } + // MARK: - Canvas Gestures private var canvasPanGesture: some Gesture { diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index 7d503de0..d83b34a5 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -33,27 +33,19 @@ extension RepositoriesFeature.State { terminalManager: WorktreeTerminalManager, customTitles: [Repository.ID: String] = [:] ) -> [ToolbarNotificationRepositoryGroup] { - let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) - var groups: [ToolbarNotificationRepositoryGroup] = [] + var repositoriesByID: [Repository.ID: Repository] = [:] + repositoriesByID.reserveCapacity(repositories.count) + for repository in repositories { + repositoriesByID[repository.id] = repository + } + var groups: [ToolbarNotificationRepositoryGroup] = [] for repositoryID in orderedRepositoryIDs() { - guard let repository = repositoriesByID[repositoryID] else { - continue - } - - let worktreeGroups: [ToolbarNotificationWorktreeGroup] = - orderedWorktrees(in: repository).compactMap { worktree -> ToolbarNotificationWorktreeGroup? in - guard let state = terminalManager.stateIfExists(for: worktree.id), !state.notifications.isEmpty else { - return nil - } - return ToolbarNotificationWorktreeGroup( - id: worktree.id, - name: worktree.name, - notifications: state.notifications, - hasUnseenNotifications: terminalManager.hasUnseenNotifications(for: worktree.id) - ) - } - + guard let repository = repositoriesByID[repositoryID] else { continue } + let worktreeGroups = worktreeNotificationGroups( + repository: repository, + terminalManager: terminalManager + ) if !worktreeGroups.isEmpty { groups.append( ToolbarNotificationRepositoryGroup( @@ -64,7 +56,27 @@ extension RepositoriesFeature.State { ) } } - return groups } + + private func worktreeNotificationGroups( + repository: Repository, + terminalManager: WorktreeTerminalManager + ) -> [ToolbarNotificationWorktreeGroup] { + var result: [ToolbarNotificationWorktreeGroup] = [] + for worktree in orderedWorktrees(in: repository) { + guard let state = terminalManager.stateIfExists(for: worktree.id), + !state.notifications.isEmpty + else { continue } + result.append( + ToolbarNotificationWorktreeGroup( + id: worktree.id, + name: worktree.name, + notifications: state.notifications, + hasUnseenNotifications: terminalManager.hasUnseenNotifications(for: worktree.id) + ) + ) + } + return result + } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ab3290d5..330f86ba 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1729,13 +1729,18 @@ extension RepositoriesFeature.State { return worktrees.filter { !archivedSet.contains($0.id) } } - func archivedWorktreesByRepository() -> [(repository: Repository, worktrees: [Worktree])] { + struct ArchivedWorktreeGroup: Equatable { + var repository: Repository + var worktrees: [Worktree] + } + + func archivedWorktreesByRepository() -> [ArchivedWorktreeGroup] { let archivedSet = archivedWorktreeIDSet - var groups: [(repository: Repository, worktrees: [Worktree])] = [] + var groups: [ArchivedWorktreeGroup] = [] for repository in repositories { let worktrees = Array(repository.worktrees.filter { archivedSet.contains($0.id) }) if !worktrees.isEmpty { - groups.append((repository: repository, worktrees: worktrees)) + groups.append(ArchivedWorktreeGroup(repository: repository, worktrees: worktrees)) } } return groups diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index c6c4a846..61d32b9a 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -7,95 +7,158 @@ struct ArchivedWorktreesDetailView: View { @State private var selectedArchivedWorktreeIDs: Set = [] var body: some View { + let snapshot = makeSnapshot() + if snapshot.groups.isEmpty { + emptyView + } else { + listView(snapshot: snapshot) + } + } + + private var emptyView: some View { + ContentUnavailableView( + "Archived Worktrees", + systemImage: "archivebox", + description: Text("Archive worktrees to keep them out of the main list.") + ) + } + + private func listView(snapshot: ArchivedSnapshot) -> some View { + let groups = snapshot.groups + return List(selection: $selectedArchivedWorktreeIDs) { + ForEach(groups.indices, id: \.self) { index in + archivedSection(index: index, group: groups[index]) + } + } + .listStyle(.sidebar) + .onChange(of: snapshot.groupIDs) { _, newValue in + collapsedRepositoryIDs = collapsedRepositoryIDs.intersection(newValue) + } + .onChange(of: snapshot.archivedWorktreeIDs) { _, newValue in + selectedArchivedWorktreeIDs = selectedArchivedWorktreeIDs.intersection(newValue) + } + .animation(.easeOut(duration: 0.2), value: snapshot.archivedRowIDs) + .focusedValue(\.deleteWorktreeAction, snapshot.deleteWorktreeAction) + .focusedSceneValue(\.confirmWorktreeAction, snapshot.confirmWorktreeAction) + .toolbar { toolbarContent(deleteWorktreeAction: snapshot.deleteWorktreeAction) } + } + + @ViewBuilder + private func archivedSection( + index: Int, + group: RepositoriesFeature.State.ArchivedWorktreeGroup + ) -> some View { + let isCollapsed = collapsedRepositoryIDs.contains(group.repository.id) + Section { + if !isCollapsed { + ForEach(group.worktrees, id: \.id) { worktree in + archivedRow(worktree: worktree, repositoryID: group.repository.id) + } + } + } header: { + ArchivedWorktreeSectionHeader( + name: group.repository.name, + worktreeCount: group.worktrees.count, + isCollapsed: isCollapsed, + showsTopSeparator: index > 0, + onToggle: { toggleSection(group.repository.id) } + ) + } + } + + private func archivedRow(worktree: Worktree, repositoryID: Repository.ID) -> some View { + ArchivedWorktreeRowView( + worktree: worktree, + info: store.state.worktreeInfo(for: worktree.id), + onUnarchive: { + store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) + }, + onDelete: { + store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repositoryID))) + } + ) + .tag(worktree.id) + .typeSelectEquivalent("") + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + + @ToolbarContentBuilder + private func toolbarContent(deleteWorktreeAction: (() -> Void)?) -> some ToolbarContent { + ToolbarItem { + let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display + Button("Delete Selected", systemImage: "trash", role: .destructive) { + deleteWorktreeAction?() + } + .help("Delete Selected (\(deleteShortcut))") + .disabled(deleteWorktreeAction == nil) + } + } + + private struct ArchivedSnapshot { + var groups: [RepositoriesFeature.State.ArchivedWorktreeGroup] + var groupIDs: Set + var archivedRowIDs: [Worktree.ID] + var archivedWorktreeIDs: Set + var deleteWorktreeAction: (() -> Void)? + var confirmWorktreeAction: (() -> Void)? + } + + private func makeSnapshot() -> ArchivedSnapshot { let groups = store.state.archivedWorktreesByRepository() let groupIDs = Set(groups.map(\.repository.id)) let archivedRowIDs = groups.flatMap(\.worktrees).map(\.id) - let archivedWorktreeIDs = Set(groups.flatMap(\.worktrees).map(\.id)) - let repositoryByWorktreeID = Dictionary( - uniqueKeysWithValues: groups.flatMap { group in - group.worktrees.map { worktree in - (worktree.id, group.repository.id) - } + let archivedWorktreeIDs = Set(archivedRowIDs) + + var repositoryByWorktreeID: [Worktree.ID: Repository.ID] = [:] + repositoryByWorktreeID.reserveCapacity(archivedRowIDs.count) + for group in groups { + for worktree in group.worktrees { + repositoryByWorktreeID[worktree.id] = group.repository.id } - ) - let selectedTargets: [RepositoriesFeature.DeleteWorktreeTarget] = - selectedArchivedWorktreeIDs.compactMap { worktreeID in - guard let repositoryID = repositoryByWorktreeID[worktreeID] else { return nil } - return RepositoriesFeature.DeleteWorktreeTarget( - worktreeID: worktreeID, - repositoryID: repositoryID + } + + var selectedTargets: [RepositoriesFeature.DeleteWorktreeTarget] = [] + selectedTargets.reserveCapacity(selectedArchivedWorktreeIDs.count) + for worktreeID in selectedArchivedWorktreeIDs { + if let repositoryID = repositoryByWorktreeID[worktreeID] { + selectedTargets.append( + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktreeID, + repositoryID: repositoryID + ) ) } - let deleteWorktreeAction: (() -> Void)? = { - guard !selectedTargets.isEmpty else { return nil } - return { + } + + let deleteWorktreeAction: (() -> Void)? + if selectedTargets.isEmpty { + deleteWorktreeAction = nil + } else { + let store = self.store + deleteWorktreeAction = { store.send(.worktreeLifecycle(.requestDeleteWorktrees(selectedTargets))) } - }() - let confirmWorktreeAction: (() -> Void)? = { - guard let alert = store.state.confirmWorktreeAlert else { return nil } - return { + } + + let confirmWorktreeAction: (() -> Void)? + if let alert = store.state.confirmWorktreeAlert { + let store = self.store + confirmWorktreeAction = { store.send(.alert(.presented(alert))) } - }() - if groups.isEmpty { - ContentUnavailableView( - "Archived Worktrees", - systemImage: "archivebox", - description: Text("Archive worktrees to keep them out of the main list.") - ) } else { - List(selection: $selectedArchivedWorktreeIDs) { - ForEach(Array(groups.enumerated()), id: \.element.repository.id) { index, group in - Section { - if !collapsedRepositoryIDs.contains(group.repository.id) { - ForEach(group.worktrees, id: \.id) { worktree in - ArchivedWorktreeRowView( - worktree: worktree, - info: store.state.worktreeInfo(for: worktree.id), - onUnarchive: { - store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) - }, - onDelete: { - store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, group.repository.id))) - } - ) - .tag(worktree.id) - .typeSelectEquivalent("") - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } - } - } header: { - ArchivedWorktreeSectionHeader( - name: group.repository.name, - worktreeCount: group.worktrees.count, - isCollapsed: collapsedRepositoryIDs.contains(group.repository.id), - showsTopSeparator: index > 0, - onToggle: { toggleSection(group.repository.id) } - ) - } - } - } - .listStyle(.sidebar) - .onChange(of: groupIDs) { _, newValue in - collapsedRepositoryIDs = collapsedRepositoryIDs.intersection(newValue) - } - .onChange(of: archivedWorktreeIDs) { _, newValue in - selectedArchivedWorktreeIDs = selectedArchivedWorktreeIDs.intersection(newValue) - } - .animation(.easeOut(duration: 0.2), value: archivedRowIDs) - .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) - .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) - .toolbar { - let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display - Button("Delete Selected", systemImage: "trash", role: .destructive) { - deleteWorktreeAction?() - } - .help("Delete Selected (\(deleteShortcut))") - .disabled(deleteWorktreeAction == nil) - } + confirmWorktreeAction = nil } + + return ArchivedSnapshot( + groups: groups, + groupIDs: groupIDs, + archivedRowIDs: archivedRowIDs, + archivedWorktreeIDs: archivedWorktreeIDs, + deleteWorktreeAction: deleteWorktreeAction, + confirmWorktreeAction: confirmWorktreeAction + ) } private func toggleSection(_ repositoryID: Repository.ID) { diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index b24796f5..81eb7241 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -24,82 +24,89 @@ struct ShelfOpenBookView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() - let splitDivider = manager.splitDividerAppearance() - Group { - if let selectedId = state.tabManager.selectedTabId { - TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer( - tree: state.splitTree(for: tabId), - activeSurfaceID: state.activeSurfaceID(for: tabId), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - hasNotification: { surfaceID in - state.hasUnseenNotification(forSurfaceID: surfaceID) - }, - action: { operation in - state.performSplitOperation(operation, in: tabId) - } - ) - } - } else { - EmptyTerminalPaneView(message: "No terminals open") + contentGroup(state: state) + .sheet( + item: Binding( + get: { state.iconPickerTabId }, + set: { state.iconPickerTabId = $0 } + ) + ) { tabId in + iconPickerSheet(state: state, tabId: tabId) } - } - .sheet( - item: Binding( - get: { state.iconPickerTabId }, - set: { state.iconPickerTabId = $0 } - ) - ) { tabId in - let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon - TabIconPickerView( - initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), - onApply: { newIcon in - state.applyIconChange(tabId, icon: newIcon) - state.dismissIconPicker() - }, - onCancel: { - state.dismissIconPicker() + .background( + WindowFocusObserverView { activity in + windowActivity = activity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } ) - } - .background( - WindowFocusObserverView { activity in - windowActivity = activity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) - } - ) - .onAppear { - shelfLogger.interval("OpenBook.onAppear") { - state.ensureInitialTab(focusing: false) - if shouldAutoFocusTerminal { - state.focusSelectedTab() + .onAppear { + shelfLogger.interval("OpenBook.onAppear") { + state.ensureInitialTab(focusing: false) + if shouldAutoFocusTerminal { + state.focusSelectedTab() + } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - let activity = resolvedWindowActivity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - } - .onDisappear { - // Long-term diagnostic — pairs with `OpenBook.onAppear` so that - // any future regression in the per-book-switch teardown/remount - // cadence shows up as a count delta on the Points of Interest - // timeline. - shelfLogger.event("OpenBook.onDisappear") - } - .onChange(of: state.tabManager.selectedTabId) { _, _ in - shelfLogger.interval("OpenBook.onChange.selectedTabId") { - if shouldAutoFocusTerminal { - state.focusSelectedTab() + .onDisappear { + // Long-term diagnostic — pairs with `OpenBook.onAppear` so that + // any future regression in the per-book-switch teardown/remount + // cadence shows up as a count delta on the Points of Interest + // timeline. + shelfLogger.event("OpenBook.onDisappear") + } + .onChange(of: state.tabManager.selectedTabId) { _, _ in + shelfLogger.interval("OpenBook.onChange.selectedTabId") { + if shouldAutoFocusTerminal { + state.focusSelectedTab() + } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - let activity = resolvedWindowActivity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in + configReloadCounter &+= 1 + } + } + + @ViewBuilder + private func contentGroup(state: WorktreeTerminalState) -> some View { + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() + if let selectedId = state.tabManager.selectedTabId { + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) + } + } else { + EmptyTerminalPaneView(message: "No terminals open") } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in - configReloadCounter &+= 1 - } + } + + private func iconPickerSheet(state: WorktreeTerminalState, tabId: TerminalTabID) -> some View { + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon + return TabIconPickerView( + initialIcon: currentIcon, + defaultIcon: state.defaultIcon(for: tabId), + onApply: { newIcon in + state.applyIconChange(tabId, icon: newIcon) + state.dismissIconPicker() + }, + onCancel: { + state.dismissIconPicker() + } + ) } private var shouldAutoFocusTerminal: Bool { diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index fa2ba54e..6e89e568 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -13,59 +13,9 @@ struct WorktreeTerminalTabsView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() - let splitDivider = manager.splitDividerAppearance() VStack(spacing: 0) { - TerminalTabBarView( - manager: state.tabManager, - createTab: createTab, - splitHorizontally: { - _ = state.performBindingActionOnFocusedSurface("new_split:down") - }, - splitVertically: { - _ = state.performBindingActionOnFocusedSurface("new_split:right") - }, - canSplit: state.tabManager.selectedTabId != nil, - renameTab: { tabId in - state.tabManager.beginTabRename(tabId) - }, - changeIcon: { tabId in - state.presentIconPicker(for: tabId) - }, - closeTab: { tabId in - state.closeTab(tabId) - }, - closeOthers: { tabId in - state.closeOtherTabs(keeping: tabId) - }, - closeToRight: { tabId in - state.closeTabsToRight(of: tabId) - }, - closeAll: { - state.closeAllTabs() - }, - hasNotification: { tabId in - state.hasUnseenNotification(for: tabId) - } - ) - if let selectedId = state.tabManager.selectedTabId { - TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer( - tree: state.splitTree(for: tabId), - activeSurfaceID: state.activeSurfaceID(for: tabId), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - hasNotification: { surfaceID in - state.hasUnseenNotification(forSurfaceID: surfaceID) - }, - action: { operation in - state.performSplitOperation(operation, in: tabId) - } - ) - } - } else { - EmptyTerminalPaneView(message: "No terminals open") - } + tabBar(state: state) + tabContent(state: state) } .sheet( item: Binding( @@ -73,18 +23,7 @@ struct WorktreeTerminalTabsView: View { set: { state.iconPickerTabId = $0 } ) ) { tabId in - let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon - TabIconPickerView( - initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), - onApply: { newIcon in - state.applyIconChange(tabId, icon: newIcon) - state.dismissIconPicker() - }, - onCancel: { - state.dismissIconPicker() - } - ) + iconPickerSheet(state: state, tabId: tabId) } .background( WindowFocusObserverView { activity in @@ -112,6 +51,80 @@ struct WorktreeTerminalTabsView: View { } } + private func tabBar(state: WorktreeTerminalState) -> some View { + TerminalTabBarView( + manager: state.tabManager, + createTab: createTab, + splitHorizontally: { + _ = state.performBindingActionOnFocusedSurface("new_split:down") + }, + splitVertically: { + _ = state.performBindingActionOnFocusedSurface("new_split:right") + }, + canSplit: state.tabManager.selectedTabId != nil, + renameTab: { tabId in + state.tabManager.beginTabRename(tabId) + }, + changeIcon: { tabId in + state.presentIconPicker(for: tabId) + }, + closeTab: { tabId in + state.closeTab(tabId) + }, + closeOthers: { tabId in + state.closeOtherTabs(keeping: tabId) + }, + closeToRight: { tabId in + state.closeTabsToRight(of: tabId) + }, + closeAll: { + state.closeAllTabs() + }, + hasNotification: { tabId in + state.hasUnseenNotification(for: tabId) + } + ) + } + + @ViewBuilder + private func tabContent(state: WorktreeTerminalState) -> some View { + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() + if let selectedId = state.tabManager.selectedTabId { + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) + } + } else { + EmptyTerminalPaneView(message: "No terminals open") + } + } + + private func iconPickerSheet(state: WorktreeTerminalState, tabId: TerminalTabID) -> some View { + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon + return TabIconPickerView( + initialIcon: currentIcon, + defaultIcon: state.defaultIcon(for: tabId), + onApply: { newIcon in + state.applyIconChange(tabId, icon: newIcon) + state.dismissIconPicker() + }, + onCancel: { + state.dismissIconPicker() + } + ) + } + private var shouldAutoFocusTerminal: Bool { if forceAutoFocus { return true -- 2.51.2 From 5b18c4bd7cf1ba908b970b27f7272c4128173b15 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 23:45:50 +0900 Subject: [PATCH 156/528] Make CI Xcode compilation cache key rotate so save-back actually triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actions/cache@v4` only saves a new cache when the primary key has no exact match. The previous static `xcode-compilation-cache-v0` key meant the very first CI run that populated the cache became permanent: every subsequent run restored that same snapshot but never updated it. In practice the saved cache is ~152MB (verified via `gh api /repos/onevcat/Prowl/actions/caches`), frozen since the workflow was added back in February 2026. Most of the project's compile work doesn't find its content hash in there, so CI keeps cold-compiling everything except a few stable SDK Clang modules — even though the `hits / cacheable tasks` metric looks high (the metric only counts tasks Xcode bothered to look up, not the silent misses). Rotate the key by Package.resolved + project.pbxproj hashes so it only churns on dep upgrades or target/build-setting edits, not on every PR. That avoids paying the ~30s upload cost on routine code-only PRs while still letting the cache evolve when its content profile actually changes. restore-keys keeps the old `-v0` cache reachable as a one-time fallback until the new `-v1` snapshot lands. --- .github/actions/setup-macos/action.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index bd9b10bf..b2fe6ada 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -60,6 +60,12 @@ runs: uses: actions/cache@v4 with: path: ~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex - key: ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v0 + # Rotate when SPM deps or the Xcode project itself changes (target add/remove, + # build setting tweaks). Day-to-day .swift edits keep the same primary key, so + # `actions/cache` restores without re-uploading — the cache is content-addressed + # by file hash, so unchanged files still hit; changed files miss and recompile + # locally without invalidating the saved snapshot. + key: ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v1-${{ hashFiles('Package.resolved', 'supacode.xcodeproj/project.pbxproj') }} restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v1- ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache- -- 2.51.2 From 60c19d0310dd08e291e536a2ecf1f6bcfa93e629 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 19 May 2026 23:55:47 +0900 Subject: [PATCH 157/528] ci: empty commit to verify v1 cache restore hits primary key -- 2.51.2 From cf7f00df2c1c3869a387ffe7d69b875fb48f0620 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 20 May 2026 00:29:18 +0900 Subject: [PATCH 158/528] bump v2026.5.20 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 92ac81f8..08b0902a 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260519; + CURRENT_PROJECT_VERSION = 20260520; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.19; + MARKETING_VERSION = 2026.5.20; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260519; + CURRENT_PROJECT_VERSION = 20260520; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.19; + MARKETING_VERSION = 2026.5.20; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260519; + CURRENT_PROJECT_VERSION = 20260520; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.19; + MARKETING_VERSION = 2026.5.20; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260519; + CURRENT_PROJECT_VERSION = 20260520; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.19; + MARKETING_VERSION = 2026.5.20; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index e7602069..b9d35a68 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.19" + public static let current = "2026.5.20" } -- 2.51.2 From 6a160bea02084cde84c4d142708daea9243bfdab Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 20 May 2026 00:29:18 +0900 Subject: [PATCH 159/528] Update CHANGELOG for 2026.5.20 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77732734..cd003aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [2026.5.20](https://github.com/onevcat/Prowl/releases/tag/v2026.5.20) + +### Fixed + +- The PR chip on the main worktree no longer shows a stale badge from a previously merged pull request. Merged PRs are now correctly ignored when the main branch is checked out. + +### Improved + +- Internal build and CI improvements; no user-facing behavior changes. + ## [2026.5.19](https://github.com/onevcat/Prowl/releases/tag/v2026.5.19) This release significantly expands the command palette, making it the central hub for navigating and controlling Prowl. -- 2.51.2 From aa43075377a33687343b6917192130cd43f8b636 Mon Sep 17 00:00:00 2001 From: Abhishek Maurya <42537316+abhi21git@users.noreply.github.com> Date: Wed, 20 May 2026 22:25:03 +0530 Subject: [PATCH 160/528] Fix toolbar title hover layout shift #324 --- .../Features/Repositories/Views/WorktreeDetailView.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 661ffa14..7aaad6d7 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -515,7 +515,7 @@ struct WorktreeDetailView: View { @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { - ToolbarItem { + ToolbarItem(placement: .navigation) { WorktreeDetailTitleView( title: toolbarState.title, onSubmit: toolbarState.title.supportsRename ? onRenameBranch : nil, @@ -524,9 +524,7 @@ struct WorktreeDetailView: View { ) } - ToolbarSpacer(.flexible) - - ToolbarItemGroup { + ToolbarItem(placement: .principal) { ToolbarStatusView( toast: toolbarState.statusToast, pullRequest: toolbarState.pullRequest, @@ -535,7 +533,6 @@ struct WorktreeDetailView: View { .padding(.horizontal) } - ToolbarSpacer(.fixed) ToolbarItemGroup { ToolbarNotificationsPopoverButton( groups: toolbarState.notificationGroups, @@ -551,7 +548,7 @@ struct WorktreeDetailView: View { } } - ToolbarSpacer(.flexible) + ToolbarSpacer(.fixed) ToolbarItemGroup { openMenu( -- 2.51.2 From 3664e57a4fe3815de993d862735b45fb0a31c989 Mon Sep 17 00:00:00 2001 From: onevclaw Date: Fri, 22 May 2026 12:05:02 +0900 Subject: [PATCH 161/528] Persist canvas card layout order --- .../Canvas/Models/CanvasCardLayout.swift | 98 +++++++++++++++++-- .../Features/Canvas/Views/CanvasView.swift | 50 ++++++---- supacodeTests/CanvasLayoutStoreTests.swift | 88 +++++++++++++++++ 3 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 supacodeTests/CanvasLayoutStoreTests.swift diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index f92f58b2..51b7d00d 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -236,23 +236,103 @@ final class CanvasLayoutStore { /// Whether auto-arrange has run in this app session. Resets on app launch. static var hasAutoArrangedInSession = false + private let defaults: UserDefaults + private let initiallyLoadedCardKeys: Set + var cardLayouts: [String: CanvasCardLayout] { didSet { save() } } - init() { - if let data = UserDefaults.standard.data(forKey: Self.storageKey), - let layouts = try? JSONDecoder().decode([String: CanvasCardLayout].self, from: data) - { - self.cardLayouts = layouts - } else { - self.cardLayouts = [:] + var zOrder: [String] { + didSet { save() } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + let stored = Self.load(from: defaults) + cardLayouts = stored.cardLayouts + zOrder = stored.zOrder + initiallyLoadedCardKeys = Set(stored.cardLayouts.keys) + } + + func shouldAutoArrangeOnInitialEntry(for cardKeys: [String]) -> Bool { + guard !cardKeys.isEmpty else { return false } + return !cardKeys.contains { initiallyLoadedCardKeys.contains($0) } + } + + func setCardLayouts(_ layouts: [String: CanvasCardLayout], zOrder newZOrder: [String]? = nil) { + cardLayouts = layouts + zOrder = normalizedZOrder(newZOrder ?? zOrder, visibleKeys: Array(layouts.keys)) + } + + func ensureZOrder(for visibleKeys: [String]) { + zOrder = normalizedZOrder(zOrder, visibleKeys: visibleKeys) + } + + func prune(to visibleKeys: Set) { + cardLayouts = cardLayouts.filter { visibleKeys.contains($0.key) } + zOrder = zOrder.filter { visibleKeys.contains($0) } + } + + func moveToFront(_ cardKey: String) { + guard cardLayouts[cardKey] != nil else { return } + zOrder.removeAll { $0 == cardKey } + zOrder.append(cardKey) + } + + func zIndex(for cardKey: String) -> Double { + guard let index = zOrder.firstIndex(of: cardKey) else { return 0 } + return Double(index) + } + + private static func load(from defaults: UserDefaults) -> CanvasLayoutStoragePayload { + guard let data = defaults.data(forKey: storageKey) else { + return CanvasLayoutStoragePayload(cardLayouts: [:], zOrder: []) + } + + if let payload = try? JSONDecoder().decode(CanvasLayoutStoragePayload.self, from: data) { + return CanvasLayoutStoragePayload( + cardLayouts: payload.cardLayouts, + zOrder: normalizedZOrder(payload.zOrder, visibleKeys: Array(payload.cardLayouts.keys)) + ) } + + if let layouts = try? JSONDecoder().decode([String: CanvasCardLayout].self, from: data) { + return CanvasLayoutStoragePayload(cardLayouts: layouts, zOrder: Array(layouts.keys).sorted()) + } + + return CanvasLayoutStoragePayload(cardLayouts: [:], zOrder: []) } private func save() { - if let data = try? JSONEncoder().encode(cardLayouts) { - UserDefaults.standard.set(data, forKey: Self.storageKey) + let payload = CanvasLayoutStoragePayload( + cardLayouts: cardLayouts, + zOrder: Self.normalizedZOrder(zOrder, visibleKeys: Array(cardLayouts.keys)) + ) + if let data = try? JSONEncoder().encode(payload) { + defaults.set(data, forKey: Self.storageKey) } } + + private func normalizedZOrder(_ order: [String], visibleKeys: [String]) -> [String] { + Self.normalizedZOrder(order, visibleKeys: visibleKeys) + } + + private static func normalizedZOrder(_ order: [String], visibleKeys: [String]) -> [String] { + let visibleKeySet = Set(visibleKeys) + var seen: Set = [] + var normalized: [String] = [] + for key in order where visibleKeySet.contains(key) && seen.insert(key).inserted { + normalized.append(key) + } + for key in visibleKeys.sorted() where seen.insert(key).inserted { + normalized.append(key) + } + return normalized + } +} + +private struct CanvasLayoutStoragePayload: Codable, Equatable { + var cardLayouts: [String: CanvasCardLayout] + var zOrder: [String] } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 43f6887f..68cc033a 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -24,6 +24,7 @@ struct CanvasView: View { @State private var lastTitleBarTapDate: Date = .distantPast @State private var activeResize: [TerminalTabID: ActiveResize] = [:] @State private var hasPerformedInitialFit = false + @State private var hasSeenCanvasCards = false @State private var viewportSize: CGSize = .zero @State private var showsCanvasHelp = false @State private var configReloadCounter = 0 @@ -59,15 +60,29 @@ struct CanvasView: View { // Background layer: handles canvas pan and tap-to-clear. Color.clear .onAppear { + if !allCardKeys.isEmpty { + hasSeenCanvasCards = true + } ensureLayouts(for: allCardKeys) + if !allCardKeys.isEmpty { + layoutStore.ensureZOrder(for: allCardKeys) + } pruneSelection(previousOrder: [], currentOrder: allTabIDs, states: activeStates) syncBroadcastCallbacks(states: activeStates) } .onChange(of: allCardKeys) { _, newKeys in if newKeys.isEmpty { CanvasLayoutStore.hasAutoArrangedInSession = false + if hasSeenCanvasCards { + layoutStore.prune(to: []) + } + } else { + hasSeenCanvasCards = true } ensureLayouts(for: newKeys) + if !newKeys.isEmpty { + layoutStore.ensureZOrder(for: newKeys) + } syncBroadcastCallbacks(states: activeStates) } .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in @@ -86,11 +101,14 @@ struct CanvasView: View { proxy.size } action: { newSize in viewportSize = newSize - if !hasPerformedInitialFit { + let currentCardKeys = collectCardKeys(from: terminalManager.activeWorktreeStates) + if !hasPerformedInitialFit, !currentCardKeys.isEmpty { hasPerformedInitialFit = true if !CanvasLayoutStore.hasAutoArrangedInSession { CanvasLayoutStore.hasAutoArrangedInSession = true - arrangeCards() + if layoutStore.shouldAutoArrangeOnInitialEntry(for: currentCardKeys) { + arrangeCards() + } } fitToView(canvasSize: newSize) } @@ -233,7 +251,7 @@ struct CanvasView: View { x: screenCenter.x - resized.size.width / 2, y: screenCenter.y - cardTotalHeight / 2 ) - .zIndex(zIndex(for: tab.id)) + .zIndex(zIndex(for: tab.id, cardKey: cardKey)) } // MARK: - Canvas Gestures @@ -301,7 +319,7 @@ struct CanvasView: View { position: gridPosition(index: positionedCount + offset, columns: columns) ) } - layoutStore.cardLayouts = layouts + layoutStore.setCardLayouts(layouts) } /// Balanced grid: columns ≈ sqrt(N). No viewport constraint — the canvas @@ -392,7 +410,7 @@ struct CanvasView: View { position: gridPosition(index: index, columns: columns) ) } - layoutStore.cardLayouts = layouts + layoutStore.setCardLayouts(layouts, zOrder: keys) } /// Arrange cards using MaxRects-BSSF bin packing. Preserves each card's @@ -412,7 +430,7 @@ struct CanvasView: View { let result = packer.pack(cards: cards, targetRatio: targetRatio) guard !result.layouts.isEmpty else { return } - layoutStore.cardLayouts = result.layouts + layoutStore.setCardLayouts(result.layouts, zOrder: keys) } /// Adjust scale and offset so all cards fit within the viewport. @@ -460,13 +478,8 @@ struct CanvasView: View { /// Remove stored layouts for tabs that no longer exist. private func cleanStaleLayouts() { let visibleKeys = Set(collectCardKeys(from: terminalManager.activeWorktreeStates)) - let staleKeys = layoutStore.cardLayouts.keys.filter { !visibleKeys.contains($0) } - guard !staleKeys.isEmpty else { return } - var layouts = layoutStore.cardLayouts - for key in staleKeys { - layouts.removeValue(forKey: key) - } - layoutStore.cardLayouts = layouts + guard !visibleKeys.isEmpty || hasSeenCanvasCards else { return } + layoutStore.prune(to: visibleKeys) } private var canvasHelpButton: some View { @@ -580,14 +593,15 @@ struct CanvasView: View { .padding() } - private func zIndex(for tabID: TerminalTabID) -> Double { + private func zIndex(for tabID: TerminalTabID, cardKey: String) -> Double { + let base = layoutStore.zIndex(for: cardKey) if selectionState.primaryTabID == tabID { - return 2 + return 10_000 + base } if selectionState.selectedTabIDs.contains(tabID) { - return 1 + return 9_000 + base } - return 0 + return base } // MARK: - Drag @@ -633,6 +647,7 @@ struct CanvasView: View { surfaceState _: WorktreeTerminalState, states: [WorktreeTerminalState] ) { + layoutStore.moveToFront(tabID.rawValue.uuidString) mutateSelection(states: states) { state in state.focusSingle(tabID) } @@ -700,6 +715,7 @@ struct CanvasView: View { return } + layoutStore.moveToFront(newTabID.rawValue.uuidString) ownerState.tabManager.selectTab(newTabID) terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID surfaceView.focusDidChange(true) diff --git a/supacodeTests/CanvasLayoutStoreTests.swift b/supacodeTests/CanvasLayoutStoreTests.swift new file mode 100644 index 00000000..09febcca --- /dev/null +++ b/supacodeTests/CanvasLayoutStoreTests.swift @@ -0,0 +1,88 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CanvasLayoutStoreTests { + @Test func loadsLegacyCardLayoutDictionary() throws { + let defaults = makeDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } + let legacyLayouts = [ + "tab-a": CanvasCardLayout(position: CGPoint(x: 10, y: 20), size: CGSize(width: 300, height: 200)), + "tab-b": CanvasCardLayout(position: CGPoint(x: 30, y: 40), size: CGSize(width: 500, height: 400)), + ] + let data = try JSONEncoder().encode(legacyLayouts) + defaults.set(data, forKey: "canvasCardLayouts") + + let store = CanvasLayoutStore(defaults: defaults) + + #expect(store.cardLayouts == legacyLayouts) + #expect(Set(store.zOrder) == Set(legacyLayouts.keys)) + #expect(store.shouldAutoArrangeOnInitialEntry(for: ["tab-a", "tab-b"]) == false) + } + + @Test func skipsInitialAutoArrangeWhenCurrentCardsWereLoadedFromStorage() throws { + let defaults = makeDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } + let store = CanvasLayoutStore(defaults: defaults) + store.setCardLayouts([ + "tab-a": CanvasCardLayout(position: CGPoint(x: 10, y: 20)), + "tab-b": CanvasCardLayout(position: CGPoint(x: 30, y: 40)), + ]) + + let restoredStore = CanvasLayoutStore(defaults: defaults) + + #expect(restoredStore.shouldAutoArrangeOnInitialEntry(for: ["tab-a", "tab-b"]) == false) + #expect(restoredStore.shouldAutoArrangeOnInitialEntry(for: ["tab-c", "tab-d"])) + } + + @Test func moveToFrontPersistsZOrder() { + let defaults = makeDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } + let store = CanvasLayoutStore(defaults: defaults) + store.setCardLayouts( + [ + "tab-a": CanvasCardLayout(position: .zero), + "tab-b": CanvasCardLayout(position: .zero), + ], + zOrder: ["tab-a", "tab-b"] + ) + + store.moveToFront("tab-a") + let restoredStore = CanvasLayoutStore(defaults: defaults) + + #expect(restoredStore.zOrder == ["tab-b", "tab-a"]) + #expect(restoredStore.zIndex(for: "tab-a") > restoredStore.zIndex(for: "tab-b")) + } + + @Test func pruneRemovesStaleLayoutsAndZOrder() { + let defaults = makeDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName(defaults)) } + let store = CanvasLayoutStore(defaults: defaults) + store.setCardLayouts( + [ + "tab-a": CanvasCardLayout(position: .zero), + "tab-b": CanvasCardLayout(position: .zero), + ], + zOrder: ["tab-a", "tab-b"] + ) + + store.prune(to: ["tab-b"]) + + #expect(Array(store.cardLayouts.keys) == ["tab-b"]) + #expect(store.zOrder == ["tab-b"]) + } +} + +private func makeDefaults() -> UserDefaults { + let suiteName = "CanvasLayoutStoreTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(suiteName, forKey: "__suiteName") + return defaults +} + +private func defaultsSuiteName(_ defaults: UserDefaults) -> String { + defaults.string(forKey: "__suiteName")! +} -- 2.51.2 From 13fc410d15b289c5da5698e5da63331e1bed7603 Mon Sep 17 00:00:00 2001 From: Abhishek Maurya <42537316+abhi21git@users.noreply.github.com> Date: Fri, 22 May 2026 13:53:33 +0530 Subject: [PATCH 162/528] UI Enhancements --- .../ActiveAgents/Views/ActiveAgentRow.swift | 67 ++++-- .../Views/ActiveAgentsPanel.swift | 27 +-- .../Views/CommandPaletteOverlayView.swift | 20 +- .../Repositories/Models/TopSegment.swift | 5 + .../Reducer/RepositoriesFeature.swift | 32 +++ .../Views/RepositorySectionView.swift | 14 +- .../Views/SidebarFooterView.swift | 10 - .../Repositories/Views/SidebarListView.swift | 223 +++++------------- .../Views/WorktreeDetailView.swift | 1 + .../Repositories/Views/WorktreeRow.swift | 2 +- .../Repositories/Views/WorktreeRowsView.swift | 7 +- .../Features/Shelf/Views/ShelfSpineView.swift | 1 + .../TabBar/TerminalTabBarMetrics.swift | 6 +- .../TerminalTabBarTrailingAccessories.swift | 6 +- .../TabBar/Views/TerminalTabBarView.swift | 2 +- .../TabBar/Views/TerminalTabLabelView.swift | 1 + .../TabBar/Views/TerminalTabView.swift | 5 +- .../TabBar/Views/TerminalTabsRowView.swift | 4 - .../Views/GhosttySurfaceSearchOverlay.swift | 25 +- .../Ghostty/GhosttySurfaceView.swift | 3 +- 20 files changed, 185 insertions(+), 276 deletions(-) create mode 100644 supacode/Features/Repositories/Models/TopSegment.swift diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 20889cbd..555bfe12 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -78,32 +78,55 @@ struct ActiveAgentRow: View { } struct BaguaWorkingIndicator: View { - static let frames = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"] - static let frameDuration = 0.12 + static let cycleDuration: Double = 1.0 + + static let perimeter: [(row: Int, col: Int)] = [ + (0, 0), (0, 1), (0, 2), + (1, 2), + (2, 2), (2, 1), (2, 0), + (1, 0), + ] var body: some View { - TimelineView(.periodic(from: .now, by: Self.frameDuration)) { context in - frameText(Self.frame(at: context.date)) - } - } + TimelineView(.animation) { context in + let elapsed = context.date.timeIntervalSinceReferenceDate + let phase = + (elapsed / Self.cycleDuration).truncatingRemainder(dividingBy: 1) + * Double(Self.perimeter.count) - static func frame(at date: Date) -> String { - frames[frameIndex(at: date)] + VStack(spacing: 2) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { col in + dot(opacity: opacity(row: row, col: col, phase: phase)) + } + } + } + } + .frame(width: 20, height: 18) + .accessibilityHidden(true) + } } - static func frameIndex(at date: Date) -> Int { - let tick = Int(date.timeIntervalSinceReferenceDate / frameDuration) - let cycleLength = (frames.count * 2) - 2 - let cycleIndex = ((tick % cycleLength) + cycleLength) % cycleLength - return cycleIndex < frames.count ? cycleIndex : cycleLength - cycleIndex + private func opacity(row: Int, col: Int, phase: Double) -> Double { + if row == 1 && col == 1 { return 0.18 } + guard + let index = Self.perimeter.firstIndex(where: { $0.row == row && $0.col == col }) + else { + return 0.18 + } + let count = Double(Self.perimeter.count) + let raw = abs(Double(index) - phase) + let distance = min(raw, count - raw) + let intensity = max(0, 1 - distance / 3) + return 0.18 + intensity * 0.82 } - private func frameText(_ frame: String) -> some View { - Text(frame) - .font(.system(size: 17, weight: .bold, design: .monospaced)) - .lineLimit(1) - .frame(width: 20, height: 18) - .accessibilityHidden(true) + private func dot(opacity: Double) -> some View { + Circle() + .fill(.foreground) + .frame(width: 4, height: 4) + .opacity(opacity) } } @@ -134,3 +157,9 @@ extension AgentDisplayState { } } } + +#Preview { + BaguaWorkingIndicator() + .foregroundStyle(.orange) + .frame(width: 100, height: 100) +} diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index b534644b..0ed7a856 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -13,6 +13,7 @@ struct ActiveAgentsPanel: View { let onHeightChanged: (Double) -> Void let onHeightChangeEnded: (Double) -> Void @State private var dragStartHeight: Double? + @State private var dragIndicatorPillOpacity: CGFloat = 0.4 var body: some View { VStack(spacing: 0) { @@ -61,10 +62,6 @@ struct ActiveAgentsPanel: View { .fill(.thinMaterial) } .clipShape(panelBackgroundShape) - .overlay { - panelBackgroundShape - .stroke(.separator.opacity(0.7), lineWidth: 1) - } } private var resizeHandle: some View { @@ -73,10 +70,10 @@ struct ActiveAgentsPanel: View { .frame(height: 1) .frame(maxWidth: .infinity) .overlay(alignment: .top) { - Rectangle() - .fill(.separator.opacity(0.7)) - .frame(height: 1) - .padding(.horizontal, 8) + Capsule() + .fill(.separator.opacity(dragIndicatorPillOpacity)) + .frame(width: 32, height: 4) + .padding(.vertical, 4) } .overlay { Rectangle() @@ -90,12 +87,14 @@ struct ActiveAgentsPanel: View { let start = dragStartHeight ?? height dragStartHeight = start onHeightChanged(clampedHeight(start - value.translation.height)) + dragIndicatorPillOpacity = 0.8 } .onEnded { value in let start = dragStartHeight ?? height let height = clampedHeight(start - value.translation.height) dragStartHeight = nil onHeightChangeEnded(height) + dragIndicatorPillOpacity = 0.4 } ) .onHover { hovering in @@ -135,15 +134,7 @@ struct ActiveAgentsPanel: View { return trimmed.isEmpty ? "Untitled tab" : trimmed } - private var panelBackgroundShape: UnevenRoundedRectangle { - UnevenRoundedRectangle( - cornerRadii: .init( - topLeading: 8, - bottomLeading: 0, - bottomTrailing: 0, - topTrailing: 8 - ), - style: .continuous - ) + private var panelBackgroundShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 14) } } diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 0837dc43..bf288ae2 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -230,20 +230,7 @@ private struct CommandPaletteCard: View { } } .frame(maxWidth: 500) - .background( - ZStack { - Rectangle().fill(.ultraThinMaterial) - Rectangle() - .fill(backgroundColor) - .blendMode(.color) - } - .compositingGroup() - ) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color(nsColor: .tertiaryLabelColor).opacity(0.75)) - ) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 14)) .shadow(radius: 32, x: 0, y: 12) .padding(Self.padding) .environment(\.colorScheme, colorScheme) @@ -696,9 +683,8 @@ private struct CommandPaletteRowView: View { } } .padding(8) - .contentShape(Rectangle()) .background(rowBackground) - .clipShape(.rect(cornerRadius: 5)) + .clipShape(.rect(cornerRadius: 14)) } .buttonStyle(.plain) .help(helpText) @@ -712,7 +698,7 @@ private struct CommandPaletteRowView: View { if isSelected { Color(nsColor: .selectedContentBackgroundColor) } else if hoveredID == row.id { - Color(nsColor: .unemphasizedSelectedContentBackgroundColor) + Color(nsColor: .unemphasizedSelectedContentBackgroundColor).opacity(0.7) } else { Color.clear } diff --git a/supacode/Features/Repositories/Models/TopSegment.swift b/supacode/Features/Repositories/Models/TopSegment.swift new file mode 100644 index 00000000..5eb1a286 --- /dev/null +++ b/supacode/Features/Repositories/Models/TopSegment.swift @@ -0,0 +1,5 @@ +enum TopSegment: Hashable { + case tabbed + case canvas + case shelf +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 330f86ba..97106a29 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -304,6 +304,9 @@ struct RepositoriesFeature { case codeHostsDetected([Repository.ID: CodeHost]) case selectArchivedWorktrees case selectCanvas + case selectShelf + case selectTabbed + case setTopSegment(TopSegment) case toggleCanvas case toggleShelf case selectNextShelfBook @@ -746,6 +749,29 @@ struct RepositoriesFeature { await terminalClient.send(.setCanvasMode(true)) } + case .selectShelf: + guard !state.isShelfActive else { return .none } + return .send(.toggleShelf) + + case .selectTabbed: + if state.isShowingCanvas { + return .send(.toggleCanvas) + } + if state.isShowingShelf { + return .send(.toggleShelf) + } + return .none + + case .setTopSegment(let segment): + switch segment { + case .tabbed: + return .send(.selectTabbed) + case .canvas: + return .send(.selectCanvas) + case .shelf: + return .send(.selectShelf) + } + case .toggleCanvas: if state.isShowingCanvas { // Exit canvas: prefer the card focused in canvas, then the worktree @@ -1683,6 +1709,12 @@ extension RepositoriesFeature.State { isShelfActive } + var topSegment: TopSegment { + if isShowingCanvas { return .canvas } + if isShowingShelf { return .shelf } + return .tabbed + } + private var canUseWorktreeHistory: Bool { // History navigation only makes sense when a worktree is the current // anchor. With selection on `.repository`, `.archivedWorktrees`, or `nil`, diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 45102122..46175907 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -86,13 +86,6 @@ struct RepositorySectionView: View { } } } - if let color = appearance.color { - Circle() - .fill(color.color) - .frame(width: 8, height: 8) - .help(color.displayName) - .accessibilityLabel(Text("Repo color: \(color.displayName)")) - } if isHovering { Menu { Button("Repo Settings") { @@ -179,6 +172,13 @@ struct RepositorySectionView: View { .help(isExpanded ? "Collapse" : "Expand") } } + if let color = appearance.color { + Circle() + .glassEffect(.regular.tint(color.color)) + .frame(width: 8, height: 8) + .help(color.displayName) + .accessibilityLabel(Text("Repo color: \(color.displayName)")) + } } .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) .padding(.horizontal, 12) diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index e141e0c6..24664889 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -94,16 +94,6 @@ struct SidebarFooterView: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background { - if surfaceBottomChromeBackgroundOpacity < 1 { - Rectangle().fill(.regularMaterial) - } else { - Color(nsColor: .windowBackgroundColor) - } - } - .overlay(alignment: .top) { - Divider() - } } static func activeAgentsPanelIconName(isPanelHidden: Bool) -> String { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 27b692a6..ee8335ba 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -5,33 +5,6 @@ import SwiftUI // Uses LazyVStack rather than List for repository drag precision; keyboard // worktree navigation goes through Cmd+Ctrl+↑/↓ (`selectNextWorktree`). struct SidebarListView: View { - enum RepositoryListHeaderAction: Equatable { - case expandAll - case collapseAll - - var title: String { - switch self { - case .expandAll: - return "Expand All" - case .collapseAll: - return "Collapse All" - } - } - - var systemImageName: String { - "chevron.right" - } - - var rotation: Angle { - switch self { - case .expandAll: - return .zero - case .collapseAll: - return .degrees(90) - } - } - } - @Bindable var store: StoreOf @Binding var expandedRepoIDs: Set @Binding var sidebarSelections: Set @@ -48,18 +21,7 @@ struct SidebarListView: View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) - let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) - let repositoryListHeaderAction = Self.repositoryListHeaderAction( - expandedRepoIDs: expandedRepoIDs, - expandableRepositoryIDs: expandableRepositoryIDs - ) let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) - let showsRepositoryListHeader = presentation.items.contains { item in - if case .listHeader = item { - return true - } - return false - } let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let selectedSurfaceID = state.selectedWorktreeID.flatMap { worktreeID in terminalManager.stateIfExists(for: worktreeID)?.activeSurfaceID @@ -82,26 +44,31 @@ struct SidebarListView: View { ScrollViewReader { scrollProxy in ScrollView { - LazyVStack(spacing: 0) { - if showsRepositoryListHeader { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } - - if repositoryItems.isEmpty { - emptyRepositoryHint() - } - - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) + LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { + Section { + if repositoryItems.isEmpty { + emptyRepositoryHint() + } + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + } + } header: { + Picker("", selection: $store.topSegment.sending(\.setTopSegment)) { + Text("Tabbed").tag(TopSegment.tabbed) + Text("Canvas").tag(TopSegment.canvas) + Text("Shelf").tag(TopSegment.shelf) + } + .pickerStyle(.segmented) + .controlSize(.large) + .labelsHidden() + .padding(.horizontal) + .padding(.vertical, 4) } } .padding(.vertical, 2) @@ -109,7 +76,6 @@ struct SidebarListView: View { } .scrollIndicators(.never) .frame(minWidth: 220) - .background(.bar) .clipped() .onGeometryChange(for: Double.self) { proxy in Double(proxy.size.height) @@ -125,24 +91,6 @@ struct SidebarListView: View { endSidebarDrag() } } - .safeAreaInset(edge: .top, spacing: 0) { - HStack(spacing: 4) { - CanvasSidebarButton( - store: store, - isSelected: state.isShowingCanvas - ) - ShelfSidebarButton( - store: store, - isSelected: state.isShowingShelf - ) - } - .padding(.top, 4) - .padding(.horizontal, 4) - .background(.bar) - .overlay(alignment: .bottom) { - Divider() - } - } .safeAreaInset(edge: .bottom, spacing: 0) { SidebarFooterView(store: store) .onGeometryChange(for: Double.self) { proxy in @@ -150,29 +98,28 @@ struct SidebarListView: View { } action: { newHeight in sidebarFooterHeight = newHeight } + .padding(.vertical, 4) } .overlay(alignment: .bottom) { - ZStack(alignment: .bottom) { - ActiveAgentsPanel( - store: store.scope(state: \.activeAgents, action: \.activeAgents), - repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, - branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, - repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, - selectedSurfaceID: selectedSurfaceID, - height: panelHeight, - maximumHeight: maximumPanelHeight, - onHeightChanged: { height in - resizingPanelHeight = height - }, - onHeightChangeEnded: { height in - resizingPanelHeight = nil - store.send(.activeAgents(.panelHeightChanged(height))) - } - ) - .frame(height: panelHeight) - .offset(y: panelOffset) - } + ActiveAgentsPanel( + store: store.scope(state: \.activeAgents, action: \.activeAgents), + repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, + branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, + repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, + selectedSurfaceID: selectedSurfaceID, + height: panelHeight, + maximumHeight: maximumPanelHeight, + onHeightChanged: { height in + resizingPanelHeight = height + }, + onHeightChangeEnded: { height in + resizingPanelHeight = nil + store.send(.activeAgents(.panelHeightChanged(height))) + } + ) + .padding(8) .frame(height: panelHeight) + .offset(y: panelOffset) .clipped() .padding(.bottom, sidebarFooterHeight) .allowsHitTesting(!state.activeAgents.isPanelHidden) @@ -190,6 +137,16 @@ struct SidebarListView: View { .task(id: pendingSidebarReveal?.id) { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + store.send(.setOpenPanelPresented(true)) + } label: { + Label("Add Repository", systemImage: "folder.badge.plus") + } + .help("Add Repository") + } + } } // ScrollViewReader } @@ -206,55 +163,6 @@ struct SidebarListView: View { } } - private func repositoryListHeader( - action: RepositoryListHeaderAction, - expandableRepositoryIDs: Set - ) -> some View { - HStack(spacing: 4) { - Text("Repositories") - .font(.caption) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - store.send(.setOpenPanelPresented(true)) - } label: { - Label("Add Repository", systemImage: "plus") - .labelStyle(.iconOnly) - .frame(width: 20, height: 20) - .contentShape(.rect) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("Add Repository") - if !expandableRepositoryIDs.isEmpty { - Button { - withAnimation(.easeOut(duration: 0.2)) { - switch action { - case .expandAll: - expandedRepoIDs.formUnion(expandableRepositoryIDs) - case .collapseAll: - expandedRepoIDs.subtract(expandableRepositoryIDs) - } - } - } label: { - Label(action.title, systemImage: action.systemImageName) - .labelStyle(.iconOnly) - .frame(width: 20, height: 20) - .rotationEffect(action.rotation) - .contentShape(.rect) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help(action.title) - } - } - .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) - .padding(.leading, 12) - .padding(.trailing, 7) - .padding(.top, 2) - .padding(.bottom, 4) - } - private func emptyRepositoryHint() -> some View { HStack(spacing: 6) { Spacer(minLength: 0) @@ -411,29 +319,6 @@ struct SidebarListView: View { store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } - static func expandableRepositoryIDs( - in repositories: Repositories - ) -> Set where Repositories.Element == Repository { - Set( - repositories - .filter(\.capabilities.supportsWorktrees) - .map(\.id) - ) - } - - static func repositoryListHeaderAction( - expandedRepoIDs: Set, - expandableRepositoryIDs: Set - ) -> RepositoryListHeaderAction { - !expandedRepoIDs.isDisjoint(with: expandableRepositoryIDs) - ? .collapseAll - : .expandAll - } - - static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { - SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) - } - static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set { var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs if let selectedWorktreeID = state.selectedWorktreeID { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 7aaad6d7..9aeecb3f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -64,6 +64,7 @@ struct WorktreeDetailView: View { ) .navigationTitle(WindowTitle.compute(repositories: repositories, terminalManager: terminalManager)) .toolbar(removing: repositories.isShowingCanvas ? nil : .title) + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) .toolbar { if repositories.isShowingCanvas { canvasToolbarContent( diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index ce3d92df..89807af8 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -338,7 +338,7 @@ private struct WorktreeRowChangeCountView: View { .padding(.vertical, 0) .fixedSize(horizontal: true, vertical: false) .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) + Capsule() .stroke(isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) } .monospacedDigit() diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 1889c27b..3c0cc2e4 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -367,18 +367,19 @@ struct WorktreeRowsView: View { .padding(.trailing, 8) .background { if isSelected { - RoundedRectangle(cornerRadius: 5) + RoundedRectangle(cornerRadius: 8) .fill(Color.accentColor.opacity(0.18)) .padding(.horizontal, 6) } } .overlay { if showsContextMenuHighlight { - RoundedRectangle(cornerRadius: 5) - .stroke(Color.accentColor, lineWidth: 2) + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 1) .padding(.horizontal, 6) } } + .padding(.vertical, 2) .transition(.opacity) .moveDisabled(config.moveDisabled) } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 07f14b51..2f6fdb7b 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -68,6 +68,7 @@ struct ShelfSpineView: View { // if/else did). Fill is derived from a stepped accent-alpha ladder // so the open book glows strongest and neighbors fade outward. Rectangle().fill(spineBackgroundColor) + .ignoresSafeArea(edges: .all) ) // Whole-spine tap target. Inner Buttons (header, tab slots, controls) // absorb their own clicks; clicks that fall on empty areas (scroll diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 78e30e74..39b226fd 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -1,13 +1,13 @@ import CoreGraphics enum TerminalTabBarMetrics { - static let barHeight: CGFloat = 33 + static let barHeight: CGFloat = 34 static let barPadding: CGFloat = 0 static let tabHeight: CGFloat = 32 static let tabMinWidth: CGFloat = 140 static let tabMaxWidth: CGFloat = 220 - static let tabCornerRadius: CGFloat = 0 - static let tabSpacing: CGFloat = 0 + static let tabCornerRadius: CGFloat = 4 + static let tabSpacing: CGFloat = 2 static let tabHorizontalPadding: CGFloat = 12 static let contentSpacing: CGFloat = 6 static let contentTrailingSpacing: CGFloat = 4 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift index 495cf5d3..0506d901 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -35,8 +35,10 @@ struct TerminalTabBarTrailingAccessories: View { ) .disabled(!canSplit) } - .frame(height: TerminalTabBarMetrics.barHeight) - .padding(.trailing, 8) + .frame(height: TerminalTabBarMetrics.barHeight - 4) + .padding(.horizontal, 8) + .glassEffect(.regular, in: Capsule()) + .padding(.vertical, 2) } private var newTabButton: some View { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index e01cf0db..b2ba85fb 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -37,8 +37,8 @@ struct TerminalTabBarView: View { ) } .frame(height: TerminalTabBarMetrics.barHeight) - .background(TerminalTabBarBackground()) .saturation(activeState == .inactive ? 0 : 1) + .padding(.horizontal, 4) .clipped() } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 1c68ac1e..16247bae 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -13,6 +13,7 @@ struct TerminalTabLabelView: View { if tab.isDirty || tab.icon != nil { TerminalTabIconBadge(tab: tab, isActive: isActive) } + Spacer(minLength: TerminalTabBarMetrics.contentTrailingSpacing) Text(tab.displayTitle) .font(.caption) .lineLimit(1) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 443b8d78..bc6e8a4b 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -119,8 +119,9 @@ struct TerminalTabView: View { } .padding(.bottom, isActive ? TerminalTabBarMetrics.activeTabBottomPadding : 0) .offset(y: isActive ? TerminalTabBarMetrics.activeTabOffset : 0) - .clipShape(.rect(cornerRadius: TerminalTabBarMetrics.tabCornerRadius)) - .contentShape(.rect) + .clipShape(.capsule) + .contentShape(.capsule) + .glassEffect() .onHover { hovering in isHovering = hovering } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index 6687a301..6eb971f3 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -76,10 +76,6 @@ struct TerminalTabsRowView: View { ) ) .id(id) - - if index < openedTabs.count - 1 { - TerminalTabDivider() - } } } } diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 50fa648d..8ab37e08 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -41,7 +41,7 @@ struct GhosttySurfaceSearchOverlay: View { .padding(.trailing, 50) .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) - .clipShape(.rect(cornerRadius: 6)) + .clipShape(.capsule) .overlay(alignment: .trailing) { matchLabel } @@ -81,7 +81,7 @@ struct GhosttySurfaceSearchOverlay: View { } .padding(8) .background(.background) - .clipShape(GhosttySearchOverlayShape()) + .clipShape(.capsule) .shadow(radius: 4) .background( GeometryReader { barGeo in @@ -249,10 +249,7 @@ private enum GhosttySearchCorner { private struct GhosttySearchOverlayShape: Shape { func path(in rect: CGRect) -> Path { - if #available(macOS 26.0, *) { - return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true).path(in: rect) - } - return RoundedRectangle(cornerRadius: 8).path(in: rect) + ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true).path(in: rect) } } @@ -262,16 +259,8 @@ private struct SearchButtonLabel: View { let systemImage: String var body: some View { - Label { - if let shortcut { - Text("\(title) \(Text("(\(shortcut))").foregroundColor(.secondary.opacity(0.7)))") - } else { - Text(title) - } - } icon: { - Image(systemName: systemImage) - .accessibilityHidden(true) - } + Image(systemName: systemImage) + .accessibilityHidden(true) } } @@ -352,10 +341,10 @@ private struct GhosttySearchButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) - .padding(.horizontal, 2) + .padding(.horizontal, 6) .frame(height: 26) .background( - RoundedRectangle(cornerRadius: 6) + Capsule() .fill(backgroundColor(isPressed: configuration.isPressed)) ) .onHover { hovering in diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 0c297488..d955d1f9 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -493,9 +493,9 @@ final class GhosttySurfaceView: NSView, Identifiable { private func applyWindowBackgroundAppearance() { guard let window, window.isVisible else { return } let opacity = runtime.backgroundOpacity() + window.titlebarAppearsTransparent = true if !isBackgroundOpaqueOverride, !window.styleMask.contains(.fullScreen), opacity < 1 { window.isOpaque = false - window.titlebarAppearsTransparent = true let isDark = window.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua window.backgroundColor = GhosttyRuntime.chromeBackgroundColor(for: isDark ? .black : .white) if let app = runtime.app { @@ -507,7 +507,6 @@ final class GhosttySurfaceView: NSView, Identifiable { return } window.isOpaque = true - window.titlebarAppearsTransparent = false window.backgroundColor = runtime.backgroundColor().withAlphaComponent(1) } -- 2.51.2 From b47be417829d194ff6dddf9a0cc0babbabe22f0f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 22 May 2026 23:08:55 +0900 Subject: [PATCH 163/528] Restore Expand/Collapse All in sidebar repo header PR #331 dropped the repository list header (and its Expand/Collapse All control) when it moved Add Repository into the sidebar toolbar. Bring the control back as a "Repositories" header row above the repository list, below the new view-mode segmented picker. Also fix the test targets the PR left uncompilable: - Restore SidebarListView.expandableRepositoryIDs / repositoryListHeaderAction and the RepositoryListHeaderAction enum. - Extract BaguaWorkingIndicator.opacity(row:col:phase:) to a testable static func and rewrite BaguaWorkingIndicatorTests for the new dot-matrix pulse. - Drop the obsolete sidebarHeaderAlwaysShows test (list-header gating is now replaced by the always-present segmented picker). --- .../ActiveAgents/Views/ActiveAgentRow.swift | 18 ++-- .../Repositories/Views/SidebarListView.swift | 96 ++++++++++++++++++- .../BaguaWorkingIndicatorTests.swift | 52 ++++++++-- .../RepositorySectionViewTests.swift | 6 -- 4 files changed, 148 insertions(+), 24 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 555bfe12..52d11041 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -98,7 +98,7 @@ struct BaguaWorkingIndicator: View { ForEach(0..<3, id: \.self) { row in HStack(spacing: 2) { ForEach(0..<3, id: \.self) { col in - dot(opacity: opacity(row: row, col: col, phase: phase)) + dot(opacity: Self.opacity(row: row, col: col, phase: phase)) } } } @@ -108,18 +108,22 @@ struct BaguaWorkingIndicator: View { } } - private func opacity(row: Int, col: Int, phase: Double) -> Double { - if row == 1 && col == 1 { return 0.18 } + // Dimmest opacity for off-pulse dots (and the always-static center). + static let baseOpacity: Double = 0.18 + + static func opacity(row: Int, col: Int, phase: Double) -> Double { + if row == 1 && col == 1 { return baseOpacity } guard - let index = Self.perimeter.firstIndex(where: { $0.row == row && $0.col == col }) + let index = perimeter.firstIndex(where: { $0.row == row && $0.col == col }) else { - return 0.18 + return baseOpacity } - let count = Double(Self.perimeter.count) + let count = Double(perimeter.count) let raw = abs(Double(index) - phase) + // Wrap the distance so the pulse stays continuous across the seam. let distance = min(raw, count - raw) let intensity = max(0, 1 - distance / 3) - return 0.18 + intensity * 0.82 + return baseOpacity + intensity * (1 - baseOpacity) } private func dot(opacity: Double) -> some View { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index ee8335ba..9b700273 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -5,6 +5,33 @@ import SwiftUI // Uses LazyVStack rather than List for repository drag precision; keyboard // worktree navigation goes through Cmd+Ctrl+↑/↓ (`selectNextWorktree`). struct SidebarListView: View { + enum RepositoryListHeaderAction: Equatable { + case expandAll + case collapseAll + + var title: String { + switch self { + case .expandAll: + return "Expand All" + case .collapseAll: + return "Collapse All" + } + } + + var systemImageName: String { + "chevron.right" + } + + var rotation: Angle { + switch self { + case .expandAll: + return .zero + case .collapseAll: + return .degrees(90) + } + } + } + @Bindable var store: StoreOf @Binding var expandedRepoIDs: Set @Binding var sidebarSelections: Set @@ -21,6 +48,11 @@ struct SidebarListView: View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) + let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) + let repositoryListHeaderAction = Self.repositoryListHeaderAction( + expandedRepoIDs: expandedRepoIDs, + expandableRepositoryIDs: expandableRepositoryIDs + ) let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let selectedSurfaceID = state.selectedWorktreeID.flatMap { worktreeID in @@ -48,6 +80,11 @@ struct SidebarListView: View { Section { if repositoryItems.isEmpty { emptyRepositoryHint() + } else { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) } ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in repositoryItemView( @@ -163,6 +200,44 @@ struct SidebarListView: View { } } + private func repositoryListHeader( + action: RepositoryListHeaderAction, + expandableRepositoryIDs: Set + ) -> some View { + HStack(spacing: 4) { + Text("Repositories") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + if !expandableRepositoryIDs.isEmpty { + Button { + withAnimation(.easeOut(duration: 0.2)) { + switch action { + case .expandAll: + expandedRepoIDs.formUnion(expandableRepositoryIDs) + case .collapseAll: + expandedRepoIDs.subtract(expandableRepositoryIDs) + } + } + } label: { + Label(action.title, systemImage: action.systemImageName) + .labelStyle(.iconOnly) + .frame(width: 20, height: 20) + .rotationEffect(action.rotation) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(action.title) + } + } + .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) + .padding(.leading, 12) + .padding(.trailing, 7) + .padding(.top, 2) + .padding(.bottom, 4) + } + private func emptyRepositoryHint() -> some View { HStack(spacing: 6) { Spacer(minLength: 0) @@ -319,6 +394,25 @@ struct SidebarListView: View { store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } + static func expandableRepositoryIDs( + in repositories: Repositories + ) -> Set where Repositories.Element == Repository { + Set( + repositories + .filter(\.capabilities.supportsWorktrees) + .map(\.id) + ) + } + + static func repositoryListHeaderAction( + expandedRepoIDs: Set, + expandableRepositoryIDs: Set + ) -> RepositoryListHeaderAction { + !expandedRepoIDs.isDisjoint(with: expandableRepositoryIDs) + ? .collapseAll + : .expandAll + } + static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set { var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs if let selectedWorktreeID = state.selectedWorktreeID { @@ -399,7 +493,7 @@ extension SidebarItem { terminalManager: terminalManager ) .environment(CommandKeyObserver()) - .frame(width: 280, height: 500) + .frame(width: 320, height: 500) } private static var mockState: RepositoriesFeature.State { diff --git a/supacodeTests/BaguaWorkingIndicatorTests.swift b/supacodeTests/BaguaWorkingIndicatorTests.swift index e221d2bd..f8a51d5a 100644 --- a/supacodeTests/BaguaWorkingIndicatorTests.swift +++ b/supacodeTests/BaguaWorkingIndicatorTests.swift @@ -4,18 +4,50 @@ import Testing @testable import supacode struct BaguaWorkingIndicatorTests { - @Test func framesUseFullTrigramSequence() { - #expect(BaguaWorkingIndicator.frames == ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"]) + @Test func perimeterTracesTheRingClockwise() { + #expect( + BaguaWorkingIndicator.perimeter.map { [$0.row, $0.col] } + == [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [2, 0], [1, 0]] + ) } - @Test func frameSelectionLoopsByDuration() { - let duration = BaguaWorkingIndicator.frameDuration + @Test func centerDotStaysAtBaseOpacity() { + // The middle cell is never part of the pulse, regardless of phase. + #expect(BaguaWorkingIndicator.opacity(row: 1, col: 1, phase: 0) == BaguaWorkingIndicator.baseOpacity) + #expect(BaguaWorkingIndicator.opacity(row: 1, col: 1, phase: 4) == BaguaWorkingIndicator.baseOpacity) + } + + @Test func pulseHeadIsFullyLit() { + // When phase aligns with a perimeter index, that dot reaches full intensity. + let head = BaguaWorkingIndicator.perimeter[0] + #expect(BaguaWorkingIndicator.opacity(row: head.row, col: head.col, phase: 0) == 1) + } + + @Test func opacityFallsOffWithPerimeterDistance() { + // Neighbors of the pulse head are dimmer, and dots far away sit at base. + let head = BaguaWorkingIndicator.perimeter[0] + let neighbor = BaguaWorkingIndicator.perimeter[1] + let opposite = BaguaWorkingIndicator.perimeter[4] + + let headOpacity = BaguaWorkingIndicator.opacity(row: head.row, col: head.col, phase: 0) + let neighborOpacity = BaguaWorkingIndicator.opacity(row: neighbor.row, col: neighbor.col, phase: 0) + let oppositeOpacity = BaguaWorkingIndicator.opacity(row: opposite.row, col: opposite.col, phase: 0) + + #expect(headOpacity > neighborOpacity) + #expect(neighborOpacity > oppositeOpacity) + #expect(oppositeOpacity == BaguaWorkingIndicator.baseOpacity) + } - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: 0)) == "☰") - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration)) == "☱") - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 7)) == "☷") - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 8)) == "☶") - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 13)) == "☱") - #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 14)) == "☰") + @Test func pulseWrapsAcrossTheSeam() { + // Distance is measured the short way around, so the last dot lights up + // as the pulse approaches the wrap point from index 0. + let last = BaguaWorkingIndicator.perimeter.count - 1 + let lastDot = BaguaWorkingIndicator.perimeter[last] + let opacityNearSeam = BaguaWorkingIndicator.opacity( + row: lastDot.row, + col: lastDot.col, + phase: 0 + ) + #expect(opacityNearSeam > BaguaWorkingIndicator.baseOpacity) } } diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index c3b68796..e729fce4 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -63,12 +63,6 @@ struct RepositorySectionViewTests { ) } - @Test func sidebarHeaderAlwaysShows() { - #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 0)) - #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 1)) - #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) - } - @Test func explicitSelectionIncludesPrimarySelectedWorktree() { let worktree = Worktree( id: "/tmp/repo/wt", -- 2.51.2 From 8506786290f1b3a1e3f874e04bc73ec8f9ea05fa Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 00:19:09 +0900 Subject: [PATCH 164/528] Rework sidebar view-mode switcher into a fixed top bar Move the Tabbed/Canvas/Shelf switcher out of the scrolling section header (which bounced on overscroll and let repo rows show through) into a fixed top safe-area-inset bar. Replace the system .segmented Picker with a custom equal-width control: the system control renders only bare icon/text and ignores option layout, so it cannot be widened to fill the sidebar. Equal-width buttons in a material capsule track fill the width with a matched-geometry sliding selection pill. Canvas/Shelf tooltips now include their resolved keyboard shortcuts via AppShortcuts.helpText. --- .../Repositories/Views/SidebarListView.swift | 112 +++++++++++++----- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 9b700273..a16ec546 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -42,6 +42,8 @@ struct SidebarListView: View { @State private var sidebarHeight = 0.0 @State private var sidebarFooterHeight = 0.0 @State private var resizingPanelHeight: Double? + @Namespace private var topSegmentNamespace + @Environment(\.resolvedKeybindings) private var resolvedKeybindings @Shared(.repositoryAppearances) private var repositoryAppearances var body: some View { @@ -76,36 +78,23 @@ struct SidebarListView: View { ScrollViewReader { scrollProxy in ScrollView { - LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { - Section { - if repositoryItems.isEmpty { - emptyRepositoryHint() - } else { - repositoryListHeader( - action: repositoryListHeaderAction, - expandableRepositoryIDs: expandableRepositoryIDs - ) - } - ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in - repositoryItemView( - item, - index: index, - repositoryOrderIDs: presentation.repositoryOrderIDs, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs - ) - } - } header: { - Picker("", selection: $store.topSegment.sending(\.setTopSegment)) { - Text("Tabbed").tag(TopSegment.tabbed) - Text("Canvas").tag(TopSegment.canvas) - Text("Shelf").tag(TopSegment.shelf) - } - .pickerStyle(.segmented) - .controlSize(.large) - .labelsHidden() - .padding(.horizontal) - .padding(.vertical, 4) + LazyVStack(spacing: 0) { + if repositoryItems.isEmpty { + emptyRepositoryHint() + } else { + repositoryListHeader( + action: repositoryListHeaderAction, + expandableRepositoryIDs: expandableRepositoryIDs + ) + } + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + repositoryOrderIDs: presentation.repositoryOrderIDs, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) } } .padding(.vertical, 2) @@ -128,6 +117,9 @@ struct SidebarListView: View { endSidebarDrag() } } + .safeAreaInset(edge: .top, spacing: 0) { + topSegmentBar + } .safeAreaInset(edge: .bottom, spacing: 0) { SidebarFooterView(store: store) .onGeometryChange(for: Double.self) { proxy in @@ -187,6 +179,66 @@ struct SidebarListView: View { } // ScrollViewReader } + // Fixed, opaque top bar (Xcode-navigator style): stays put while the list + // scrolls underneath, so it neither bounces with the scroll nor lets repo + // rows show through. Custom segmented control because the system .segmented + // Picker only renders bare icon/text and ignores option layout, so it can't + // be widened to fill; equal-width buttons inside a glass capsule track give + // the macOS 26 look while truly filling the width. + private var topSegmentBar: some View { + HStack(spacing: 4) { + topSegmentButton(.tabbed, systemImage: "checklist.unchecked", title: "Default") + topSegmentButton( + .canvas, + systemImage: "square.grid.2x2", + title: "Canvas", + shortcutCommandID: AppShortcuts.CommandID.toggleCanvas + ) + topSegmentButton( + .shelf, + systemImage: "distribute.horizontal.fill", + title: "Shelf", + shortcutCommandID: AppShortcuts.CommandID.toggleShelf + ) + } + .background(.thinMaterial, in: Capsule()) + .padding(.horizontal, 8) + .padding(.vertical, 2) + } + + private func topSegmentButton( + _ segment: TopSegment, + systemImage: String, + title: String, + shortcutCommandID: String? = nil + ) -> some View { + let isSelected = store.topSegment == segment + let helpText = + shortcutCommandID.map { + AppShortcuts.helpText(title: title, commandID: $0, in: resolvedKeybindings) + } ?? title + return Button { + store.send(.setTopSegment(segment)) + } label: { + Image(systemName: systemImage) + .accessibilityHidden(true) + .frame(maxWidth: .infinity) + .frame(height: 22) + .foregroundStyle(isSelected ? AnyShapeStyle(Color.white) : AnyShapeStyle(.secondary)) + .background { + if isSelected { + Capsule() + .fill(Color.accentColor) + .matchedGeometryEffect(id: "topSegmentPill", in: topSegmentNamespace) + } + } + .contentShape(.capsule) + } + .buttonStyle(.plain) + .help(helpText) + .accessibilityLabel(Text(title)) + } + private func focusTerminalAfterSidebarSelection(worktreeID: Worktree.ID?) { guard let worktreeID else { return } Task { @MainActor [terminalManager] in -- 2.51.2 From 6bfe1685a376cdbc2c2b954fd0ab0275e9e580c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 00:28:10 +0900 Subject: [PATCH 165/528] Keep repo color dot solid so it never grays out on focus loss The repo color dot used .glassEffect(.regular.tint(...)), but glass/vibrant materials desaturate to gray when the window is not key, dropping the dot's color whenever the window loses focus. Use a solid .fill(color.color) so the color is preserved regardless of window focus. --- .../Features/Repositories/Views/RepositorySectionView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 46175907..a9d1bc1e 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -173,8 +173,11 @@ struct RepositorySectionView: View { } } if let color = appearance.color { + // Solid fill (not .glassEffect): glass/vibrant materials desaturate to + // gray when the window is not key, but the repo color dot must keep its + // color regardless of focus. Circle() - .glassEffect(.regular.tint(color.color)) + .fill(color.color) .frame(width: 8, height: 8) .help(color.displayName) .accessibilityLabel(Text("Repo color: \(color.displayName)")) -- 2.51.2 From d027ea7add37493a6a2fe9ee5df6314bcaf0c885 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 00:36:42 +0900 Subject: [PATCH 166/528] Revert PR #331 tab bar reshape; adopt #327 brightness ladder PR #331 reshaped the terminal tab bar (glass capsule tabs, removed bar background and dividers, changed corner & spacing metrics), which was not the preferred look. Restore the tab bar structure, layout, and metrics to their pre-#331 state, and convey tab selection via the appearance-aware brightness ladder from #327 (TerminalTabBarColors): a labelColor-based bar < inactive < hovered < active ladder in dark mode, with the original native look preserved in light mode. --- .../TabBar/TerminalTabBarColors.swift | 29 +++++++++++++++++-- .../TabBar/TerminalTabBarMetrics.swift | 6 ++-- .../TerminalTabBarTrailingAccessories.swift | 6 ++-- .../TabBar/Views/TerminalTabBarView.swift | 2 +- .../TabBar/Views/TerminalTabLabelView.swift | 1 - .../TabBar/Views/TerminalTabView.swift | 5 ++-- .../TabBar/Views/TerminalTabsRowView.swift | 4 +++ 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift index 67c6f139..ec375851 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift @@ -6,16 +6,39 @@ enum TerminalTabBarColors { Color(nsColor: .windowBackgroundColor) } + // Selection is conveyed by a brightness ladder layered over `barBackground`, + // but only in dark mode. There, `controlBackgroundColor` is actually *darker* + // than `windowBackgroundColor`, so the old selection sank into the bar; a + // `labelColor` (≈ `Color.primary`) tint brightens reliably instead, keeping + // bar < inactive < hovered < active distinct. In light mode that same tint + // would *darken* the tab and read unnaturally, so we keep the original system + // appearance: a white tab floating on the gray bar. static var activeTabBackground: Color { - Color(nsColor: .controlBackgroundColor) + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.14) }, light: { .controlBackgroundColor }) } static var hoveredTabBackground: Color { - Color(nsColor: .controlBackgroundColor).opacity(0.5) + adaptiveFill( + dark: { .labelColor.withAlphaComponent(0.08) }, + light: { .controlBackgroundColor.withAlphaComponent(0.5) } + ) } static var inactiveTabBackground: Color { - .clear + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.035) }, light: { .clear }) + } + + /// Resolves to `dark` under Dark Aqua and `light` otherwise, wrapped in a + /// dynamic `NSColor` so callers stay appearance-agnostic. + private static func adaptiveFill( + dark: @escaping () -> NSColor, + light: @escaping () -> NSColor + ) -> Color { + Color( + nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark() : light() + } + ) } static var activeText: Color { diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 39b226fd..78e30e74 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -1,13 +1,13 @@ import CoreGraphics enum TerminalTabBarMetrics { - static let barHeight: CGFloat = 34 + static let barHeight: CGFloat = 33 static let barPadding: CGFloat = 0 static let tabHeight: CGFloat = 32 static let tabMinWidth: CGFloat = 140 static let tabMaxWidth: CGFloat = 220 - static let tabCornerRadius: CGFloat = 4 - static let tabSpacing: CGFloat = 2 + static let tabCornerRadius: CGFloat = 0 + static let tabSpacing: CGFloat = 0 static let tabHorizontalPadding: CGFloat = 12 static let contentSpacing: CGFloat = 6 static let contentTrailingSpacing: CGFloat = 4 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift index 0506d901..495cf5d3 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -35,10 +35,8 @@ struct TerminalTabBarTrailingAccessories: View { ) .disabled(!canSplit) } - .frame(height: TerminalTabBarMetrics.barHeight - 4) - .padding(.horizontal, 8) - .glassEffect(.regular, in: Capsule()) - .padding(.vertical, 2) + .frame(height: TerminalTabBarMetrics.barHeight) + .padding(.trailing, 8) } private var newTabButton: some View { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index b2ba85fb..e01cf0db 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -37,8 +37,8 @@ struct TerminalTabBarView: View { ) } .frame(height: TerminalTabBarMetrics.barHeight) + .background(TerminalTabBarBackground()) .saturation(activeState == .inactive ? 0 : 1) - .padding(.horizontal, 4) .clipped() } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 16247bae..1c68ac1e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -13,7 +13,6 @@ struct TerminalTabLabelView: View { if tab.isDirty || tab.icon != nil { TerminalTabIconBadge(tab: tab, isActive: isActive) } - Spacer(minLength: TerminalTabBarMetrics.contentTrailingSpacing) Text(tab.displayTitle) .font(.caption) .lineLimit(1) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index bc6e8a4b..443b8d78 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -119,9 +119,8 @@ struct TerminalTabView: View { } .padding(.bottom, isActive ? TerminalTabBarMetrics.activeTabBottomPadding : 0) .offset(y: isActive ? TerminalTabBarMetrics.activeTabOffset : 0) - .clipShape(.capsule) - .contentShape(.capsule) - .glassEffect() + .clipShape(.rect(cornerRadius: TerminalTabBarMetrics.tabCornerRadius)) + .contentShape(.rect) .onHover { hovering in isHovering = hovering } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index 6eb971f3..6687a301 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -76,6 +76,10 @@ struct TerminalTabsRowView: View { ) ) .id(id) + + if index < openedTabs.count - 1 { + TerminalTabDivider() + } } } } -- 2.51.2 From 76d0c0d3d655365543d293507cd723df5d045b4b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 10:17:12 +0900 Subject: [PATCH 167/528] Polish terminal tab bar: centered titles, full-width tabs, refined chrome Refine the (pre-#331) tab bar visuals: - Background wraps only the tabs; the trailing accessories (+ / splits) sit outside it on the bare bar chrome. - Tabs split the bar equally and fill it, dropping to a minimum width and scrolling once there are too many (no more max-width cap). - Titles are truly centered via symmetric placeholders sized to the close button, so the close button never overlaps the title. - Inter-tab dividers are shortened with a vertical inset and hidden on both sides of the selected tab so it reads as floating. - Add a TerminalTabBarView preview (0/1/2/15 tabs) backed by a preview-safe GhosttyShortcutManager init (runtime now optional). --- .../TabBar/TerminalTabBarColors.swift | 4 +- .../TabBar/TerminalTabBarMetrics.swift | 8 +-- .../TabBar/Views/TerminalTabBackground.swift | 6 -- .../Views/TerminalTabBarBackground.swift | 3 +- .../TabBar/Views/TerminalTabBarView.swift | 66 ++++++++++++++++++- .../TabBar/Views/TerminalTabDivider.swift | 2 +- .../TabBar/Views/TerminalTabLabelView.swift | 37 +++++++---- .../TabBar/Views/TerminalTabView.swift | 8 +-- .../TabBar/Views/TerminalTabsRowView.swift | 7 +- .../TabBar/Views/TerminalTabsView.swift | 15 +++-- .../Ghostty/GhosttyShortcutManager.swift | 15 ++++- 11 files changed, 128 insertions(+), 43 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift index ec375851..8e73a81e 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift @@ -3,7 +3,7 @@ import SwiftUI enum TerminalTabBarColors { static var barBackground: Color { - Color(nsColor: .windowBackgroundColor) + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.15) }, light: { .labelColor.withAlphaComponent(0.05) }) } // Selection is conveyed by a brightness ladder layered over `barBackground`, @@ -25,7 +25,7 @@ enum TerminalTabBarColors { } static var inactiveTabBackground: Color { - adaptiveFill(dark: { .labelColor.withAlphaComponent(0.035) }, light: { .clear }) + .clear } /// Resolves to `dark` under Dark Aqua and `light` otherwise, wrapped in a diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 78e30e74..4e9de90f 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -2,18 +2,18 @@ import CoreGraphics enum TerminalTabBarMetrics { static let barHeight: CGFloat = 33 - static let barPadding: CGFloat = 0 + static let barPadding: CGFloat = 4 static let tabHeight: CGFloat = 32 static let tabMinWidth: CGFloat = 140 - static let tabMaxWidth: CGFloat = 220 static let tabCornerRadius: CGFloat = 0 static let tabSpacing: CGFloat = 0 + // Top/bottom inset applied to the inter-tab divider so it does not run the + // full bar height; the shorter line is centered by the row's HStack. + static let tabDividerVerticalInset: CGFloat = 6 static let tabHorizontalPadding: CGFloat = 12 static let contentSpacing: CGFloat = 6 static let contentTrailingSpacing: CGFloat = 4 static let activeIndicatorHeight: CGFloat = 2 - static let activeTabOffset: CGFloat = 0.5 - static let activeTabBottomPadding: CGFloat = 1 static let closeButtonSize: CGFloat = 16 static let dirtyIndicatorSize: CGFloat = 8 static let overflowShadowWidth: CGFloat = 24 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift index aadbf5b6..b0018b0f 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift @@ -16,12 +16,6 @@ struct TerminalTabBackground: View { TerminalTabBarColors.inactiveTabBackground } - if isActive { - Rectangle() - .fill(Color.accentColor) - .frame(height: TerminalTabBarMetrics.activeIndicatorHeight) - } - if !isActive { VStack(spacing: 0) { Spacer(minLength: 0) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift index af57996a..5c42ee37 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift @@ -7,8 +7,9 @@ struct TerminalTabBarBackground: View { private var surfaceTopChromeBackgroundOpacity var body: some View { - Rectangle() + Capsule() .fill(TerminalTabBarColors.barBackground.opacity(chromeBackgroundOpacity)) + .padding(.horizontal, TerminalTabBarMetrics.barPadding) } private var chromeBackgroundOpacity: Double { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index e01cf0db..589c5499 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -28,6 +28,9 @@ struct TerminalTabBarView: View { closeAll: closeAll, hasNotification: hasNotification ) + // Background wraps only the tabs; the trailing accessories (+ / splits) + // sit outside it on the bare bar chrome. + .background(TerminalTabBarBackground()) Spacer(minLength: 0) TerminalTabBarTrailingAccessories( createTab: createTab, @@ -37,8 +40,69 @@ struct TerminalTabBarView: View { ) } .frame(height: TerminalTabBarMetrics.barHeight) - .background(TerminalTabBarBackground()) .saturation(activeState == .inactive ? 0 : 1) .clipped() } } + +// MARK: - Previews + +#if DEBUG + @MainActor + private struct TerminalTabBarPreviewRow: View { + let title: String + @State private var manager: TerminalTabManager + + init(title: String, tabCount: Int) { + self.title = title + let manager = TerminalTabManager() + for index in 0.. 0, count > 0 else { return nil } - let perTab = containerWidth / CGFloat(count) - return min( - TerminalTabBarMetrics.tabMaxWidth, - max(TerminalTabBarMetrics.tabMinWidth, perTab) - ) + // Tabs split the bar equally and fill it (no max cap). Once the equal share + // would dip below the minimum (too many tabs), pin to the minimum so the + // row overflows and scrolls instead. Account for the row's horizontal + // padding and inter-tab spacing so a filled row does not spuriously overflow. + let available = + containerWidth + - TerminalTabBarMetrics.barPadding * 2 + - TerminalTabBarMetrics.tabSpacing * CGFloat(count - 1) + let perTab = available / CGFloat(count) + return max(TerminalTabBarMetrics.tabMinWidth, perTab) } } diff --git a/supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift b/supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift index dfb415b1..41534c76 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift @@ -5,7 +5,7 @@ import SwiftUI @MainActor @Observable final class GhosttyShortcutManager { - private let runtime: GhosttyRuntime + private let runtime: GhosttyRuntime? private var generation: Int = 0 init(runtime: GhosttyRuntime) { @@ -15,18 +15,27 @@ final class GhosttyShortcutManager { } } + #if DEBUG + /// Preview/test instance with no runtime; shortcut lookups return nil so + /// views render without a live Ghostty app (mirrors + /// `WorktreeTerminalManager.preview`). + init(preview: Void) { + self.runtime = nil + } + #endif + func refresh() { generation += 1 } var commandPaletteEntries: [GhosttyCommand] { _ = generation - return runtime.commandPaletteEntries() + return runtime?.commandPaletteEntries() ?? [] } func keyboardShortcut(for action: String) -> KeyboardShortcut? { _ = generation - return runtime.keyboardShortcut(for: action) + return runtime?.keyboardShortcut(for: action) } func display(for action: String) -> String? { -- 2.51.2 From 36a15ce9133f74544f1f252c96a74f8679f0ac4d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 12:05:41 +0900 Subject: [PATCH 168/528] Refine tab bar: floating glass tabs, hover close circle, stable layout - Drop the bottom separator line under unselected tabs. - Remove the scroll-edge gradient overlays (and the now-dead overflow-shadow view, its tracking state, and the tabScroll coordinate space). - Hover/press the close button now shows the standard macOS circular background (system label colors) instead of only recoloring the glyph. - The tab fill is inset 1pt and shaped as a Capsule (matching the tab's .clipShape(.capsule)) so the selected tab floats with a small gap and keeps its rounded ends; the selected tab gets a Liquid Glass surface. - Inter-tab dividers stay in the layout and only toggle opacity (no add/remove on selection), and their width is accounted for in the equal-fill tab-width math so a filled row fits exactly. This stops selection-driven scrollTo(.center) from nudging the row when picking a middle tab. --- .../TabBar/TerminalTabBarMetrics.swift | 3 +- .../TabBar/Views/TerminalTabBackground.swift | 26 +++++----- .../TerminalTabCloseButtonBackground.swift | 7 ++- .../TabBar/Views/TerminalTabDivider.swift | 2 +- .../Views/TerminalTabsOverflowShadow.swift | 42 --------------- .../TabBar/Views/TerminalTabsRowView.swift | 11 ++-- .../TabBar/Views/TerminalTabsView.swift | 51 +++---------------- 7 files changed, 32 insertions(+), 110 deletions(-) delete mode 100644 supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 4e9de90f..6ef6ea9d 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -7,6 +7,7 @@ enum TerminalTabBarMetrics { static let tabMinWidth: CGFloat = 140 static let tabCornerRadius: CGFloat = 0 static let tabSpacing: CGFloat = 0 + static let tabDividerWidth: CGFloat = 1 // Top/bottom inset applied to the inter-tab divider so it does not run the // full bar height; the shorter line is centered by the row's HStack. static let tabDividerVerticalInset: CGFloat = 6 @@ -16,12 +17,10 @@ enum TerminalTabBarMetrics { static let activeIndicatorHeight: CGFloat = 2 static let closeButtonSize: CGFloat = 16 static let dirtyIndicatorSize: CGFloat = 8 - static let overflowShadowWidth: CGFloat = 24 static let dropIndicatorWidth: CGFloat = 2 static let dropIndicatorHeight: CGFloat = 20 static let hoverAnimationDuration: Double = 0.1 static let closeAnimationDuration: Double = 0.2 - static let fadeAnimationDuration: Double = 0.15 static let selectionAnimationDuration: Double = 0.15 static let reorderAnimationDuration: Double = 0.3 static let reorderAnimationBounce: Double = 0.15 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift index b0018b0f..b416d75e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift @@ -7,23 +7,23 @@ struct TerminalTabBackground: View { var isHovering: Bool var body: some View { - ZStack(alignment: .top) { + Group { if isActive { - TerminalTabBarColors.activeTabBackground + // Selected tab floats as a Liquid Glass surface tinted by the + // brightness-ladder fill, for the macOS 26 look. + Capsule() + .fill(TerminalTabBarColors.activeTabBackground) + .glassEffect(.regular, in: Capsule()) } else if isHovering || isPressing || isDragging { - TerminalTabBarColors.hoveredTabBackground + Capsule().fill(TerminalTabBarColors.hoveredTabBackground) } else { - TerminalTabBarColors.inactiveTabBackground - } - - if !isActive { - VStack(spacing: 0) { - Spacer(minLength: 0) - Rectangle() - .fill(TerminalTabBarColors.separator) - .frame(height: 1) - } + Capsule().fill(TerminalTabBarColors.inactiveTabBackground) } } + // 1pt inset so the capsule floats with a small gap instead of touching the + // bar/neighbor edges. Capsule (not a rounded rect) matches the tab's outer + // .clipShape(.capsule); a rounded rect's corners would poke past the + // capsule clip and get shaved off. + .padding(1) } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift index e9c420f3..c1544c55 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift @@ -9,12 +9,15 @@ struct TerminalTabCloseButtonBackground: View { .fill(backgroundColor) } + // A clear circular fill on hover/press, like the standard macOS close-button + // affordance. Uses system label colors so it reads on any bar background + // (the brightness-ladder tab fills are too faint to show as a circle). private var backgroundColor: Color { if isPressing { - return TerminalTabBarColors.hoveredTabBackground + return Color(nsColor: .tertiaryLabelColor) } if isHoveringClose { - return TerminalTabBarColors.hoveredTabBackground + return Color(nsColor: .quaternaryLabelColor) } return .clear } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift index b1bfd03a..93702518 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift @@ -3,7 +3,7 @@ import SwiftUI struct TerminalTabDivider: View { var body: some View { Rectangle() - .frame(width: 1) + .frame(width: TerminalTabBarMetrics.tabDividerWidth) .frame(height: TerminalTabBarMetrics.tabHeight - TerminalTabBarMetrics.tabDividerVerticalInset * 2) .foregroundStyle(TerminalTabBarColors.separator) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift deleted file mode 100644 index 8d9641a0..00000000 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift +++ /dev/null @@ -1,42 +0,0 @@ -import SwiftUI - -struct TerminalTabsOverflowShadow: View { - var width: CGFloat - var startPoint: UnitPoint - var endPoint: UnitPoint - - @Environment(\.controlActiveState) - private var activeState - @Environment(\.surfaceTopChromeBackgroundOpacity) - private var surfaceTopChromeBackgroundOpacity - - var body: some View { - Rectangle() - .frame(maxHeight: .infinity) - .frame(width: width) - .foregroundStyle(.clear) - .background( - LinearGradient( - gradient: Gradient(colors: gradientColors), - startPoint: startPoint, - endPoint: endPoint - ) - ) - .allowsHitTesting(false) - } - - private var gradientColors: [Color] { - [ - TerminalTabBarColors.barBackground.opacity(chromeBackgroundOpacity), - TerminalTabBarColors.barBackground.opacity(0), - ] - } - - private var chromeBackgroundOpacity: Double { - let baseOpacity = surfaceTopChromeBackgroundOpacity - if activeState == .inactive { - return baseOpacity * 0.95 - } - return baseOpacity - } -} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index 3b298720..949a273f 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -78,12 +78,13 @@ struct TerminalTabsRowView: View { .id(id) if index < openedTabs.count - 1 { - // Hide the dividers flanking the selected tab so it reads as - // floating rather than boxed in by separators. + // Always keep the divider in the layout and only toggle its + // opacity, so changing selection never shifts tab positions. + // Hidden on both sides of the selected tab so it reads as floating. let selectedId = manager.selectedTabId - if openedTabs[index] != selectedId && openedTabs[index + 1] != selectedId { - TerminalTabDivider() - } + let touchesSelection = openedTabs[index] == selectedId || openedTabs[index + 1] == selectedId + TerminalTabDivider() + .opacity(touchesSelection ? 0 : 1) } } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift index 70cc0ff3..fc3b791a 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift @@ -15,8 +15,6 @@ struct TerminalTabsView: View { @State private var openedTabs: [TerminalTabID] = [] @State private var tabLocations: [TerminalTabID: CGRect] = [:] @State private var closeButtonGestureActive = false - @State private var scrollOffset: CGFloat = 0 - @State private var contentWidth: CGFloat = 0 @State private var containerWidth: CGFloat = 0 var body: some View { @@ -41,23 +39,8 @@ struct TerminalTabsView: View { scrollReader: scrollReader ) .padding(.horizontal, TerminalTabBarMetrics.barPadding) - .background( - GeometryReader { contentGeo in - Color.clear - .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in - scrollOffset = -newFrame.minX - contentWidth = newFrame.width - } - .onAppear { - let frame = contentGeo.frame(in: .named("tabScroll")) - scrollOffset = -frame.minX - contentWidth = frame.width - } - } - ) } .scrollIndicators(.never) - .coordinateSpace(name: "tabScroll") .onAppear { containerWidth = geometryProxy.size.width if let selectedId = manager.selectedTabId { @@ -75,46 +58,24 @@ struct TerminalTabsView: View { } } } - .overlay(alignment: .leading) { - TerminalTabsOverflowShadow( - width: TerminalTabBarMetrics.overflowShadowWidth, - startPoint: .leading, - endPoint: .trailing - ) - .opacity(canScrollLeft ? 1 : 0) - .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: canScrollLeft) - } - .overlay(alignment: .trailing) { - TerminalTabsOverflowShadow( - width: TerminalTabBarMetrics.overflowShadowWidth, - startPoint: .trailing, - endPoint: .leading - ) - .opacity(canScrollRight ? 1 : 0) - .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: canScrollRight) - } } } - private var canScrollLeft: Bool { - scrollOffset > 1 - } - - private var canScrollRight: Bool { - contentWidth > containerWidth && scrollOffset < contentWidth - containerWidth - 1 - } - private var effectiveTabWidth: CGFloat? { let count = manager.tabs.count guard containerWidth > 0, count > 0 else { return nil } // Tabs split the bar equally and fill it (no max cap). Once the equal share // would dip below the minimum (too many tabs), pin to the minimum so the // row overflows and scrolls instead. Account for the row's horizontal - // padding and inter-tab spacing so a filled row does not spuriously overflow. + // padding, inter-tab spacing, and the always-present dividers so a filled + // row fits exactly — otherwise the leftover overflow lets selection-driven + // scrollTo(.center) nudge the whole row when picking a middle tab. + let interTabCount = CGFloat(count - 1) let available = containerWidth - TerminalTabBarMetrics.barPadding * 2 - - TerminalTabBarMetrics.tabSpacing * CGFloat(count - 1) + - TerminalTabBarMetrics.tabSpacing * interTabCount + - TerminalTabBarMetrics.tabDividerWidth * interTabCount let perTab = available / CGFloat(count) return max(TerminalTabBarMetrics.tabMinWidth, perTab) } -- 2.51.2 From 3322606bad44634910fad8110a2df46242f35648 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 12:15:59 +0900 Subject: [PATCH 169/528] Add tab bar/terminal gap and tune dark-mode tab brightness - 4pt gap between the tab bar and the terminal surface below it. - Dark mode: dim the bar background (labelColor 0.15 -> 0.10) and brighten the selected tab (0.14 -> 0.25) so the active tab stands out more against the bar. --- supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift | 4 ++-- .../Features/Terminal/Views/WorktreeTerminalTabsView.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift index 8e73a81e..a57787fc 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift @@ -3,7 +3,7 @@ import SwiftUI enum TerminalTabBarColors { static var barBackground: Color { - adaptiveFill(dark: { .labelColor.withAlphaComponent(0.15) }, light: { .labelColor.withAlphaComponent(0.05) }) + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.10) }, light: { .labelColor.withAlphaComponent(0.05) }) } // Selection is conveyed by a brightness ladder layered over `barBackground`, @@ -14,7 +14,7 @@ enum TerminalTabBarColors { // would *darken* the tab and read unnaturally, so we keep the original system // appearance: a white tab floating on the gray bar. static var activeTabBackground: Color { - adaptiveFill(dark: { .labelColor.withAlphaComponent(0.14) }, light: { .controlBackgroundColor }) + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.25) }, light: { .controlBackgroundColor }) } static var hoveredTabBackground: Color { diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 6e89e568..1db8ed53 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -13,7 +13,8 @@ struct WorktreeTerminalTabsView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - VStack(spacing: 0) { + // 4pt gap between the tab bar and the terminal surface below it. + VStack(spacing: 4) { tabBar(state: state) tabContent(state: state) } -- 2.51.2 From f98f7d4f1f6adf26bf302ee85ee890e68e34d056 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 15:02:09 +0900 Subject: [PATCH 170/528] Tab bar & active panel tweaks - Trailing accessory buttons (+ / splits) no longer swap to text + shortcut hint while Cmd is held; they always show the icon. - Tighten the active agents panel padding (8 -> 6). - Light mode: nudge the tab bar background darker (labelColor 0.05 -> 0.08). --- .../Repositories/Views/SidebarListView.swift | 2 +- .../TabBar/TerminalTabBarColors.swift | 2 +- .../TerminalTabBarTrailingAccessories.swift | 30 +++---------------- 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index a16ec546..acacbc70 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -146,7 +146,7 @@ struct SidebarListView: View { store.send(.activeAgents(.panelHeightChanged(height))) } ) - .padding(8) + .padding(6) .frame(height: panelHeight) .offset(y: panelOffset) .clipped() diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift index a57787fc..1a80ec38 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift @@ -3,7 +3,7 @@ import SwiftUI enum TerminalTabBarColors { static var barBackground: Color { - adaptiveFill(dark: { .labelColor.withAlphaComponent(0.10) }, light: { .labelColor.withAlphaComponent(0.05) }) + adaptiveFill(dark: { .labelColor.withAlphaComponent(0.10) }, light: { .labelColor.withAlphaComponent(0.08) }) } // Selection is conveyed by a brightness ladder layered over `barBackground`, diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift index 495cf5d3..ef128fa5 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -8,8 +8,6 @@ struct TerminalTabBarTrailingAccessories: View { @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts - @Environment(CommandKeyObserver.self) - private var commandKeyObserver @State private var isHoveringButton = false @State private var isHoveringPopover = false @@ -44,18 +42,8 @@ struct TerminalTabBarTrailingAccessories: View { createTab() isHoverPopoverPresented = false } label: { - if commandKeyObserver.isPressed { - HStack(spacing: TerminalTabBarMetrics.contentSpacing) { - Text("New Tab") - .font(.caption) - if let shortcut = ghosttyShortcuts.display(for: "new_tab") { - ShortcutHintView(text: shortcut, color: TerminalTabBarColors.inactiveText) - } - } - } else { - Label("New Tab", systemImage: "plus") - .labelStyle(.iconOnly) - } + Label("New Tab", systemImage: "plus") + .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help(helpText("New Tab", shortcut: ghosttyShortcuts.display(for: "new_tab"))) @@ -89,18 +77,8 @@ struct TerminalTabBarTrailingAccessories: View { let shortcut = ghosttyShortcuts.display(for: shortcutBinding) return Button(action: action) { - if commandKeyObserver.isPressed { - HStack(spacing: TerminalTabBarMetrics.contentSpacing) { - Text(title) - .font(.caption) - if let shortcut { - ShortcutHintView(text: shortcut, color: TerminalTabBarColors.inactiveText) - } - } - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - } + Label(title, systemImage: systemImage) + .labelStyle(.iconOnly) } .buttonStyle(.borderless) .help(helpText(title, shortcut: shortcut)) -- 2.51.2 From 64a79615a68380beee83ccce1ee9a29a48a3ad6b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 16:50:05 +0900 Subject: [PATCH 171/528] Tint Shelf chrome with the open repo's color Frame the open book's terminal with one repo color across the spine, the toolbar strip, and the nav panel: - Add ShelfSurfaceTint: the pinned repo color, or a neutral surface (near-black in dark mode / near-white in light) when the repo has no color set. Independent of window transparency so the spine and the toolbar band stay at the same alpha. - The spine background and a new toolbar tint band share this surface, so the open spine and the strip above the terminal read as one "L". - A leading band fills the area behind the floating glass sidebar; the glass blends it into a uniform nav-panel tint matching the spine/band (the sidebar's own background is hidden by the glass, so the color is driven from the detail side). - Stop spines bleeding into the titlebar / under the sidebar so each region shows one cohesive color instead of stacked stripes. --- .../Features/Shelf/Views/ShelfSpineView.swift | 30 +++++--- .../Shelf/Views/ShelfSurfaceTint.swift | 36 ++++++++++ supacode/Features/Shelf/Views/ShelfView.swift | 72 +++++++++++++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 supacode/Features/Shelf/Views/ShelfSurfaceTint.swift diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 2f6fdb7b..c270a6c7 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -67,8 +67,15 @@ struct ShelfSpineView: View { // swapping one view for another (which the previous `@ViewBuilder` // if/else did). Fill is derived from a stepped accent-alpha ladder // so the open book glows strongest and neighbors fade outward. + // + // Only extends `.bottom`: the spine stops at the toolbar's lower edge + // (the uniform toolbar tint up there is owned by + // `ShelfView.topTintBand`) and no longer bleeds sideways under the + // floating sidebar. The nav panel's color is now driven explicitly by + // `SidebarView`'s tint, so a leftward bleed would only muddy it (the + // glass would blur several spines together into an off-hue mix). Rectangle().fill(spineBackgroundColor) - .ignoresSafeArea(edges: .all) + .ignoresSafeArea(edges: .bottom) ) // Whole-spine tap target. Inner Buttons (header, tab slots, controls) // absorb their own clicks; clicks that fall on empty areas (scroll @@ -114,21 +121,26 @@ struct ShelfSpineView: View { /// "this is interactable" affordance that sits just below the open /// book and animates in/out smoothly. /// - /// When the book's repository has a user-pinned color, that color - /// replaces `Color.accentColor` as the proximity-tint base so the - /// shelf reads as "books on shelves" instead of one continuous - /// accent ribbon. The proximity ladder is unchanged — we only swap - /// the hue. + /// The surface hue/alpha come from `ShelfSurfaceTint`: a repo with a + /// user-pinned color tints its spine with that color, while an uncolored + /// repo uses a neutral surface (near-black in dark mode, near-white in + /// light) so the shelf stays calm and the open book's spine reads as one + /// continuous "L" with the toolbar tint band above it. The proximity + /// ladder is unchanged — we only swap the base. private var spineBackgroundColor: Color { guard distanceFromOpen != nil else { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return effectiveTintColor.opacity(0.20 * multiplier) + return ShelfSurfaceTint.base(for: appearance.color) + .opacity(ShelfSurfaceTint.peakAlpha(for: appearance.color) * multiplier) } - /// Repo's pinned color, or `.accentColor` when none — used as the - /// proximity-tint base and as the icon tint in the header. + /// Repo's pinned color, or `.accentColor` when none — used as the header + /// icon tint and the active-tab highlight. The spine *surface* fill + /// instead routes through `ShelfSurfaceTint` (neutral when uncolored), so + /// an uncolored repo keeps an accent icon / tab marker on an otherwise + /// neutral spine. private var effectiveTintColor: Color { appearance.color?.color ?? .accentColor } diff --git a/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift b/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift new file mode 100644 index 00000000..10c016ae --- /dev/null +++ b/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Shared color logic for the Shelf's tinted *surfaces* — the spine +/// background fill and the toolbar tint band above the open book — so the +/// open book's spine and the band read as one continuous "L" framing the +/// terminal's top and left edges. +/// +/// A repo with a user-pinned color tints its surfaces with that color. An +/// uncolored repo falls back to a neutral surface that reads as near-black +/// in dark mode and near-white in light (`Color.primary` composited over +/// the window background at low alpha), so the shelf stays calm until the +/// user opts into a color. +/// +/// This intentionally covers only surface *fills*. The header icon and the +/// active-tab highlight keep using the app accent as their uncolored +/// fallback (see `ShelfSpineView.effectiveTintColor`), so an uncolored repo +/// still gets an accent icon / tab marker on its otherwise-neutral spine. +enum ShelfSurfaceTint { + /// Base hue for a surface fill: the pinned color, or `Color.primary` for + /// the neutral fallback. + static func base(for color: RepositoryColorChoice?) -> Color { + color?.color ?? .primary + } + + /// Peak fill alpha for the open book's surface (the band, and the open + /// spine at proximity 0). Independent of window transparency, so the + /// spine and the toolbar band stay in lockstep at the same alpha + /// regardless of `background-opacity`. The neutral fallback uses a + /// gentler peak because `Color.primary` reads as a distinct panel at far + /// lower opacity than a saturated hue (0.20 of primary would look like a + /// glaring mid-gray rather than the subtle near-black / near-white + /// surface we want). + static func peakAlpha(for color: RepositoryColorChoice?) -> Double { + color == nil ? 0.10 : 0.20 + } +} diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 00d15fe4..45e03e2f 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import Sharing import SwiftUI private let shelfLogger = SupaLogger("Shelf") @@ -22,6 +23,15 @@ struct ShelfView: View { /// stamping an opaque layer behind every child — including the /// terminal surface and empty-state area. @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity + @Shared(.repositoryAppearances) private var repositoryAppearances + /// Height of the window toolbar/titlebar. The window draws content under + /// the transparent titlebar, so the Shelf's top safe-area inset equals + /// the toolbar height — exactly the region the tint band should fill. + @State private var toolbarInset: CGFloat = 0 + /// Width of the floating glass sidebar. The detail is laid out full-bleed + /// beneath the sidebar, so the Shelf's leading safe-area inset equals the + /// sidebar width — the span the left tint band fills to color the nav. + @State private var sidebarInset: CGFloat = 0 var body: some View { // Body-invocation counter. The @ViewBuilder getter rules out a @@ -37,6 +47,9 @@ struct ShelfView: View { let openIndex = openBook.flatMap { book in books.firstIndex(where: { $0.id == book.id }) } + // Color identity of the open book's repo (nil ⇒ neutral surface). Shared + // by the spine fill and the toolbar band so they read as one "L". + let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } HStack(spacing: 0) { ForEach(Array(books.enumerated()), id: \.element.id) { index, book in @@ -51,6 +64,38 @@ struct ShelfView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity)) + // Capture the toolbar/titlebar height so the band can fill exactly that + // region: content draws under the transparent titlebar, so the top + // safe-area inset measured here equals the toolbar height. + .onGeometryChange(for: CGFloat.self) { + $0.safeAreaInsets.top + } action: { + toolbarInset = $0 + } + // Capture the floating sidebar's width (= leading safe-area inset) so the + // left band can fill exactly the region behind it. + .onGeometryChange(for: CGFloat.self) { + $0.safeAreaInsets.leading + } action: { + sidebarInset = $0 + } + // Repo-colored band sitting behind the toolbar items, bleeding up into + // the titlebar via `ignoresSafeArea`. Only shown when a book is open; an + // empty shelf keeps its bare chrome. + .overlay(alignment: .top) { + if openBook != nil { + topTintBand(openColor: openColor) + } + } + // Repo-colored strip behind the floating glass sidebar. The glass blends + // with the detail content beneath it, so this strip (not the sidebar's + // own background, which the glass hides) is what tints the nav panel — + // a single uniform color matching the open spine and the toolbar band. + .overlay(alignment: .leading) { + if openBook != nil { + leftTintBand(openColor: openColor) + } + } // Animate on every openBookID change — covers both Shelf-originated // book switches (which also set their own TCA animation) and // left-nav-originated switches, so the spine flow is consistent @@ -58,6 +103,33 @@ struct ShelfView: View { .animation(.easeInOut(duration: 0.2), value: openBookID) } + /// Tinted strip behind the toolbar items. Uses the exact same hue/alpha + /// as the open book's spine surface (`ShelfSurfaceTint`) so the band and + /// the spine join seamlessly. + private func topTintBand(openColor: RepositoryColorChoice?) -> some View { + ShelfSurfaceTint.base(for: openColor) + .opacity(ShelfSurfaceTint.peakAlpha(for: openColor)) + .frame(height: toolbarInset) + .frame(maxWidth: .infinity) + .ignoresSafeArea(.container, edges: .top) + .allowsHitTesting(false) + } + + /// Full-height strip filling the leading safe-area inset — i.e. the area + /// behind the floating glass sidebar. Uses the same hue/alpha as the spine + /// and the toolbar band, so once the glass blends it in, the nav panel, + /// the open spine, and the toolbar all read as one continuous color. + /// `.top` / `.bottom` are ignored too so it backs the sidebar's full + /// height, including its own toolbar region. + private func leftTintBand(openColor: RepositoryColorChoice?) -> some View { + ShelfSurfaceTint.base(for: openColor) + .opacity(ShelfSurfaceTint.peakAlpha(for: openColor)) + .frame(width: sidebarInset) + .frame(maxHeight: .infinity) + .ignoresSafeArea(.container, edges: [.leading, .top, .bottom]) + .allowsHitTesting(false) + } + @ViewBuilder private func spine(book: ShelfBook, index: Int, openIndex: Int?) -> some View { let distance = openIndex.map { abs(index - $0) } -- 2.51.2 From 51af064e6059f32f1646ca8da6d53960265995d5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 22:05:31 +0900 Subject: [PATCH 172/528] Add a window tint setting for nav, toolbar, and tab bar chrome Generalize the Shelf's chrome tint into a setting that colors the nav panel, the toolbar, and (in Normal view) the terminal tab bar backing across every view mode. - Add WindowTintMode (none / repository color / custom) and a ColorPicker in Appearance settings, persisted via GlobalSettings (TintColor stores the custom color as sRGB components). - Replace ShelfSurfaceTint with a shared WindowChromeTint: it resolves the band fill from the mode + custom color + the active repo color, and exposes a .windowChromeTint(_:edges:) modifier that paints the toolbar (top) and nav (leading) bands on full-bleed detail content. - Shelf: bands honor the setting; the spine still always uses the open repo's color regardless of the mode. - Normal: tint the toolbar + nav, and paint a full-width tint strip behind the whole terminal tab bar (left gap, tabs capsule, and trailing accessories). The strip sits outside the bar's inactive saturation so it keeps its hue on blur, matching the toolbar / nav. - Canvas: tint the toolbar + nav; card title bars keep their per-repo color. - Add WindowChromeTintTests covering fill resolution and TintColor round-tripping. --- supacode/Domain/TintColor.swift | 40 +++++ supacode/Domain/WindowChromeTint.swift | 145 ++++++++++++++++++ .../Views/WorktreeDetailView.swift | 72 ++++++++- .../Settings/Models/GlobalSettings.swift | 22 ++- .../Settings/Models/WindowTintMode.swift | 34 ++++ .../Settings/Reducer/SettingsFeature.swift | 14 +- .../Views/AppearanceSettingsView.swift | 32 ++++ .../Features/Shelf/Views/ShelfSpineView.swift | 20 +-- .../Shelf/Views/ShelfSurfaceTint.swift | 36 ----- supacode/Features/Shelf/Views/ShelfView.swift | 87 +++-------- .../TabBar/Views/TerminalTabBarView.swift | 22 ++- .../Views/WorktreeTerminalTabsView.swift | 5 + supacodeTests/WindowChromeTintTests.swift | 55 +++++++ 13 files changed, 464 insertions(+), 120 deletions(-) create mode 100644 supacode/Domain/TintColor.swift create mode 100644 supacode/Domain/WindowChromeTint.swift create mode 100644 supacode/Features/Settings/Models/WindowTintMode.swift delete mode 100644 supacode/Features/Shelf/Views/ShelfSurfaceTint.swift create mode 100644 supacodeTests/WindowChromeTintTests.swift diff --git a/supacode/Domain/TintColor.swift b/supacode/Domain/TintColor.swift new file mode 100644 index 00000000..d61f0f09 --- /dev/null +++ b/supacode/Domain/TintColor.swift @@ -0,0 +1,40 @@ +import AppKit +import SwiftUI + +/// A persistable sRGB color value used for the user's custom window tint. +/// +/// SwiftUI's `Color` is not `Codable`, so the custom tint is stored as +/// three sRGB components (opacity is intentionally dropped — the chrome +/// band applies its own fixed alpha, so a user-chosen alpha would have no +/// meaning). The project's "system colors only" rule is deliberately +/// relaxed here: the custom tint is an explicit, user-driven free color +/// choice, unlike the closed `RepositoryColorChoice` palette. +nonisolated struct TintColor: Codable, Equatable, Sendable { + var red: Double + var green: Double + var blue: Double + + init(red: Double, green: Double, blue: Double) { + self.red = red + self.green = green + self.blue = blue + } + + /// Resolves an arbitrary SwiftUI `Color` to sRGB components. Falls back + /// to a mid-gray if the color cannot be expressed in sRGB (e.g. a + /// pattern color), which never happens for `ColorPicker` output. + init(_ color: Color) { + let resolved = NSColor(color).usingColorSpace(.sRGB) ?? NSColor.gray.usingColorSpace(.sRGB) + red = Double(resolved?.redComponent ?? 0.5) + green = Double(resolved?.greenComponent ?? 0.5) + blue = Double(resolved?.blueComponent ?? 0.5) + } + + var color: Color { + Color(.sRGB, red: red, green: green, blue: blue) + } + + /// Default custom tint shown before the user picks their own — a calm + /// blue that reads well as a low-alpha chrome band in both appearances. + static let `default` = TintColor(.blue) +} diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift new file mode 100644 index 00000000..199c13a1 --- /dev/null +++ b/supacode/Domain/WindowChromeTint.swift @@ -0,0 +1,145 @@ +import SwiftUI + +/// Unified color logic for the window *chrome* tint — the nav-panel band +/// behind the floating glass sidebar and the toolbar band behind the +/// transparent titlebar — shared across every view mode (Normal, Shelf, +/// Canvas). +/// +/// The tint is "driven from the detail side": both bands are overlays on +/// the full-bleed detail content that bleed up under the titlebar and left +/// under the sidebar glass, so one resolved color paints the nav and the +/// toolbar as a single continuous "L". +/// +/// `WindowTintMode` selects the source: +/// - `.none` ⇒ no band (neutral system chrome). +/// - `.repositoryColor` ⇒ the active repo's pinned color, or a neutral +/// surface when uncolored — the same `repositoryBase`/`repositoryPeakAlpha` +/// pair the Shelf spine uses, so the chrome and the open spine match. +/// - `.custom` ⇒ a single user-chosen color, ignoring per-repo colors. +enum WindowChromeTint { + /// A resolved chrome band: a base color plus the alpha it should be + /// filled at. `nil` from `fill(...)` means "draw no band". + struct Fill: Equatable { + var color: Color + var alpha: Double + } + + /// Peak fill alpha for a saturated tint (a colored repo, or a custom + /// color). Matches the Shelf spine's open-book peak so the chrome and + /// the spine read at the same intensity. + static let saturatedPeakAlpha: Double = 0.20 + /// Gentler peak for the neutral fallback: `Color.primary` reads as a + /// distinct panel at far lower opacity than a saturated hue, so 0.20 + /// would look like a glaring mid-gray instead of the subtle near-black / + /// near-white surface we want. + static let neutralPeakAlpha: Double = 0.10 + + /// Base hue for a repository-color surface: the pinned color, or + /// `Color.primary` for the neutral fallback. Used by the Shelf spine + /// (always) and by `.repositoryColor` chrome bands. + static func repositoryBase(for color: RepositoryColorChoice?) -> Color { + color?.color ?? .primary + } + + /// Peak alpha for a repository-color surface — the saturated peak for a + /// pinned color, the neutral peak when uncolored. + static func repositoryPeakAlpha(for color: RepositoryColorChoice?) -> Double { + color == nil ? neutralPeakAlpha : saturatedPeakAlpha + } + + /// Resolves the chrome band for the given tint mode. Returns `nil` when + /// no band should be drawn (`.none`, or `.custom` without a color). + /// + /// - Parameters: + /// - mode: the user's window tint mode. + /// - customColor: the user's custom color, for `.custom` mode. + /// - repositoryColor: the active repo's pinned color (the open book in + /// Shelf, the selected worktree's repo in Normal, the focused card's + /// repo in Canvas), used by `.repositoryColor` mode. + static func fill( + mode: WindowTintMode, + customColor: Color?, + repositoryColor: RepositoryColorChoice? + ) -> Fill? { + switch mode { + case .none: + return nil + case .repositoryColor: + return Fill( + color: repositoryBase(for: repositoryColor), + alpha: repositoryPeakAlpha(for: repositoryColor) + ) + case .custom: + guard let customColor else { return nil } + return Fill(color: customColor, alpha: saturatedPeakAlpha) + } + } +} + +extension View { + /// Overlays chrome tint bands on full-bleed detail content. The bands + /// fill the safe-area insets the window reserves for the toolbar (top) + /// and the floating sidebar (leading), bleeding past them via + /// `ignoresSafeArea` so the color reaches under the titlebar / sidebar + /// glass. A `nil` fill draws nothing. + /// + /// - Parameters: + /// - fill: the resolved band fill, or `nil` for no tint. + /// - edges: which chrome regions to tint — `.top` (toolbar) and/or + /// `.leading` (nav). Normal uses both; Canvas uses `.leading` only. + func windowChromeTint(_ fill: WindowChromeTint.Fill?, edges: Edge.Set) -> some View { + modifier(WindowChromeTintModifier(fill: fill, edges: edges)) + } +} + +/// Measures the toolbar (top) and sidebar (leading) safe-area insets of +/// the detail content and paints a band into each requested edge. +private struct WindowChromeTintModifier: ViewModifier { + let fill: WindowChromeTint.Fill? + let edges: Edge.Set + + @State private var topInset: CGFloat = 0 + @State private var leadingInset: CGFloat = 0 + + func body(content: Content) -> some View { + content + // Content draws under the transparent titlebar, so the top + // safe-area inset equals the toolbar/titlebar height — exactly the + // region the top band should fill. + .onGeometryChange(for: CGFloat.self) { + $0.safeAreaInsets.top + } action: { + topInset = $0 + } + // The detail is laid out full-bleed beneath the floating glass + // sidebar, so the leading inset equals the sidebar width — the span + // the leading band fills to color the nav. + .onGeometryChange(for: CGFloat.self) { + $0.safeAreaInsets.leading + } action: { + leadingInset = $0 + } + .overlay(alignment: .top) { + if let fill, edges.contains(.top) { + band(fill) + .frame(height: topInset) + .frame(maxWidth: .infinity) + .ignoresSafeArea(.container, edges: .top) + } + } + .overlay(alignment: .leading) { + if let fill, edges.contains(.leading) { + band(fill) + .frame(width: leadingInset) + .frame(maxHeight: .infinity) + .ignoresSafeArea(.container, edges: [.leading, .top, .bottom]) + } + } + } + + private func band(_ fill: WindowChromeTint.Fill) -> some View { + fill.color + .opacity(fill.alpha) + .allowsHitTesting(false) + } +} diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 9aeecb3f..3c75b621 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -1,5 +1,6 @@ import AppKit import ComposableArchitecture +import Sharing import SwiftUI struct WorktreeDetailView: View { @@ -20,6 +21,9 @@ struct WorktreeDetailView: View { @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @Environment(CommandKeyObserver.self) private var commandKeyObserver + /// Drive the chrome (nav + toolbar) tint for Normal and Canvas modes. + @Shared(.repositoryAppearances) private var repositoryAppearances + @Shared(.settingsFile) private var settingsFile var body: some View { detailBody(state: store.state) @@ -252,15 +256,46 @@ struct WorktreeDetailView: View { repositoryCustomTitles: repositories.repositoryCustomTitles, onExitToTab: { store.send(.repositories(.toggleCanvas)) - }) + } + ) + // Canvas tints the nav (leading) and the toolbar (top); the card + // title bars additionally carry their own per-repo color. + .windowChromeTint(chromeFill(repositories: repositories, context: .canvas), edges: [.top, .leading]) } else if repositories.isShowingShelf { + // Shelf manages its own chrome bands (and its always-repo-colored + // spine) inside `ShelfView`, so no tint modifier is applied here. ShelfView( store: store.scope(state: \.repositories, action: \.repositories), terminalManager: terminalManager, createTab: { store.send(.newTerminal) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if repositories.isShowingArchivedWorktrees { + } else { + // Normal view mode (terminal, archived list, multi-selection, loading, + // repository detail, empty): tint the toolbar (top) and nav (leading) + // chrome, and pass the same fill into the terminal tab bar so its + // background reads as part of the same tinted chrome. + let normalFill = chromeFill(repositories: repositories, context: .normal) + normalModeContent( + repositories: repositories, + loadingInfo: loadingInfo, + selectedTerminalWorktree: selectedTerminalWorktree, + selectedWorktreeSummaries: selectedWorktreeSummaries, + barTint: normalFill + ) + .windowChromeTint(normalFill, edges: [.top, .leading]) + } + } + + @ViewBuilder + private func normalModeContent( + repositories: RepositoriesFeature.State, + loadingInfo: WorktreeLoadingInfo?, + selectedTerminalWorktree: Worktree?, + selectedWorktreeSummaries: [MultiSelectedWorktreeSummary], + barTint: WindowChromeTint.Fill? + ) -> some View { + if repositories.isShowingArchivedWorktrees { ArchivedWorktreesDetailView( store: store.scope(state: \.repositories, action: \.repositories) ) @@ -279,7 +314,8 @@ struct WorktreeDetailView: View { manager: terminalManager, shouldRunSetupScript: shouldRunSetupScript, forceAutoFocus: shouldFocusTerminal, - createTab: { store.send(.newTerminal) } + createTab: { store.send(.newTerminal) }, + barTint: barTint ) .id(selectedTerminalWorktree.id) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -298,6 +334,36 @@ struct WorktreeDetailView: View { } } + /// The chrome region a tint is being resolved for. + private enum ChromeContext { + case normal + case canvas + } + + /// Resolves the chrome band fill for the current view mode, honoring the + /// user's window tint setting. In `.repositoryColor` mode the band tracks + /// the active repository — the selected worktree's repo in Normal, the + /// focused card's repo in Canvas — falling back to a neutral surface when + /// none is colored. + private func chromeFill( + repositories: RepositoriesFeature.State, + context: ChromeContext + ) -> WindowChromeTint.Fill? { + let repositoryID: Repository.ID? = + switch context { + case .normal: + repositories.repositoryID(for: repositories.selectedWorktreeID) ?? repositories.selectedRepositoryID + case .canvas: + repositories.repositoryID(for: terminalManager.canvasFocusedWorktreeID) + } + let repositoryColor = repositoryID.flatMap { repositoryAppearances[$0]?.color } + return WindowChromeTint.fill( + mode: settingsFile.global.windowTintMode, + customColor: settingsFile.global.windowTintCustomColor.color, + repositoryColor: repositoryColor + ) + } + private func applyFocusedActions( content: Content, actions: FocusedActions diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 533b6517..cc1cbe72 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -29,6 +29,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool + var windowTintMode: WindowTintMode + var windowTintCustomColor: TintColor static let `default` = GlobalSettings( appearanceMode: .dark, @@ -60,7 +62,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { keybindingUserOverrides: .empty, defaultViewMode: .normal, dimUnfocusedSplits: true, - autoShowActiveAgentsPanel: false + autoShowActiveAgentsPanel: false, + windowTintMode: .repositoryColor, + windowTintCustomColor: .default ) init( @@ -93,7 +97,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { keybindingUserOverrides: KeybindingUserOverrideStore = .empty, defaultViewMode: DefaultViewMode = .normal, dimUnfocusedSplits: Bool = true, - autoShowActiveAgentsPanel: Bool = false + autoShowActiveAgentsPanel: Bool = false, + windowTintMode: WindowTintMode = .repositoryColor, + windowTintCustomColor: TintColor = .default ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -125,6 +131,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.defaultViewMode = defaultViewMode self.dimUnfocusedSplits = dimUnfocusedSplits self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel + self.windowTintMode = windowTintMode + self.windowTintCustomColor = windowTintCustomColor } func encode(to encoder: any Encoder) throws { @@ -159,6 +167,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(defaultViewMode, forKey: .defaultViewMode) try container.encode(dimUnfocusedSplits, forKey: .dimUnfocusedSplits) try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) + try container.encode(windowTintMode, forKey: .windowTintMode) + try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) } private enum CodingKeys: String, CodingKey { @@ -192,6 +202,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case defaultViewMode case dimUnfocusedSplits case autoShowActiveAgentsPanel + case windowTintMode + case windowTintCustomColor // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -290,5 +302,11 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoShowActiveAgentsPanel = try container.decodeIfPresent(Bool.self, forKey: .autoShowActiveAgentsPanel) ?? Self.default.autoShowActiveAgentsPanel + windowTintMode = + try container.decodeIfPresent(WindowTintMode.self, forKey: .windowTintMode) + ?? Self.default.windowTintMode + windowTintCustomColor = + try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) + ?? Self.default.windowTintCustomColor } } diff --git a/supacode/Features/Settings/Models/WindowTintMode.swift b/supacode/Features/Settings/Models/WindowTintMode.swift new file mode 100644 index 00000000..8b81fec3 --- /dev/null +++ b/supacode/Features/Settings/Models/WindowTintMode.swift @@ -0,0 +1,34 @@ +/// How Prowl tints its window chrome — the nav-panel band behind the +/// floating sidebar and the toolbar band behind the titlebar — across +/// every view mode (Normal, Shelf, Canvas). +/// +/// The Shelf spine is *not* governed by this setting: it always tints with +/// the open repo's pinned color (see `WindowChromeTint.repositoryBase`). +/// +/// Persistence: encoded as the raw `String` (case name). Cases must never +/// be renamed once shipped because user JSON references them by name. +enum WindowTintMode: String, CaseIterable, Identifiable, Codable, Sendable { + /// No chrome tint. The nav and toolbar fall back to the neutral system + /// chrome (the default, untinted look). + case none + /// Tint the chrome with the active repository's pinned color, matching + /// the Shelf spine. An uncolored repo falls back to a neutral surface + /// (near-black in dark mode / near-white in light). + case repositoryColor + /// Tint the chrome with a single user-chosen color, unconditionally — + /// ignores per-repository colors entirely. + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .none: + return "None" + case .repositoryColor: + return "Repository Color" + case .custom: + return "Custom Color" + } + } +} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index f8e9a31c..237c517c 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import SwiftUI @Reducer struct SettingsFeature { @@ -35,6 +36,11 @@ struct SettingsFeature { var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool + var windowTintMode: WindowTintMode + /// Mirrors `GlobalSettings.windowTintCustomColor` as a live `Color` so + /// the `ColorPicker` can bind to it directly; converted back to the + /// persistable `TintColor` at the `globalSettings` boundary. + var windowTintCustomColor: Color var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true var selection: SettingsSection? = .general @@ -74,6 +80,8 @@ struct SettingsFeature { defaultViewMode = settings.defaultViewMode dimUnfocusedSplits = settings.dimUnfocusedSplits autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel + windowTintMode = settings.windowTintMode + windowTintCustomColor = settings.windowTintCustomColor.color } var globalSettings: GlobalSettings { @@ -109,7 +117,9 @@ struct SettingsFeature { keybindingUserOverrides: keybindingUserOverrides, defaultViewMode: defaultViewMode, dimUnfocusedSplits: dimUnfocusedSplits, - autoShowActiveAgentsPanel: autoShowActiveAgentsPanel + autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, + windowTintMode: windowTintMode, + windowTintCustomColor: TintColor(windowTintCustomColor) ) } } @@ -212,6 +222,8 @@ struct SettingsFeature { state.defaultViewMode = normalizedSettings.defaultViewMode state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel + state.windowTintMode = normalizedSettings.windowTintMode + state.windowTintCustomColor = normalizedSettings.windowTintCustomColor.color state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index bc255217..59780295 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -46,6 +46,27 @@ struct AppearanceSettingsView: View { .font(.footnote) .foregroundStyle(.secondary) } + Section("Window Tint") { + Picker("Tint nav & toolbar", selection: $store.windowTintMode) { + ForEach(WindowTintMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .help( + "Color the navigation panel and toolbar. The Shelf spine always uses its repository color." + ) + if store.windowTintMode == .custom { + ColorPicker( + "Custom tint color", + selection: $store.windowTintCustomColor, + supportsOpacity: false + ) + .help("Tint the nav and toolbar with this color in every view, ignoring repository colors.") + } + Text(tintFootnote) + .font(.callout) + .foregroundStyle(.secondary) + } Section("Splits") { Toggle( "Dim unfocused split panes", @@ -97,4 +118,15 @@ struct AppearanceSettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private var tintFootnote: String { + switch store.windowTintMode { + case .none: + return "No tint. The nav and toolbar use the neutral system chrome." + case .repositoryColor: + return "Uses the active repository's color. Uncolored repositories get a neutral surface." + case .custom: + return "Uses your chosen color everywhere, regardless of per-repository colors." + } + } } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index c270a6c7..f121ec84 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -121,24 +121,26 @@ struct ShelfSpineView: View { /// "this is interactable" affordance that sits just below the open /// book and animates in/out smoothly. /// - /// The surface hue/alpha come from `ShelfSurfaceTint`: a repo with a - /// user-pinned color tints its spine with that color, while an uncolored - /// repo uses a neutral surface (near-black in dark mode, near-white in - /// light) so the shelf stays calm and the open book's spine reads as one - /// continuous "L" with the toolbar tint band above it. The proximity - /// ladder is unchanged — we only swap the base. + /// The surface hue/alpha come from `WindowChromeTint`'s repository-color + /// surface: a repo with a user-pinned color tints its spine with that + /// color, while an uncolored repo uses a neutral surface (near-black in + /// dark mode, near-white in light) so the shelf stays calm and the open + /// book's spine reads as one continuous "L" with the toolbar tint band + /// above it. The spine always uses the repo color and ignores the global + /// `WindowTintMode` — only the chrome bands honor that setting. The + /// proximity ladder is unchanged — we only swap the base. private var spineBackgroundColor: Color { guard distanceFromOpen != nil else { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return ShelfSurfaceTint.base(for: appearance.color) - .opacity(ShelfSurfaceTint.peakAlpha(for: appearance.color) * multiplier) + return WindowChromeTint.repositoryBase(for: appearance.color) + .opacity(WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier) } /// Repo's pinned color, or `.accentColor` when none — used as the header /// icon tint and the active-tab highlight. The spine *surface* fill - /// instead routes through `ShelfSurfaceTint` (neutral when uncolored), so + /// instead routes through `WindowChromeTint` (neutral when uncolored), so /// an uncolored repo keeps an accent icon / tab marker on an otherwise /// neutral spine. private var effectiveTintColor: Color { diff --git a/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift b/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift deleted file mode 100644 index 10c016ae..00000000 --- a/supacode/Features/Shelf/Views/ShelfSurfaceTint.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -/// Shared color logic for the Shelf's tinted *surfaces* — the spine -/// background fill and the toolbar tint band above the open book — so the -/// open book's spine and the band read as one continuous "L" framing the -/// terminal's top and left edges. -/// -/// A repo with a user-pinned color tints its surfaces with that color. An -/// uncolored repo falls back to a neutral surface that reads as near-black -/// in dark mode and near-white in light (`Color.primary` composited over -/// the window background at low alpha), so the shelf stays calm until the -/// user opts into a color. -/// -/// This intentionally covers only surface *fills*. The header icon and the -/// active-tab highlight keep using the app accent as their uncolored -/// fallback (see `ShelfSpineView.effectiveTintColor`), so an uncolored repo -/// still gets an accent icon / tab marker on its otherwise-neutral spine. -enum ShelfSurfaceTint { - /// Base hue for a surface fill: the pinned color, or `Color.primary` for - /// the neutral fallback. - static func base(for color: RepositoryColorChoice?) -> Color { - color?.color ?? .primary - } - - /// Peak fill alpha for the open book's surface (the band, and the open - /// spine at proximity 0). Independent of window transparency, so the - /// spine and the toolbar band stay in lockstep at the same alpha - /// regardless of `background-opacity`. The neutral fallback uses a - /// gentler peak because `Color.primary` reads as a distinct panel at far - /// lower opacity than a saturated hue (0.20 of primary would look like a - /// glaring mid-gray rather than the subtle near-black / near-white - /// surface we want). - static func peakAlpha(for color: RepositoryColorChoice?) -> Double { - color == nil ? 0.10 : 0.20 - } -} diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 45e03e2f..7fb0b40f 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -24,14 +24,10 @@ struct ShelfView: View { /// terminal surface and empty-state area. @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity @Shared(.repositoryAppearances) private var repositoryAppearances - /// Height of the window toolbar/titlebar. The window draws content under - /// the transparent titlebar, so the Shelf's top safe-area inset equals - /// the toolbar height — exactly the region the tint band should fill. - @State private var toolbarInset: CGFloat = 0 - /// Width of the floating glass sidebar. The detail is laid out full-bleed - /// beneath the sidebar, so the Shelf's leading safe-area inset equals the - /// sidebar width — the span the left tint band fills to color the nav. - @State private var sidebarInset: CGFloat = 0 + /// Drives the chrome tint mode / custom color. The Shelf *spine* always + /// uses the open repo's color regardless of this setting; only the + /// toolbar / nav bands below honor it. + @Shared(.settingsFile) private var settingsFile var body: some View { // Body-invocation counter. The @ViewBuilder getter rules out a @@ -50,6 +46,17 @@ struct ShelfView: View { // Color identity of the open book's repo (nil ⇒ neutral surface). Shared // by the spine fill and the toolbar band so they read as one "L". let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } + // Chrome band fill for the toolbar (top) and nav (leading), honoring the + // user's window tint mode. Only shown when a book is open; an empty + // shelf keeps its bare chrome. The spine itself is unaffected. + let chromeFill = + openBook == nil + ? nil + : WindowChromeTint.fill( + mode: settingsFile.global.windowTintMode, + customColor: settingsFile.global.windowTintCustomColor.color, + repositoryColor: openColor + ) HStack(spacing: 0) { ForEach(Array(books.enumerated()), id: \.element.id) { index, book in @@ -64,38 +71,11 @@ struct ShelfView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity)) - // Capture the toolbar/titlebar height so the band can fill exactly that - // region: content draws under the transparent titlebar, so the top - // safe-area inset measured here equals the toolbar height. - .onGeometryChange(for: CGFloat.self) { - $0.safeAreaInsets.top - } action: { - toolbarInset = $0 - } - // Capture the floating sidebar's width (= leading safe-area inset) so the - // left band can fill exactly the region behind it. - .onGeometryChange(for: CGFloat.self) { - $0.safeAreaInsets.leading - } action: { - sidebarInset = $0 - } - // Repo-colored band sitting behind the toolbar items, bleeding up into - // the titlebar via `ignoresSafeArea`. Only shown when a book is open; an - // empty shelf keeps its bare chrome. - .overlay(alignment: .top) { - if openBook != nil { - topTintBand(openColor: openColor) - } - } - // Repo-colored strip behind the floating glass sidebar. The glass blends - // with the detail content beneath it, so this strip (not the sidebar's - // own background, which the glass hides) is what tints the nav panel — - // a single uniform color matching the open spine and the toolbar band. - .overlay(alignment: .leading) { - if openBook != nil { - leftTintBand(openColor: openColor) - } - } + // Tint the toolbar (top) and nav (leading) chrome. The bands bleed up + // under the titlebar and left under the floating glass sidebar, so once + // the glass blends the leading band in, the nav panel, the open spine, + // and the toolbar all read as one continuous color. + .windowChromeTint(chromeFill, edges: [.top, .leading]) // Animate on every openBookID change — covers both Shelf-originated // book switches (which also set their own TCA animation) and // left-nav-originated switches, so the spine flow is consistent @@ -103,33 +83,6 @@ struct ShelfView: View { .animation(.easeInOut(duration: 0.2), value: openBookID) } - /// Tinted strip behind the toolbar items. Uses the exact same hue/alpha - /// as the open book's spine surface (`ShelfSurfaceTint`) so the band and - /// the spine join seamlessly. - private func topTintBand(openColor: RepositoryColorChoice?) -> some View { - ShelfSurfaceTint.base(for: openColor) - .opacity(ShelfSurfaceTint.peakAlpha(for: openColor)) - .frame(height: toolbarInset) - .frame(maxWidth: .infinity) - .ignoresSafeArea(.container, edges: .top) - .allowsHitTesting(false) - } - - /// Full-height strip filling the leading safe-area inset — i.e. the area - /// behind the floating glass sidebar. Uses the same hue/alpha as the spine - /// and the toolbar band, so once the glass blends it in, the nav panel, - /// the open spine, and the toolbar all read as one continuous color. - /// `.top` / `.bottom` are ignored too so it backs the sidebar's full - /// height, including its own toolbar region. - private func leftTintBand(openColor: RepositoryColorChoice?) -> some View { - ShelfSurfaceTint.base(for: openColor) - .opacity(ShelfSurfaceTint.peakAlpha(for: openColor)) - .frame(width: sidebarInset) - .frame(maxHeight: .infinity) - .ignoresSafeArea(.container, edges: [.leading, .top, .bottom]) - .allowsHitTesting(false) - } - @ViewBuilder private func spine(book: ShelfBook, index: Int, openIndex: Int?) -> some View { let distance = openIndex.map { abs(index - $0) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index 589c5499..1393e1b0 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -2,6 +2,12 @@ import SwiftUI struct TerminalTabBarView: View { @Bindable var manager: TerminalTabManager + /// Chrome tint painted as a full-width strip behind the whole bar row — + /// the left gap, the tabs, and the trailing accessories — so the bar's + /// backing reads as part of the same tinted chrome as the toolbar above. + /// The tabs keep their own neutral capsule on top. `nil` leaves the bare + /// (untinted) bar chrome. + var barTint: WindowChromeTint.Fill? let createTab: () -> Void let splitHorizontally: () -> Void let splitVertically: () -> Void @@ -28,8 +34,8 @@ struct TerminalTabBarView: View { closeAll: closeAll, hasNotification: hasNotification ) - // Background wraps only the tabs; the trailing accessories (+ / splits) - // sit outside it on the bare bar chrome. + // Capsule wraps only the tabs; the trailing accessories (+ / splits) + // sit outside it on the bar chrome. .background(TerminalTabBarBackground()) Spacer(minLength: 0) TerminalTabBarTrailingAccessories( @@ -40,7 +46,18 @@ struct TerminalTabBarView: View { ) } .frame(height: TerminalTabBarMetrics.barHeight) + // Desaturate only the bar *contents* (tabs + accessories) when the window + // is inactive — the tint band below stays out of this scope so it keeps + // its hue on blur, matching the toolbar / nav chrome (which don't gray out). .saturation(activeState == .inactive ? 0 : 1) + // Tint the full-width bar backing (behind the left gap, tabs capsule, and + // trailing accessories) so it joins the toolbar / nav chrome as one tinted + // surface. Fixed band alpha mirrors `windowChromeTint`; `nil` leaves it bare. + .background { + if let barTint { + barTint.color.opacity(barTint.alpha) + } + } .clipped() } } @@ -76,6 +93,7 @@ struct TerminalTabBarView: View { .foregroundStyle(.secondary) TerminalTabBarView( manager: manager, + barTint: nil, createTab: {}, splitHorizontally: {}, splitVertically: {}, diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 1db8ed53..4e5bcc95 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -7,6 +7,10 @@ struct WorktreeTerminalTabsView: View { let shouldRunSetupScript: Bool let forceAutoFocus: Bool let createTab: () -> Void + /// Chrome tint for the tab bar background, matching the toolbar / nav + /// bands so the bar reads as part of the same tinted chrome. `nil` keeps + /// the neutral system bar background. + var barTint: WindowChromeTint.Fill? @State private var windowActivity = WindowActivityState.inactive @State private var configReloadCounter = 0 @@ -55,6 +59,7 @@ struct WorktreeTerminalTabsView: View { private func tabBar(state: WorktreeTerminalState) -> some View { TerminalTabBarView( manager: state.tabManager, + barTint: barTint, createTab: createTab, splitHorizontally: { _ = state.performBindingActionOnFocusedSurface("new_split:down") diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift new file mode 100644 index 00000000..453d661f --- /dev/null +++ b/supacodeTests/WindowChromeTintTests.swift @@ -0,0 +1,55 @@ +import SwiftUI +import Testing + +@testable import supacode + +struct WindowChromeTintTests { + // MARK: - fill(mode:customColor:repositoryColor:) + + @Test + func noneModeNeverDrawsABand() { + #expect(WindowChromeTint.fill(mode: .none, customColor: nil, repositoryColor: nil) == nil) + #expect(WindowChromeTint.fill(mode: .none, customColor: .green, repositoryColor: .red) == nil) + } + + @Test + func repositoryColorModeUsesRepoColorAtSaturatedAlpha() { + let fill = WindowChromeTint.fill(mode: .repositoryColor, customColor: nil, repositoryColor: .red) + #expect(fill == WindowChromeTint.Fill(color: .red, alpha: WindowChromeTint.saturatedPeakAlpha)) + } + + @Test + func repositoryColorModeFallsBackToNeutralSurfaceWhenUncolored() { + let fill = WindowChromeTint.fill(mode: .repositoryColor, customColor: nil, repositoryColor: nil) + #expect(fill == WindowChromeTint.Fill(color: .primary, alpha: WindowChromeTint.neutralPeakAlpha)) + } + + @Test + func customModeUsesCustomColorAndIgnoresRepoColor() { + let fill = WindowChromeTint.fill(mode: .custom, customColor: .green, repositoryColor: .red) + #expect(fill == WindowChromeTint.Fill(color: .green, alpha: WindowChromeTint.saturatedPeakAlpha)) + } + + @Test + func customModeWithoutAColorDrawsNoBand() { + #expect(WindowChromeTint.fill(mode: .custom, customColor: nil, repositoryColor: .red) == nil) + } + + // MARK: - Repository surface helpers (shared with the Shelf spine) + + @Test + func repositoryPeakAlphaIsGentlerWhenUncolored() { + #expect(WindowChromeTint.repositoryPeakAlpha(for: nil) == WindowChromeTint.neutralPeakAlpha) + #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) + } + + // MARK: - TintColor persistence + + @Test + func tintColorRoundTripsThroughSRGBComponents() throws { + let original = TintColor(red: 0.2, green: 0.4, blue: 0.6) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(TintColor.self, from: data) + #expect(decoded == original) + } +} -- 2.51.2 From 6935de56dadb6bc1ece3660417f2d2c44890ce2d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 23:07:34 +0900 Subject: [PATCH 173/528] Tune tab bar and sidebar switcher heights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar view-mode switcher: bump the segment height 22 → 28. - Terminal tab bar: barHeight 33 → 31, tabHeight 32 → 30, so the bar reads at a comparable height to the switcher and sits flush under the toolbar (no extra gap). --- supacode/Features/Repositories/Views/SidebarListView.swift | 2 +- supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index acacbc70..22516ff4 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -223,7 +223,7 @@ struct SidebarListView: View { Image(systemName: systemImage) .accessibilityHidden(true) .frame(maxWidth: .infinity) - .frame(height: 22) + .frame(height: 28) .foregroundStyle(isSelected ? AnyShapeStyle(Color.white) : AnyShapeStyle(.secondary)) .background { if isSelected { diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 6ef6ea9d..e27b596b 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -1,9 +1,9 @@ import CoreGraphics enum TerminalTabBarMetrics { - static let barHeight: CGFloat = 33 + static let barHeight: CGFloat = 31 static let barPadding: CGFloat = 4 - static let tabHeight: CGFloat = 32 + static let tabHeight: CGFloat = 30 static let tabMinWidth: CGFloat = 140 static let tabCornerRadius: CGFloat = 0 static let tabSpacing: CGFloat = 0 -- 2.51.2 From 8dc41bc36a1ad81d1cdd24ac99ea437d4df698ae Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 23:07:41 +0900 Subject: [PATCH 174/528] Allow a custom (free) repository color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepositoryColorChoice gains a `.custom(TintColor)` case alongside the named presets, with backward-compatible Codable: presets still encode as their bare case-name string, custom encodes as a keyed object. - TintColor is now Hashable so the choice stays Hashable. - The repository color picker adds a trailing iOS-style rainbow wheel: a solid rainbow dot until a color is picked, then the chosen color in the middle with a small gap to a thin rainbow rim. Tapping opens the system color panel via a small ColorPanelController (NSColorPanel target/action), seeded with the current color. - Tighten the swatch row spacing (8 → 6). - Expand RepositoryColorChoiceTests for preset + custom round-trips and legacy-string decoding. --- supacode/Domain/RepositoryColorChoice.swift | 103 +++++++++++++++--- supacode/Domain/TintColor.swift | 2 +- .../RepositoryAppearancePickerView.swift | 94 +++++++++++++++- .../RepositoryColorChoiceTests.swift | 61 +++++++++-- 4 files changed, 235 insertions(+), 25 deletions(-) diff --git a/supacode/Domain/RepositoryColorChoice.swift b/supacode/Domain/RepositoryColorChoice.swift index 0647d8bd..5707ea94 100644 --- a/supacode/Domain/RepositoryColorChoice.swift +++ b/supacode/Domain/RepositoryColorChoice.swift @@ -1,16 +1,20 @@ import SwiftUI -/// One of a fixed palette of system-provided colors a user can pin to a -/// repository to make it identifiable in the sidebar, shelf spine, and -/// canvas card title bar. The palette is intentionally closed (10 colors) -/// to align with macOS Finder's tag colors and to keep `repoColor` a -/// purely semantic system color — never a custom hex — per the project's -/// "system provided only" rule. +/// A color a user can pin to a repository to make it identifiable in the +/// sidebar, shelf spine, and canvas card title bar — and, with window +/// tinting enabled, to tint the nav / toolbar chrome. /// -/// Persistence: encoded as the raw `String` (case name). New cases are -/// safe to append; cases must never be renamed once shipped because user -/// JSON references them by name. -nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, Hashable { +/// Either one of a fixed palette of system-provided colors (aligned with +/// macOS Finder's tag colors) or a free `.custom` color the user picks from +/// a color wheel. The named presets stay purely semantic system colors so +/// they adapt to light/dark mode; `.custom` is an explicit, user-driven +/// opt-out of that constraint (mirroring the window tint's custom color). +/// +/// Persistence: a preset encodes as its bare case-name `String` — identical +/// to the legacy `String`-rawValue representation, so existing user JSON +/// keeps decoding unchanged. `.custom` encodes as a keyed object so it is +/// visually distinct in the file and never collides with a preset name. +nonisolated enum RepositoryColorChoice: Codable, Sendable, Hashable { case red case orange case yellow @@ -21,6 +25,13 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case purple case pink case gray + case custom(TintColor) + + /// The named presets, in palette order. Excludes `.custom` (which carries + /// an associated value and is surfaced through its own picker affordance). + static let presets: [RepositoryColorChoice] = [ + .red, .orange, .yellow, .green, .mint, .cyan, .blue, .purple, .pink, .gray, + ] /// User-facing label for the color picker. var displayName: String { @@ -35,12 +46,13 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case .purple: "Purple" case .pink: "Pink" case .gray: "Gray" + case .custom: "Custom" } } - /// Resolved SwiftUI color. Only the bare named system colors are used - /// — never custom RGB — so the palette adapts to light/dark mode and - /// any future system tweaks. + /// Resolved SwiftUI color. Presets use the bare named system colors (never + /// custom RGB) so they adapt to light/dark mode; `.custom` resolves its + /// stored sRGB components. var color: Color { switch self { case .red: .red @@ -53,6 +65,71 @@ nonisolated enum RepositoryColorChoice: String, Codable, CaseIterable, Sendable, case .purple: .purple case .pink: .pink case .gray: .gray + case .custom(let tint): tint.color + } + } + + // MARK: - Codable + + /// Stable identifier for a preset — the legacy `String` raw value. `nil` + /// for `.custom`. + private var presetIdentifier: String? { + switch self { + case .red: "red" + case .orange: "orange" + case .yellow: "yellow" + case .green: "green" + case .mint: "mint" + case .cyan: "cyan" + case .blue: "blue" + case .purple: "purple" + case .pink: "pink" + case .gray: "gray" + case .custom: nil + } + } + + private init?(presetIdentifier: String) { + switch presetIdentifier { + case "red": self = .red + case "orange": self = .orange + case "yellow": self = .yellow + case "green": self = .green + case "mint": self = .mint + case "cyan": self = .cyan + case "blue": self = .blue + case "purple": self = .purple + case "pink": self = .pink + case "gray": self = .gray + default: return nil + } + } + + private enum CodingKeys: String, CodingKey { + case custom + } + + init(from decoder: any Decoder) throws { + // Preset / legacy form: a bare case-name string. + if let single = try? decoder.singleValueContainer(), + let name = try? single.decode(String.self), + let preset = Self(presetIdentifier: name) + { + self = preset + return + } + // Custom form: { "custom": }. + let container = try decoder.container(keyedBy: CodingKeys.self) + self = .custom(try container.decode(TintColor.self, forKey: .custom)) + } + + func encode(to encoder: any Encoder) throws { + if let presetIdentifier { + var container = encoder.singleValueContainer() + try container.encode(presetIdentifier) + } else if case .custom(let tint) = self { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tint, forKey: .custom) } } } diff --git a/supacode/Domain/TintColor.swift b/supacode/Domain/TintColor.swift index d61f0f09..07c9c377 100644 --- a/supacode/Domain/TintColor.swift +++ b/supacode/Domain/TintColor.swift @@ -9,7 +9,7 @@ import SwiftUI /// meaning). The project's "system colors only" rule is deliberately /// relaxed here: the custom tint is an explicit, user-driven free color /// choice, unlike the closed `RepositoryColorChoice` palette. -nonisolated struct TintColor: Codable, Equatable, Sendable { +nonisolated struct TintColor: Codable, Equatable, Hashable, Sendable { var red: Double var green: Double var blue: Double diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index f4d792ca..c894881c 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -19,6 +19,10 @@ struct RepositoryAppearancePickerView: View { @State private var isSymbolPickerPresented = false @State private var isHoveringIconTile = false + /// Retains the AppKit controller that drives the shared color panel for the + /// custom-color swatch. `NSColorPanel` keeps its target weakly, so this must + /// outlive each presentation — `@State` ties it to the view's lifetime. + @State private var colorPanel = ColorPanelController() private let previewSize: CGFloat = 40 /// Diameter of the colored swatch itself. @@ -206,10 +210,11 @@ struct RepositoryAppearancePickerView: View { Text("Color") .font(.headline) .frame(width: previewSize, alignment: .center) - HStack(spacing: 8) { - ForEach(RepositoryColorChoice.allCases, id: \.self) { choice in + HStack(spacing: 6) { + ForEach(RepositoryColorChoice.presets, id: \.self) { choice in colorSwatch(for: choice) } + customColorSwatch noColorSwatch Spacer(minLength: 0) } @@ -242,6 +247,67 @@ struct RepositoryAppearancePickerView: View { .buttonStyle(.plain) } + /// Trailing swatch for a free, user-picked color, styled like iOS's color + /// picker: a rainbow ring (the "any color" affordance) around the chosen + /// color. macOS has no built-in style for this, so the wheel is hand-drawn; + /// tapping opens the system color panel via `ColorPanelController`, which + /// streams the picked color back through `setAppearanceColor`. + @ViewBuilder + private var customColorSwatch: some View { + let isSelected = isCustomColorSelected + Button { + colorPanel.onColorChange = { newColor in + store.send(.setAppearanceColor(.custom(TintColor(newColor)))) + } + colorPanel.present(initialColor: currentCustomColor) + } label: { + swatchSlot(isSelected: isSelected) { + customColorWheel + } + .help("Custom color — pick any color") + .accessibilityLabel("Custom color") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + .buttonStyle(.plain) + } + + /// A solid rainbow dot when no custom color is set (the "pick any color" + /// affordance); once a custom color is chosen, that color sits in the middle + /// with a small gap to a thin rainbow rim. + @ViewBuilder + private var customColorWheel: some View { + let rainbow = AngularGradient( + colors: [.red, .orange, .yellow, .green, .cyan, .blue, .purple, .pink, .red], + center: .center + ) + if case .custom(let tint) = store.appearance.color { + ZStack { + Circle() + .strokeBorder(rainbow, lineWidth: 2) + .frame(width: swatchDotSize, height: swatchDotSize) + Circle() + .fill(tint.color) + .frame(width: swatchDotSize - 8, height: swatchDotSize - 8) + } + } else { + Circle() + .fill(rainbow) + .frame(width: swatchDotSize, height: swatchDotSize) + } + } + + private var isCustomColorSelected: Bool { + if case .custom = store.appearance.color { return true } + return false + } + + /// Seeds the system color panel: the current custom color, or — for a repo + /// that has never used a custom color — the currently selected preset color, + /// falling back to the accent tint when no color is set at all. + private var currentCustomColor: Color { + store.appearance.color?.color ?? .accentColor + } + @ViewBuilder private var noColorSwatch: some View { let isSelected = store.appearance.color == nil @@ -352,3 +418,27 @@ struct RepositoryAppearancePickerView: View { } } } + +/// Thin AppKit controller that opens the shared `NSColorPanel` for the custom +/// color swatch and forwards live color changes back to SwiftUI. macOS has no +/// SwiftUI API to present the color panel from an arbitrary control, so this +/// drives it directly. `NSColorPanel` holds its target weakly, so the owner +/// must keep this controller alive (see `RepositoryAppearancePickerView`). +@MainActor +private final class ColorPanelController: NSObject { + /// Invoked continuously while the user adjusts the panel's color. + var onColorChange: ((Color) -> Void)? + + func present(initialColor: Color) { + let panel = NSColorPanel.shared + panel.showsAlpha = false + panel.color = NSColor(initialColor) + panel.setTarget(self) + panel.setAction(#selector(colorDidChange(_:))) + panel.makeKeyAndOrderFront(nil) + } + + @objc private func colorDidChange(_ sender: NSColorPanel) { + onColorChange?(Color(nsColor: sender.color)) + } +} diff --git a/supacodeTests/RepositoryColorChoiceTests.swift b/supacodeTests/RepositoryColorChoiceTests.swift index 631052ed..965932e6 100644 --- a/supacodeTests/RepositoryColorChoiceTests.swift +++ b/supacodeTests/RepositoryColorChoiceTests.swift @@ -6,16 +6,21 @@ import Testing struct RepositoryColorChoiceTests { @Test func paletteHasTenSystemColors() { // The fixed palette is part of the persistence contract: once - // shipped, removing or renaming a case would break user JSON. This + // shipped, removing or renaming a preset would break user JSON. This // test pins the count so an accidental rename or removal trips a // failure before release. - #expect(RepositoryColorChoice.allCases.count == 10) + #expect(RepositoryColorChoice.presets.count == 10) } - @Test func paletteCasesAreStable() { - // Raw values are written to JSON; reordering allCases is fine but - // case names are forever. Pin them. - let names = RepositoryColorChoice.allCases.map(\.rawValue).sorted() + @Test func presetCaseNamesAreStable() throws { + // Presets encode as their bare case-name string (the legacy raw value); + // reordering `presets` is fine but case names are forever. Pin them by + // checking the encoded string for each preset. + let encoder = JSONEncoder() + let names = try RepositoryColorChoice.presets.map { choice -> String in + try JSONDecoder().decode(String.self, from: encoder.encode(choice)) + } + .sorted() #expect( names == [ "blue", @@ -32,10 +37,10 @@ struct RepositoryColorChoiceTests { ) } - @Test func codableRoundTrip() throws { + @Test func presetCodableRoundTrip() throws { let encoder = JSONEncoder() let decoder = JSONDecoder() - for choice in RepositoryColorChoice.allCases { + for choice in RepositoryColorChoice.presets { let data = try encoder.encode(choice) let decoded = try decoder.decode(RepositoryColorChoice.self, from: data) #expect(decoded == choice) @@ -43,8 +48,46 @@ struct RepositoryColorChoiceTests { } @Test func displayNameNonEmpty() { - for choice in RepositoryColorChoice.allCases { + for choice in RepositoryColorChoice.presets + [.custom(.default)] { #expect(!choice.displayName.isEmpty) } } + + // MARK: - Custom color + + @Test func customDisplayNameIsCustom() { + #expect(RepositoryColorChoice.custom(.default).displayName == "Custom") + } + + @Test func legacyStringDecodesToPreset() throws { + let data = Data("\"green\"".utf8) + let decoded = try JSONDecoder().decode(RepositoryColorChoice.self, from: data) + #expect(decoded == .green) + } + + @Test func customRoundTrips() throws { + let original = RepositoryColorChoice.custom(TintColor(red: 0.1, green: 0.2, blue: 0.3)) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(RepositoryColorChoice.self, from: data) + #expect(decoded == original) + } + + @Test func customEncodesAsObjectNotABareString() throws { + let data = try JSONEncoder().encode(RepositoryColorChoice.custom(.default)) + // A custom color must not be mistaken for a preset case-name string. + #expect((try? JSONDecoder().decode(String.self, from: data)) == nil) + } + + @Test func repositoryAppearanceRoundTripsWithCustomColor() throws { + let appearance = RepositoryAppearance(color: .custom(TintColor(red: 0.5, green: 0.6, blue: 0.7))) + let data = try JSONEncoder().encode(appearance) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded == appearance) + } + + @Test func legacyRepositoryAppearanceWithStringColorStillDecodes() throws { + let data = Data(#"{"color":"red"}"#.utf8) + let decoded = try JSONDecoder().decode(RepositoryAppearance.self, from: data) + #expect(decoded.color == .red) + } } -- 2.51.2 From eecdda08f532a823e4bfd1af5fe83e4e98b13486 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 23:30:34 +0900 Subject: [PATCH 175/528] Widen the settings window minimum width to 800 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the minimum width 750 → 800 in both the SwiftUI frame and the AppKit `window.minSize` so the floor is actually enforced (the AppKit minSize is the real constraint on how far the window can be dragged). --- .../Features/Settings/BusinessLogic/SettingsWindowManager.swift | 2 +- supacode/Features/Settings/Views/SettingsView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift index 7a9c7483..a0c2c6c9 100644 --- a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift +++ b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift @@ -61,7 +61,7 @@ final class SettingsWindowManager { window.isReleasedWhenClosed = false window.isExcludedFromWindowsMenu = true window.setContentSize(NSSize(width: 800, height: 600)) - window.minSize = NSSize(width: 750, height: 500) + window.minSize = NSSize(width: 800, height: 500) willCloseObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 8b891f67..11d9f2a2 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -128,7 +128,7 @@ struct SettingsView: View { } .navigationSplitViewStyle(.balanced) .alert(store: settingsStore.scope(state: \.$alert, action: \.alert)) - .frame(minWidth: 750, minHeight: 500) + .frame(minWidth: 800, minHeight: 500) .background { WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme) WindowLevelSetter(level: .normal) -- 2.51.2 From b6e8d3f218ad7321fdf06b096b6f157dd6dc9da5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 23 May 2026 23:30:35 +0900 Subject: [PATCH 176/528] Restore the bagua glyph working indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the active-agent working indicator from the 3×3 dot grid (driven by the display-linked `.animation` timeline, which rebuilt nine shapes every refresh and ran continuously per working agent) back to the single-glyph bagua spinner: a `.periodic` (~8 fps) timeline that swaps one trigram glyph per tick. Far cheaper — the panel idles between ticks and the cost no longer scales with the display refresh rate or the number of working agents. --- .../ActiveAgents/Views/ActiveAgentRow.swift | 70 +++++++------------ 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 52d11041..43ff4576 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -77,60 +77,40 @@ struct ActiveAgentRow: View { } } +/// Bagua trigram spinner: ping-pongs through the eight trigram glyphs as a +/// single `Text`. Deliberately driven by a coarse `.periodic` timeline +/// (~8 fps) that swaps one glyph per tick — far cheaper than redrawing a +/// per-dot grid on the display-linked `.animation` schedule, which rebuilt +/// nine shapes every refresh (60/120 fps) and ran continuously per working +/// agent. With several agents working at once the periodic glyph swap keeps +/// the panel idle between ticks. struct BaguaWorkingIndicator: View { - static let cycleDuration: Double = 1.0 - - static let perimeter: [(row: Int, col: Int)] = [ - (0, 0), (0, 1), (0, 2), - (1, 2), - (2, 2), (2, 1), (2, 0), - (1, 0), - ] + static let frames = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"] + static let frameDuration = 0.12 var body: some View { - TimelineView(.animation) { context in - let elapsed = context.date.timeIntervalSinceReferenceDate - let phase = - (elapsed / Self.cycleDuration).truncatingRemainder(dividingBy: 1) - * Double(Self.perimeter.count) - - VStack(spacing: 2) { - ForEach(0..<3, id: \.self) { row in - HStack(spacing: 2) { - ForEach(0..<3, id: \.self) { col in - dot(opacity: Self.opacity(row: row, col: col, phase: phase)) - } - } - } - } - .frame(width: 20, height: 18) - .accessibilityHidden(true) + TimelineView(.periodic(from: .now, by: Self.frameDuration)) { context in + frameText(Self.frame(at: context.date)) } } - // Dimmest opacity for off-pulse dots (and the always-static center). - static let baseOpacity: Double = 0.18 + static func frame(at date: Date) -> String { + frames[frameIndex(at: date)] + } - static func opacity(row: Int, col: Int, phase: Double) -> Double { - if row == 1 && col == 1 { return baseOpacity } - guard - let index = perimeter.firstIndex(where: { $0.row == row && $0.col == col }) - else { - return baseOpacity - } - let count = Double(perimeter.count) - let raw = abs(Double(index) - phase) - // Wrap the distance so the pulse stays continuous across the seam. - let distance = min(raw, count - raw) - let intensity = max(0, 1 - distance / 3) - return baseOpacity + intensity * (1 - baseOpacity) + static func frameIndex(at date: Date) -> Int { + let tick = Int(date.timeIntervalSinceReferenceDate / frameDuration) + let cycleLength = (frames.count * 2) - 2 + let cycleIndex = ((tick % cycleLength) + cycleLength) % cycleLength + return cycleIndex < frames.count ? cycleIndex : cycleLength - cycleIndex } - private func dot(opacity: Double) -> some View { - Circle() - .fill(.foreground) - .frame(width: 4, height: 4) - .opacity(opacity) + private func frameText(_ frame: String) -> some View { + Text(frame) + .font(.system(size: 17, weight: .bold, design: .monospaced)) + .lineLimit(1) + .frame(width: 20, height: 18) + .accessibilityHidden(true) } } -- 2.51.2 From 7e558ef0f7de3b3b603ed24dddf05a7f74364c19 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:10:07 +0900 Subject: [PATCH 177/528] Fix BaguaWorkingIndicatorTests after the glyph revert Restoring the glyph spinner (b6e8d3f2) removed the dot-grid's `perimeter` / `opacity` / `baseOpacity` members that these tests referenced, breaking the test target's compile (the app target and `make check` don't surface it). Restore the glyph-era test (`frames` + `frame(at:)`) to match the current implementation. --- .../BaguaWorkingIndicatorTests.swift | 52 ++++--------------- 1 file changed, 10 insertions(+), 42 deletions(-) diff --git a/supacodeTests/BaguaWorkingIndicatorTests.swift b/supacodeTests/BaguaWorkingIndicatorTests.swift index f8a51d5a..e221d2bd 100644 --- a/supacodeTests/BaguaWorkingIndicatorTests.swift +++ b/supacodeTests/BaguaWorkingIndicatorTests.swift @@ -4,50 +4,18 @@ import Testing @testable import supacode struct BaguaWorkingIndicatorTests { - @Test func perimeterTracesTheRingClockwise() { - #expect( - BaguaWorkingIndicator.perimeter.map { [$0.row, $0.col] } - == [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [2, 0], [1, 0]] - ) + @Test func framesUseFullTrigramSequence() { + #expect(BaguaWorkingIndicator.frames == ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"]) } - @Test func centerDotStaysAtBaseOpacity() { - // The middle cell is never part of the pulse, regardless of phase. - #expect(BaguaWorkingIndicator.opacity(row: 1, col: 1, phase: 0) == BaguaWorkingIndicator.baseOpacity) - #expect(BaguaWorkingIndicator.opacity(row: 1, col: 1, phase: 4) == BaguaWorkingIndicator.baseOpacity) - } - - @Test func pulseHeadIsFullyLit() { - // When phase aligns with a perimeter index, that dot reaches full intensity. - let head = BaguaWorkingIndicator.perimeter[0] - #expect(BaguaWorkingIndicator.opacity(row: head.row, col: head.col, phase: 0) == 1) - } - - @Test func opacityFallsOffWithPerimeterDistance() { - // Neighbors of the pulse head are dimmer, and dots far away sit at base. - let head = BaguaWorkingIndicator.perimeter[0] - let neighbor = BaguaWorkingIndicator.perimeter[1] - let opposite = BaguaWorkingIndicator.perimeter[4] - - let headOpacity = BaguaWorkingIndicator.opacity(row: head.row, col: head.col, phase: 0) - let neighborOpacity = BaguaWorkingIndicator.opacity(row: neighbor.row, col: neighbor.col, phase: 0) - let oppositeOpacity = BaguaWorkingIndicator.opacity(row: opposite.row, col: opposite.col, phase: 0) - - #expect(headOpacity > neighborOpacity) - #expect(neighborOpacity > oppositeOpacity) - #expect(oppositeOpacity == BaguaWorkingIndicator.baseOpacity) - } + @Test func frameSelectionLoopsByDuration() { + let duration = BaguaWorkingIndicator.frameDuration - @Test func pulseWrapsAcrossTheSeam() { - // Distance is measured the short way around, so the last dot lights up - // as the pulse approaches the wrap point from index 0. - let last = BaguaWorkingIndicator.perimeter.count - 1 - let lastDot = BaguaWorkingIndicator.perimeter[last] - let opacityNearSeam = BaguaWorkingIndicator.opacity( - row: lastDot.row, - col: lastDot.col, - phase: 0 - ) - #expect(opacityNearSeam > BaguaWorkingIndicator.baseOpacity) + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: 0)) == "☰") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration)) == "☱") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 7)) == "☷") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 8)) == "☶") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 13)) == "☱") + #expect(BaguaWorkingIndicator.frame(at: Date(timeIntervalSinceReferenceDate: duration * 14)) == "☰") } } -- 2.51.2 From 18de41f29c590f758cb341fee1f9ec0dbee20edb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:10:15 +0900 Subject: [PATCH 178/528] Improve the zero-repository empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: drop the misleading "Add your first repository" arrow hint (the Add Repository button moved to the toolbar) and show a centered "Repositories you add will appear here" placeholder instead; the detail pane's EmptyStateView carries the actual Add Repository button. - EmptyStateView: use the folder.badge.plus icon to match the toolbar's Add Repository control (was `tray`). - Force Normal view with zero repositories: `isShowingShelf` now also requires at least one repository, so a zero-repo "Default View = Shelf" launch can't stick on an empty Shelf — covering the launch race where the repository snapshot flips `isShelfActive` on before the empty entries reconcile repos back to zero. Also disable the Canvas/Shelf view-mode buttons while there are no repositories. - Nudge the sidebar (40pt) and active-agents (8pt) empty placeholders up for visual balance. - Add a test pinning the isShowingShelf zero-repo guard. --- .../Views/ActiveAgentsPanel.swift | 2 + .../Reducer/RepositoriesFeature.swift | 8 ++- .../Repositories/Views/EmptyStateView.swift | 2 +- .../Repositories/Views/SidebarListView.swift | 55 ++++++++++--------- supacodeTests/ShelfFeatureTests.swift | 28 ++++++++++ 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 0ed7a856..46562ac3 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -33,6 +33,8 @@ struct ActiveAgentsPanel: View { Text("New agents will appear here") .font(.callout) .foregroundStyle(.secondary) + // Nudge up slightly off dead-center for better visual balance. + .offset(y: -8) Spacer(minLength: 0) } else { ScrollView { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 97106a29..ff0c219a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1706,7 +1706,13 @@ extension RepositoriesFeature.State { } var isShowingShelf: Bool { - isShelfActive + // Shelf needs at least one repository to render. Guarding here (not just + // on entry) also covers the launch race where the repository snapshot + // briefly repopulates books and flips `isShelfActive` on before the empty + // entries file reconciles repos back to zero — without this a zero-repo + // launch with "Default View = Shelf" would stick on an empty Shelf instead + // of falling back to Normal. + isShelfActive && !repositories.isEmpty } var topSegment: TopSegment { diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 8d800bbf..7569678b 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -8,7 +8,7 @@ struct EmptyStateView: View { var body: some View { let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) ContentUnavailableView { - Label("Open a repository or folder", systemImage: "tray") + Label("Open a repository or folder", systemImage: "folder.badge.plus") } description: { Text(promptText(shortcutDisplay: shortcutDisplay)) } actions: { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 22516ff4..6e433b27 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -79,9 +79,10 @@ struct SidebarListView: View { ScrollViewReader { scrollProxy in ScrollView { LazyVStack(spacing: 0) { - if repositoryItems.isEmpty { - emptyRepositoryHint() - } else { + // When there are no repositories the sidebar stays empty — the + // detail pane's `EmptyStateView` ("Open a repository or folder") + // carries the prompt and the Add Repository button instead. + if !repositoryItems.isEmpty { repositoryListHeader( action: repositoryListHeaderAction, expandableRepositoryIDs: expandableRepositoryIDs @@ -129,6 +130,18 @@ struct SidebarListView: View { } .padding(.vertical, 4) } + .overlay { + if repositoryItems.isEmpty { + Text("Repositories you add will appear here") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + // Sit above dead-center for better visual balance in the tall panel. + .offset(y: -40) + .accessibilityAddTraits(.isStaticText) + } + } .overlay(alignment: .bottom) { ActiveAgentsPanel( store: store.scope(state: \.activeAgents, action: \.activeAgents), @@ -192,13 +205,15 @@ struct SidebarListView: View { .canvas, systemImage: "square.grid.2x2", title: "Canvas", - shortcutCommandID: AppShortcuts.CommandID.toggleCanvas + shortcutCommandID: AppShortcuts.CommandID.toggleCanvas, + requiresRepository: true ) topSegmentButton( .shelf, systemImage: "distribute.horizontal.fill", title: "Shelf", - shortcutCommandID: AppShortcuts.CommandID.toggleShelf + shortcutCommandID: AppShortcuts.CommandID.toggleShelf, + requiresRepository: true ) } .background(.thinMaterial, in: Capsule()) @@ -210,11 +225,17 @@ struct SidebarListView: View { _ segment: TopSegment, systemImage: String, title: String, - shortcutCommandID: String? = nil + shortcutCommandID: String? = nil, + requiresRepository: Bool = false ) -> some View { let isSelected = store.topSegment == segment + // Canvas and Shelf need at least one repository; with none, only Normal + // (Default) is available, so disable them. + let isDisabled = requiresRepository && store.repositories.isEmpty let helpText = - shortcutCommandID.map { + isDisabled + ? "\(title) — add a repository first" + : shortcutCommandID.map { AppShortcuts.helpText(title: title, commandID: $0, in: resolvedKeybindings) } ?? title return Button { @@ -233,8 +254,10 @@ struct SidebarListView: View { } } .contentShape(.capsule) + .opacity(isDisabled ? 0.35 : 1) } .buttonStyle(.plain) + .disabled(isDisabled) .help(helpText) .accessibilityLabel(Text(title)) } @@ -290,24 +313,6 @@ struct SidebarListView: View { .padding(.bottom, 4) } - private func emptyRepositoryHint() -> some View { - HStack(spacing: 6) { - Spacer(minLength: 0) - Text("Add your first repository") - .font(.caption) - .foregroundStyle(.secondary) - Image(systemName: "arrow.turn.right.up") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .symbolEffect(.pulse, options: .repeating) - .accessibilityHidden(true) - } - .padding(.leading, 12) - .padding(.trailing, 14) - .padding(.top, 2) - .padding(.bottom, 6) - } - @ViewBuilder private func repositoryItemView( _ item: SidebarItem, diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 4d8570ea..2a942fc6 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -929,4 +929,32 @@ struct ShelfFeatureTests { // No `.toggleShelf` here — the Layout Restore path is responsible. await store.finish() } + + @Test func isShowingShelfRequiresAtLeastOneRepository() { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: [] + ) + + // Active shelf with zero repositories falls back to Normal — covers the + // zero-repo launch race where `isShelfActive` is flipped on (from the + // repository snapshot) before the empty entries file reconciles repos + // back to empty. + var empty = RepositoriesFeature.State() + empty.isShelfActive = true + #expect(empty.repositories.isEmpty) + #expect(empty.isShowingShelf == false) + + // With a repository present, the active shelf renders. + var withRepo = RepositoriesFeature.State(repositories: [repository]) + withRepo.isShelfActive = true + #expect(withRepo.isShowingShelf == true) + + // Inactive shelf is never showing. + withRepo.isShelfActive = false + #expect(withRepo.isShowingShelf == false) + } } -- 2.51.2 From 41f490431422c2a4cabbbeebca11307cfc62b2e6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:45:31 +0900 Subject: [PATCH 179/528] Extend the tab bar tint across the bottom gap The 4pt VStack spacing between the terminal tab bar and the surface was a transparent seam, so a translucent strip showed through when background-opacity < 1. Move the gap inside the tab bar as bottom padding under the tint band, so the chrome tint reads as one continuous surface down to the terminal. --- .../Terminal/TabBar/TerminalTabBarMetrics.swift | 5 +++++ .../TabBar/Views/TerminalTabBarView.swift | 15 +++++++++++---- .../Terminal/Views/WorktreeTerminalTabsView.swift | 7 +++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index e27b596b..cf249bf8 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -3,6 +3,11 @@ import CoreGraphics enum TerminalTabBarMetrics { static let barHeight: CGFloat = 31 static let barPadding: CGFloat = 4 + // Gap between the tab bar and the terminal surface below it. The chrome + // tint band extends across this gap so it reads as one continuous surface + // instead of revealing the translucent window background when + // `background-opacity` < 1. + static let barBottomGap: CGFloat = 4 static let tabHeight: CGFloat = 30 static let tabMinWidth: CGFloat = 140 static let tabCornerRadius: CGFloat = 0 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index 1393e1b0..c1b28e6e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -3,7 +3,8 @@ import SwiftUI struct TerminalTabBarView: View { @Bindable var manager: TerminalTabManager /// Chrome tint painted as a full-width strip behind the whole bar row — - /// the left gap, the tabs, and the trailing accessories — so the bar's + /// the left gap, the tabs, and the trailing accessories — and extended + /// down across the bottom gap to the terminal surface, so the bar's /// backing reads as part of the same tinted chrome as the toolbar above. /// The tabs keep their own neutral capsule on top. `nil` leaves the bare /// (untinted) bar chrome. @@ -50,9 +51,15 @@ struct TerminalTabBarView: View { // is inactive — the tint band below stays out of this scope so it keeps // its hue on blur, matching the toolbar / nav chrome (which don't gray out). .saturation(activeState == .inactive ? 0 : 1) - // Tint the full-width bar backing (behind the left gap, tabs capsule, and - // trailing accessories) so it joins the toolbar / nav chrome as one tinted - // surface. Fixed band alpha mirrors `windowChromeTint`; `nil` leaves it bare. + // Reserve the gap down to the terminal *inside* the tinted region (added + // after saturation, before the tint) so the band fills it instead of the + // parent leaving a transparent seam that reveals the translucent window + // background when `background-opacity` < 1. + .padding(.bottom, TerminalTabBarMetrics.barBottomGap) + // Tint the full-width bar backing (behind the left gap, tabs capsule, + // trailing accessories, and the bottom gap) so it joins the toolbar / nav + // chrome as one tinted surface. Fixed band alpha mirrors `windowChromeTint`; + // `nil` leaves it bare. .background { if let barTint { barTint.color.opacity(barTint.alpha) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 4e5bcc95..1d0de786 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -17,8 +17,11 @@ struct WorktreeTerminalTabsView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - // 4pt gap between the tab bar and the terminal surface below it. - VStack(spacing: 4) { + // The gap between the tab bar and the terminal surface lives inside the + // tab bar (`TerminalTabBarMetrics.barBottomGap`) so the chrome tint band + // fills it, rather than a transparent VStack seam that reveals the + // translucent window background when `background-opacity` < 1. + VStack(spacing: 0) { tabBar(state: state) tabContent(state: state) } -- 2.51.2 From 09e9173cde3031f2afe0ebc41423e36c09aca53e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:45:35 +0900 Subject: [PATCH 180/528] Brighten the nav view picker track to match the tab bar The view picker's glass track sat darker than the terminal tab bar's capsule. Overlay the same TerminalTabBarColors.barBackground fill on the .thinMaterial track so the inactive segments read at the same level as the tab bar. --- .../Features/Repositories/Views/SidebarListView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 6e433b27..fdcd5af4 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -216,7 +216,14 @@ struct SidebarListView: View { requiresRepository: true ) } - .background(.thinMaterial, in: Capsule()) + .background { + // Glass track, brightened by the same fill the terminal tab bar's capsule + // uses so the inactive (unselected) segments read at the same level as the + // tab bar instead of sitting darker on the bare material. + Capsule() + .fill(.thinMaterial) + .overlay(Capsule().fill(TerminalTabBarColors.barBackground)) + } .padding(.horizontal, 8) .padding(.vertical, 2) } -- 2.51.2 From 0ca9bc9f4b24b5baa8c5b7b8cd75db75e634f4a1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:46:23 +0900 Subject: [PATCH 181/528] Show xcsift warnings in build-app output Drop the -q (quiet) flag from the build-app xcsift pipe so warnings surface during the Debug build instead of being suppressed. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 21b17ac3..b7494728 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,7 @@ sync-ghostty: # Force sync GhosttyKit to current submodule HEAD (always rebuilds @echo "Done. Xcode module cache cleared for fresh compilation." build-app: ensure-ghostty embed-cli-debug # Build the macOS app (Debug) - 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' + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -w --format toon' sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion.swift @version="$$(/usr/bin/awk -F' = ' '/MARKETING_VERSION = [0-9.]*;/{gsub(/;/,"",$$2);print $$2; exit}' \ -- 2.51.2 From a15ba2915312f25ffa52f2e92e593a02c8a7d27a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 00:55:07 +0900 Subject: [PATCH 182/528] Move the tab close button to the leading edge Align the terminal tab close affordance with native macOS tabs (Safari/Finder), where the close button sits on the left. Move the close button, its notification dot, and the shortcut-hint overlay into the leading slot, and mirror the rename-field reservation and the icon-picker double-click hit zone to the trailing placeholder so they stay clear of it. --- .../TabBar/Views/TerminalTabLabelView.swift | 3 +- .../TabBar/Views/TerminalTabView.swift | 28 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 7b4122d8..683cbc20 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -30,7 +30,8 @@ struct TerminalTabLabelView: View { .frame(width: TerminalTabBarMetrics.closeButtonSize) } .frame(maxHeight: .infinity) - .overlay(alignment: .trailing) { + // Leading, sharing the close button's slot (they are mutually exclusive). + .overlay(alignment: .leading) { if showsShortcutHint, let shortcutHint { ShortcutHintView(text: shortcutHint, color: TerminalTabBarColors.inactiveText) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index dccd6b7a..501b7883 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -23,11 +23,12 @@ struct TerminalTabView: View { @State private var editingTitle = "" @State private var initialEditingTitle = "" @State private var cancelOnExit = false + @State private var tabWidth: CGFloat = 0 @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { - ZStack(alignment: .trailing) { + ZStack(alignment: .leading) { Button(action: onSelect) { TerminalTabLabelView( tab: tab, @@ -67,7 +68,7 @@ struct TerminalTabView: View { } .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) .animation(.easeInOut(duration: 0.2), value: hasNotification) - .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) + .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding) .opacity(isEditing ? 0 : 1) .allowsHitTesting(!isEditing) } @@ -102,8 +103,8 @@ struct TerminalTabView: View { ) .accessibilityLabel("Rename tab") } - .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding) - .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.leading, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } @@ -122,6 +123,11 @@ struct TerminalTabView: View { .onHover { hovering in isHovering = hovering } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { width in + tabWidth = width + } .simultaneousGesture( SpatialTapGesture(count: 2, coordinateSpace: .local).onEnded { value in if isInIconHitArea(value.location) { @@ -160,14 +166,16 @@ struct TerminalTabView: View { hasNotification && !isHovering && !isHoveringClose && !isDragging && !showsShortcutHint } - /// Hit zone for the icon in tab-local coordinates. Covers the leading - /// padding plus the icon column, so a double-click anywhere on the icon - /// (or the empty strip just to its left) opens the icon picker. The rest - /// of the tab routes to inline rename. + /// Hit zone for the icon-picker double-click, in tab-local coordinates. + /// The close button now lives in the leading slot, so this mirrors to the + /// trailing placeholder (the opposite slot) to stay clear of it: a + /// double-click there opens the icon picker, while the rest of the tab + /// routes to inline rename. private func isInIconHitArea(_ point: CGPoint) -> Bool { guard tab.isDirty || tab.icon != nil else { return false } - let maxX = TerminalTabBarMetrics.tabHorizontalPadding + TerminalTabBarMetrics.closeButtonSize - return point.x >= 0 && point.x <= maxX + guard tabWidth > 0 else { return false } + let minX = tabWidth - TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.closeButtonSize + return point.x >= minX } } -- 2.51.2 From 5cfad79ceb615cb6249d9152ab25d77ecce43557 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 13:14:51 +0900 Subject: [PATCH 183/528] Leave the Canvas toolbar untinted The Canvas chrome tint painted both the nav (leading) and the toolbar (top) band. With cards floating freely under the transparent titlebar, a tinted top band reads poorly behind the cards, so tint the nav only and leave the toolbar untinted. --- .../Features/Repositories/Views/WorktreeDetailView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 3c75b621..bb45946a 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -258,9 +258,10 @@ struct WorktreeDetailView: View { store.send(.repositories(.toggleCanvas)) } ) - // Canvas tints the nav (leading) and the toolbar (top); the card - // title bars additionally carry their own per-repo color. - .windowChromeTint(chromeFill(repositories: repositories, context: .canvas), edges: [.top, .leading]) + // Canvas tints the nav (leading) only; the toolbar is left untinted so + // floating cards don't read against a colored band. The card title + // bars still carry their own per-repo color. + .windowChromeTint(chromeFill(repositories: repositories, context: .canvas), edges: [.leading]) } else if repositories.isShowingShelf { // Shelf manages its own chrome bands (and its always-repo-colored // spine) inside `ShelfView`, so no tint modifier is applied here. -- 2.51.2 From 43b8dda06ecd6ac49311f0c59998f0cd719a3070 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 17:31:05 +0900 Subject: [PATCH 184/528] Sync new surface focus state to unfocused on creation A freshly created Ghostty surface defaults to focused, so its cursor blinks until something explicitly unfocuses it. The restore path calls `focusDidChange(false)` on every restored surface, but that no-ops when `self.focused` is already false (its initial value), so the unfocus never reaches Ghostty. After a session restore, every non-selected card on the Canvas kept a blinking cursor even though only one was selected. Push the focus state directly in `createSurface()` so Ghostty starts in sync with the Swift-side `focused` flag. The selected surface is still lit via the normal `focusDidChange(true)` path, and normally created tabs are unaffected since they are explicitly focused after creation. --- supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 0c297488..0eab1c20 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1041,6 +1041,11 @@ final class GhosttySurfaceView: NSView, Identifiable { bridge.surface = surface occlusionState.reset() lastSurfaceFocus = nil + // A freshly created Ghostty surface defaults to focused (blinking cursor). + // Sync it to our Swift-side `focused` flag so background/restored surfaces + // that were never explicitly focused don't blink. `focusDidChange(false)` + // would no-op here (self.focused already false), so push the state directly. + setSurfaceFocus(focused) updateSurfaceSize() } -- 2.51.2 From 0a196b2178d403670b072b9c71cc52b614d88a73 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 17:53:28 +0900 Subject: [PATCH 185/528] Fix CI parallel test step masking build/test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_task` read `$?` right after an `if cmd; then ...; fi` block to capture a failure code. But when the command fails (condition false, no else branch) the `if` compound itself exits 0, so `$?` was 0 — the real exit code was lost. `run_task` always returned 0, every `wait` succeeded, and the step exited 0 even when `make test-app` failed to compile. CI reported success on builds that did not compile. Capture the command's own exit status via `|| status=$?` instead. --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ff47a03..1d836eaf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,12 +37,12 @@ jobs: shift local log="build/ci-logs/${name}.log" echo "::group::${name}" - if "$@" >"$log" 2>&1; then - cat "$log" - echo "::endgroup::" - return 0 - fi - local status=$? + # Capture the command's own exit code. Reading $? after an + # `if cmd; then ...; fi` block yields the `if` status (0 when the + # condition is false and there is no else), which silently masked + # failures and let the step pass on a failing build. + local status=0 + "$@" >"$log" 2>&1 || status=$? cat "$log" echo "::endgroup::" return "$status" -- 2.51.2 From 2fb79a820c7abe299b0385dd5aad84f2ac5aacf7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 19:11:57 +0900 Subject: [PATCH 186/528] =?UTF-8?q?Add=20=E2=8C=83=E2=8C=A5=E2=86=91/?= =?UTF-8?q?=E2=86=93=20shortcuts=20to=20navigate=20Active=20Agents=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Select Next/Previous Agent commands that step focus through the Active Agents panel, anchored on the currently focused surface and wrapping at the ends. Navigation reuses the existing entryTapped flow (select worktree + focus surface); the anchor is kept in sync via the terminal focusChanged event. --- supacode/App/AppShortcuts.swift | 24 ++++++ supacode/Commands/SidebarCommands.swift | 16 ++++ .../Reducer/ActiveAgentsFeature.swift | 62 ++++++++++++++ .../Features/App/Reducer/AppFeature.swift | 5 ++ supacodeTests/ActiveAgentsFeatureTests.swift | 82 +++++++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 0280cd5e..39a33186 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -93,6 +93,8 @@ enum AppShortcuts { static let openPullRequest = "open_pull_request" static let toggleLeftSidebar = "toggle_left_sidebar" static let toggleActiveAgentsPanel = "toggle_active_agents_panel" + static let selectNextActiveAgent = "select_next_active_agent" + static let selectPreviousActiveAgent = "select_previous_active_agent" static let refreshWorktrees = "refresh_worktrees" static let jumpToLatestUnread = "jump_to_latest_unread" static let runScript = "run_script" @@ -182,6 +184,12 @@ enum AppShortcuts { static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) static let toggleLeftSidebar = AppShortcut(key: "s", modifiers: [.command, .control]) static let toggleActiveAgentsPanel = AppShortcut(key: "p", modifiers: [.command, .option]) + static let selectNextActiveAgent = AppShortcut( + keyEquivalent: .downArrow, ghosttyKeyName: "arrow_down", modifiers: [.control, .option] + ) + static let selectPreviousActiveAgent = AppShortcut( + keyEquivalent: .upArrow, ghosttyKeyName: "arrow_up", modifiers: [.control, .option] + ) static let refreshWorktrees = AppShortcut(key: "r", modifiers: [.command, .shift]) static let jumpToLatestUnread = AppShortcut(key: "u", modifiers: [.command, .option]) static let runScript = AppShortcut(key: "r", modifiers: .command) @@ -330,6 +338,8 @@ enum AppShortcuts { .init(actionTitle: "Open Settings", shortcut: openSettings), .init(actionTitle: "Toggle Left Sidebar", shortcut: toggleLeftSidebar), .init(actionTitle: "Toggle Active Agents Panel", shortcut: toggleActiveAgentsPanel), + .init(actionTitle: "Select Next Agent", shortcut: selectNextActiveAgent), + .init(actionTitle: "Select Previous Agent", shortcut: selectPreviousActiveAgent), .init(actionTitle: "Jump to Latest Unread", shortcut: jumpToLatestUnread), .init(actionTitle: "Run Script", shortcut: runScript), .init(actionTitle: "Stop Script", shortcut: stopRunScript), @@ -399,6 +409,18 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: toggleActiveAgentsPanel ), + .init( + id: CommandID.selectNextActiveAgent, + title: "Select Next Agent", + scope: .configurableAppAction, + shortcut: selectNextActiveAgent + ), + .init( + id: CommandID.selectPreviousActiveAgent, + title: "Select Previous Agent", + scope: .configurableAppAction, + shortcut: selectPreviousActiveAgent + ), .init( id: CommandID.refreshWorktrees, title: "Refresh Worktrees", @@ -864,6 +886,8 @@ enum AppShortcuts { openPullRequest, toggleLeftSidebar, toggleActiveAgentsPanel, + selectNextActiveAgent, + selectPreviousActiveAgent, revealInSidebar, refreshWorktrees, jumpToLatestUnread, diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 0b4da087..01727d70 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -28,6 +28,22 @@ struct SidebarCommands: Commands { KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleActiveAgentsPanel)) ) .help(helpText(title: "Active Agents", commandID: AppShortcuts.CommandID.toggleActiveAgentsPanel)) + Button("Select Next Agent") { + store.send(.repositories(.activeAgents(.selectNextEntry))) + } + .modifier( + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectNextActiveAgent)) + ) + .help(helpText(title: "Select Next Agent", commandID: AppShortcuts.CommandID.selectNextActiveAgent)) + Button("Select Previous Agent") { + store.send(.repositories(.activeAgents(.selectPreviousEntry))) + } + .modifier( + KeyboardShortcutModifier( + shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectPreviousActiveAgent) + ) + ) + .help(helpText(title: "Select Previous Agent", commandID: AppShortcuts.CommandID.selectPreviousActiveAgent)) Button("Canvas") { store.send(.repositories(.toggleCanvas)) } diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index c1bdd77b..650bdaf8 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -9,9 +9,18 @@ struct ActiveAgentsFeature { static let maximumPanelHeight = 560.0 static let reservedSidebarListHeight = 200.0 + /// Direction for keyboard navigation across the agent list. + enum NavigationDirection { + case next + case previous + } + @ObservableState struct State: Equatable { var entries: IdentifiedArrayOf = [] + /// Surface that currently has terminal focus, mirrored from `focusChanged` events. + /// Used as the anchor for keyboard list navigation; not persisted. + var focusedSurfaceID: UUID? @Shared(.appStorage("activeAgentsPanelHidden")) var isPanelHidden: Bool = false @Shared(.appStorage("activeAgentsPanelHeight")) var panelHeight: Double = 200 } @@ -20,6 +29,9 @@ struct ActiveAgentsFeature { case agentEntryChanged(ActiveAgentEntry, autoShowPanel: Bool) case agentEntryRemoved(ActiveAgentEntry.ID) case entryTapped(ActiveAgentEntry.ID) + case focusedSurfaceChanged(UUID?) + case selectNextEntry + case selectPreviousEntry case togglePanelVisibility case panelHeightChanged(Double) } @@ -41,6 +53,16 @@ struct ActiveAgentsFeature { case .entryTapped: return .none + case .focusedSurfaceChanged(let surfaceID): + state.focusedSurfaceID = surfaceID + return .none + + case .selectNextEntry: + return navigate(&state, direction: .next) + + case .selectPreviousEntry: + return navigate(&state, direction: .previous) + case .togglePanelVisibility: state.$isPanelHidden.withLock { $0.toggle() } return .none @@ -52,6 +74,46 @@ struct ActiveAgentsFeature { } } + /// Moves the keyboard anchor to the neighbouring entry and reuses `entryTapped` + /// so the parent reducer performs the actual worktree selection + surface focus. + private func navigate(_ state: inout State, direction: NavigationDirection) -> Effect { + guard + let targetID = Self.entryID( + navigatingFrom: state.focusedSurfaceID, + direction: direction, + in: state.entries + ) + else { + return .none + } + state.focusedSurfaceID = state.entries[id: targetID]?.surfaceID + return .send(.entryTapped(targetID)) + } + + /// Resolves the entry to navigate to, anchored on the focused surface. + /// + /// When no entry matches the focused surface the list wraps from an edge: + /// `.next` starts at the first entry and `.previous` at the last. With a known + /// anchor it steps one position and wraps around the ends. + static func entryID( + navigatingFrom focusedSurfaceID: UUID?, + direction: NavigationDirection, + in entries: IdentifiedArrayOf + ) -> ActiveAgentEntry.ID? { + guard !entries.isEmpty else { return nil } + let anchorIndex = focusedSurfaceID.flatMap { surfaceID in + entries.firstIndex { $0.surfaceID == surfaceID } + } + switch direction { + case .next: + guard let anchorIndex else { return entries.first?.id } + return entries[(anchorIndex + 1) % entries.count].id + case .previous: + guard let anchorIndex else { return entries.last?.id } + return entries[(anchorIndex - 1 + entries.count) % entries.count].id + } + } + static func clampedPanelHeight(_ height: Double) -> Double { min(maximumPanelHeight, max(minimumPanelHeight, height)) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e62217d6..6b151dde 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1292,6 +1292,11 @@ struct AppFeature { guard remainingTabs == 0 else { return .none } return .send(.repositories(.markWorktreeClosed(worktreeID))) + case .terminalEvent(.focusChanged(_, let surfaceID)): + // Keep the Active Agents panel's keyboard-navigation anchor in sync with + // the surface that actually has focus, so ⌃⌥↑/↓ steps from the right place. + return .send(.repositories(.activeAgents(.focusedSurfaceChanged(surfaceID)))) + case .terminalEvent: return .none } diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index e08c9eaf..d6159a3e 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -69,6 +69,88 @@ struct ActiveAgentsFeatureTests { #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) } + @Test func navigationReturnsNilForEmptyList() { + let entries: IdentifiedArrayOf = [] + #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .next, in: entries) == nil) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .previous, in: entries) == nil) + } + + @Test func navigationWithoutAnchorStartsFromEdges() { + let entries = sampleEntries() + // No focus, or focus on a surface that is not in the list, anchors on an edge. + #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .next, in: entries) == UUID(0)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .previous, in: entries) == UUID(2)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(99), direction: .next, in: entries) == UUID(0)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(99), direction: .previous, in: entries) == UUID(2)) + } + + @Test func navigationStepsAndWrapsAroundAnchor() { + let entries = sampleEntries() + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(0), direction: .next, in: entries) == UUID(1)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(2), direction: .next, in: entries) == UUID(0)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(1), direction: .previous, in: entries) == UUID(0)) + #expect(ActiveAgentsFeature.entryID(navigatingFrom: UUID(0), direction: .previous, in: entries) == UUID(2)) + } + + @Test func selectNextEntryAdvancesAnchorAndTapsNeighbour() async { + var state = ActiveAgentsFeature.State() + state.entries = sampleEntries() + state.focusedSurfaceID = UUID(0) + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + + await store.send(.selectNextEntry) { + $0.focusedSurfaceID = UUID(1) + } + await store.receive(.entryTapped(UUID(1))) + } + + @Test func selectPreviousEntryWrapsToLastWhenAtFirst() async { + var state = ActiveAgentsFeature.State() + state.entries = sampleEntries() + state.focusedSurfaceID = UUID(0) + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + + await store.send(.selectPreviousEntry) { + $0.focusedSurfaceID = UUID(2) + } + await store.receive(.entryTapped(UUID(2))) + } + + @Test func navigationWithoutEntriesIsNoOp() async { + let store = TestStore(initialState: ActiveAgentsFeature.State()) { + ActiveAgentsFeature() + } + + await store.send(.selectNextEntry) + await store.send(.selectPreviousEntry) + } + + @Test func focusedSurfaceChangedUpdatesAnchor() async { + let store = TestStore(initialState: ActiveAgentsFeature.State()) { + ActiveAgentsFeature() + } + + await store.send(.focusedSurfaceChanged(UUID(7))) { + $0.focusedSurfaceID = UUID(7) + } + await store.send(.focusedSurfaceChanged(nil)) { + $0.focusedSurfaceID = nil + } + } + + private func sampleEntries() -> IdentifiedArrayOf { + let now = Date(timeIntervalSince1970: 10) + return [ + entry(id: UUID(0), state: .working, changedAt: now), + entry(id: UUID(1), state: .idle, changedAt: now), + entry(id: UUID(2), state: .blocked, changedAt: now), + ] + } + private func entry(id: UUID, state: AgentDisplayState, changedAt: Date) -> ActiveAgentEntry { ActiveAgentEntry( id: id, -- 2.51.2 From b05718d6e74d3f5842ff0dc2b147f9a1fa38b6bf Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 21:00:09 +0900 Subject: [PATCH 187/528] Show navigation shortcut hint in Active Agents panel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While Cmd is held, the panel header shows the merged ⌥⌃↑↓ hint on the right (matching the app's existing Cmd-to-reveal-shortcuts behavior). The hint is hidden when the list is empty or when either binding has been customized, since the merged glyph form would then be inaccurate. --- supacode/App/AppShortcuts.swift | 18 +++++++++++ .../Views/ActiveAgentsPanel.swift | 8 +++++ .../Repositories/Views/SidebarListView.swift | 8 +++++ supacodeTests/AppShortcutsTests.swift | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 39a33186..ee7a7d9d 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -819,6 +819,24 @@ enum AppShortcuts { return display(for: worktreeSelectionCommandIDs[index], in: resolvedKeybindings) } + /// Combined up/down display for the Active Agents list navigation (e.g. "⌥⌃↑↓"). + /// + /// Returns `nil` once either binding has been customized, so callers can hide a + /// hint that the merged glyph form could otherwise render inaccurately. + static func activeAgentsNavigationDisplay(in resolvedKeybindings: ResolvedKeybindingMap) -> String? { + guard + let previous = resolvedKeybindings.binding(for: CommandID.selectPreviousActiveAgent), + let next = resolvedKeybindings.binding(for: CommandID.selectNextActiveAgent), + previous.source == .appDefault, + next.source == .appDefault, + let upDisplay = resolvedKeybindings.display(for: CommandID.selectPreviousActiveAgent), + let downGlyph = resolvedKeybindings.display(for: CommandID.selectNextActiveAgent)?.last + else { + return nil + } + return upDisplay + String(downGlyph) + } + static func terminalTabSelectionDisplay(at index: Int, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { guard terminalTabSelectionCommandIDs.indices.contains(index) else { return nil } return display(for: terminalTabSelectionCommandIDs[index], in: resolvedKeybindings) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 46562ac3..24f345a9 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -8,6 +8,9 @@ struct ActiveAgentsPanel: View { let branchNamesByWorktreeID: [Worktree.ID: String] let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] let selectedSurfaceID: UUID? + /// Merged "⌥⌃↑↓" hint shown while Cmd is held; `nil` hides it (bindings customized + /// or Cmd not held). Resolved by the parent so the panel stays presentational. + let navigationShortcutHint: String? let height: Double let maximumHeight: Double let onHeightChanged: (Double) -> Void @@ -23,10 +26,15 @@ struct ActiveAgentsPanel: View { .font(.caption) .foregroundStyle(.secondary) Spacer() + if let navigationShortcutHint, !store.entries.isEmpty { + ShortcutHintView(text: navigationShortcutHint, color: .secondary) + .transition(.opacity) + } } .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 4) + .animation(.easeInOut(duration: 0.15), value: navigationShortcutHint) if store.entries.isEmpty { Spacer(minLength: 0) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index fdcd5af4..704e304e 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -44,6 +44,7 @@ struct SidebarListView: View { @State private var resizingPanelHeight: Double? @Namespace private var topSegmentNamespace @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(CommandKeyObserver.self) private var commandKeyObserver @Shared(.repositoryAppearances) private var repositoryAppearances var body: some View { @@ -60,6 +61,12 @@ struct SidebarListView: View { let selectedSurfaceID = state.selectedWorktreeID.flatMap { worktreeID in terminalManager.stateIfExists(for: worktreeID)?.activeSurfaceID } + // Only surface the hint while Cmd is held and the bindings are still at their + // defaults; a customized binding makes the merged "⌥⌃↑↓" glyph inaccurate. + let activeAgentsShortcutHint = + commandKeyObserver.isPressed + ? AppShortcuts.activeAgentsNavigationDisplay(in: resolvedKeybindings) + : nil let pendingSidebarReveal = state.pendingSidebarReveal let maximumPanelHeight = @@ -149,6 +156,7 @@ struct SidebarListView: View { branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, selectedSurfaceID: selectedSurfaceID, + navigationShortcutHint: activeAgentsShortcutHint, height: panelHeight, maximumHeight: maximumPanelHeight, onHeightChanged: { height in diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 7155905f..be8e9cdf 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -309,6 +309,36 @@ struct AppShortcutsTests { #expect(arguments.contains("--keybind=ctrl+m=goto_tab:1") == false) } + @Test func activeAgentsNavigationDisplayMergesDefaultBindings() { + #expect(AppShortcuts.activeAgentsNavigationDisplay(in: .appDefaults) == "⌥⌃↑↓") + } + + @Test func activeAgentsNavigationDisplayHiddenWhenEitherBindingCustomized() { + let nextOverridden = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.selectNextActiveAgent: KeybindingUserOverride( + binding: Keybinding(key: "j", modifiers: .init(command: true)) + ) + ] + ) + ) + #expect(AppShortcuts.activeAgentsNavigationDisplay(in: nextOverridden) == nil) + + let previousOverridden = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.selectPreviousActiveAgent: KeybindingUserOverride( + binding: Keybinding(key: "k", modifiers: .init(command: true)) + ) + ] + ) + ) + #expect(AppShortcuts.activeAgentsNavigationDisplay(in: previousOverridden) == nil) + } + @Test func disabledManagedGhosttyActionKeepsDefaultUnboundWithoutBindingAction() { let overrides = KeybindingUserOverrideStore( overrides: [ -- 2.51.2 From 31cfdb4700e1a21763f41e63b414a6c8daa4e5cb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 21:13:43 +0900 Subject: [PATCH 188/528] Fix Active Agents highlight flashing previous agent during navigation The panel highlight read selectedSurfaceID, derived from the selected worktree's active surface, which only updates after the async focusSurface completes. Navigating across worktrees briefly highlighted the target worktree's previous agent before settling on the real target. Highlight now prefers the reducer-tracked focusedSurfaceID, which is set synchronously on navigation, falling back to selectedSurfaceID before the first focus is recorded. Actual tab focus was always correct; only the highlight lagged. --- .../ActiveAgents/Views/ActiveAgentsPanel.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 24f345a9..2ef80609 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -133,8 +133,14 @@ struct ActiveAgentsPanel: View { } private func isDimmed(_ entry: ActiveAgentEntry) -> Bool { - if let selectedSurfaceID { - return entry.surfaceID != selectedSurfaceID + // Prefer the reducer-tracked focused surface so keyboard navigation highlights + // the target immediately. `selectedSurfaceID` is derived from the selected + // worktree's active surface and only catches up once the async `focusSurface` + // completes, which would otherwise flash that worktree's previous agent for a + // frame when navigation wraps across worktrees. Falls back to it before the + // first focus is recorded. + if let activeSurfaceID = store.focusedSurfaceID ?? selectedSurfaceID { + return entry.surfaceID != activeSurfaceID } return false } -- 2.51.2 From 516aacbf1516e989009dc0354a245cfb537f7b7e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 23:04:50 +0900 Subject: [PATCH 189/528] Fix Active Agents tab and highlight flicker when selecting agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an agent (by click or keyboard) wrapped two effects in a merge: `selectWorktree` and `focusSurface`. The selection landed first, so the worktree became visible showing its previously-focused tab before `focusSurface` switched to the target tab — a visible tab flip, and the panel highlight (derived from the selected worktree's active surface) flashed the wrong agent for a frame. A prior attempt masked the highlight flash by reading the reducer's focus anchor first, but that anchor only updates via keyboard navigation and via `focusChanged` events, which are deduplicated per worktree. Re-focusing a worktree's already-focused surface emits nothing, so clicking an agent left the anchor stale and the highlight stuck on the last keyboard-selected agent. Fix the ordering at the source: focus the target surface first (which selects its tab without the worktree being visible yet), then select the worktree with `focusTerminal` so the terminal view focuses the now-correct selected tab once it appears. The worktree shows the right tab immediately and `selectedSurfaceID` is already correct when the selection lands, so the panel highlight reverts to plain `selectedSurfaceID` — no flicker and no dependence on the dedup-prone focus anchor. Also mirror tapped entries into the focus anchor so keyboard navigation continues from the just-selected agent regardless of the dedup. --- .../Reducer/ActiveAgentsFeature.swift | 8 ++- .../Views/ActiveAgentsPanel.swift | 15 +++--- .../Reducer/RepositoriesFeature.swift | 15 +++--- supacodeTests/ActiveAgentsFeatureTests.swift | 15 ++++++ supacodeTests/RepositoriesFeatureTests.swift | 49 +++++++++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 650bdaf8..7afd4fd6 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -50,7 +50,13 @@ struct ActiveAgentsFeature { state.entries.remove(id: id) return .none - case .entryTapped: + case .entryTapped(let id): + // Mirror the tapped surface into the focus anchor so the panel highlight and + // keyboard navigation step from the just-selected agent immediately. The async + // `focusChanged` event can't be relied on here: it is deduplicated per worktree + // (`emitFocusChangedIfNeeded`), so re-focusing a worktree's previously focused + // surface emits nothing and would leave the anchor stale. + state.focusedSurfaceID = state.entries[id: id]?.surfaceID return .none case .focusedSurfaceChanged(let surfaceID): diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 2ef80609..d816ed2a 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -133,14 +133,13 @@ struct ActiveAgentsPanel: View { } private func isDimmed(_ entry: ActiveAgentEntry) -> Bool { - // Prefer the reducer-tracked focused surface so keyboard navigation highlights - // the target immediately. `selectedSurfaceID` is derived from the selected - // worktree's active surface and only catches up once the async `focusSurface` - // completes, which would otherwise flash that worktree's previous agent for a - // frame when navigation wraps across worktrees. Falls back to it before the - // first focus is recorded. - if let activeSurfaceID = store.focusedSurfaceID ?? selectedSurfaceID { - return entry.surfaceID != activeSurfaceID + // Highlight the selected worktree's active surface. `entryTapped` now focuses + // the target surface before selecting its worktree, so `selectedSurfaceID` is + // already correct by the time the selection lands — no cross-worktree flash and + // no dependence on the reducer's focus anchor, which can go stale when the + // per-worktree `focusChanged` dedup suppresses an event. + if let selectedSurfaceID { + return entry.surfaceID != selectedSurfaceID } return false } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ff0c219a..818aaa43 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -418,12 +418,15 @@ struct RepositoriesFeature { case .activeAgents(.entryTapped(let id)): guard let entry = state.activeAgents.entries[id: id] else { return .none } - return .merge( - .send(.selectWorktree(entry.worktreeID, focusTerminal: false)), - .run { _ in - _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) - } - ) + return .run { send in + // Focus the target surface (which selects its tab) before making the + // worktree visible, so it shows the right tab immediately instead of + // flashing its previously-focused tab. Then select the worktree with + // `focusTerminal` so the terminal view focuses the now-correct selected + // tab once it appears, regardless of where first responder currently is. + _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) + await send(.selectWorktree(entry.worktreeID, focusTerminal: true)) + } case .activeAgents: return .none diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index d6159a3e..11e93b6a 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -129,6 +129,21 @@ struct ActiveAgentsFeatureTests { await store.send(.selectPreviousEntry) } + @Test func entryTappedUpdatesFocusAnchor() async { + var state = ActiveAgentsFeature.State() + state.entries = sampleEntries() + let store = TestStore(initialState: state) { + ActiveAgentsFeature() + } + + // Tapping mirrors the entry's surface into the focus anchor so keyboard + // navigation continues from the just-selected agent, without relying on the + // (per-worktree deduplicated) async `focusChanged` event. + await store.send(.entryTapped(UUID(2))) { + $0.focusedSurfaceID = UUID(2) + } + } + @Test func focusedSurfaceChangedUpdatesAnchor() async { let store = TestStore(initialState: ActiveAgentsFeature.State()) { ActiveAgentsFeature() diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b11bbc55..1ac6b481 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -928,6 +928,55 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test func activeAgentEntryTappedFocusesSurfaceBeforeSelectingWorktree() async { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + let surfaceID = UUID() + let entry = ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + tabID: TerminalTabID(rawValue: UUID()), + tabTitle: "agent", + surfaceID: surfaceID, + paneIndex: 0, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + state.activeAgents.entries = [entry] + + let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.focusSurface = { worktreeID, surface in + focusedSurface.setValue((worktreeID, surface)) + return true + } + } + + // Tapping mirrors the surface into the keyboard-nav anchor synchronously... + await store.send(.activeAgents(.entryTapped(entry.id))) { + $0.activeAgents.focusedSurfaceID = surfaceID + } + // ...then the worktree is selected with `focusTerminal` (proved by the pending + // focus set) only after the target surface has already been focused, so the + // worktree shows the right tab the instant it becomes visible. + await store.receive(\.selectWorktree) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + $0.openedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + + #expect(focusedSurface.value?.0 == worktree.id) + #expect(focusedSurface.value?.1 == surfaceID) + } + @Test func selectWorktreeCollapsesSidebarSelectedWorktreeIDs() async { let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") -- 2.51.2 From bb6a88660c4d18f2e1da0392f3181221fbafc03a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 23:31:49 +0900 Subject: [PATCH 190/528] bump v2026.5.24 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 08b0902a..815775ff 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260520; + CURRENT_PROJECT_VERSION = 20260524; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.20; + MARKETING_VERSION = 2026.5.24; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260520; + CURRENT_PROJECT_VERSION = 20260524; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.20; + MARKETING_VERSION = 2026.5.24; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260520; + CURRENT_PROJECT_VERSION = 20260524; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.20; + MARKETING_VERSION = 2026.5.24; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260520; + CURRENT_PROJECT_VERSION = 20260524; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.20; + MARKETING_VERSION = 2026.5.24; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index b9d35a68..a1652a02 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.20" + public static let current = "2026.5.24" } -- 2.51.2 From 47b6efac899df47b92fd5af9713f7469f6ef8ed8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 24 May 2026 23:31:50 +0900 Subject: [PATCH 191/528] Update CHANGELOG for 2026.5.24 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd003aac..f1066e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2026.5.24](https://github.com/onevcat/Prowl/releases/tag/v2026.5.24) + +This release redesigns the terminal tab bar and sidebar to sit more naturally within macOS, introduces a window tint that colors the chrome to match your repository, and adds keyboard navigation for the Active Agents panel. + +### New + +- **Navigate the Active Agents panel from the keyboard.** Press ⌃⌥↓ to jump to the next agent and ⌃⌥↑ for the previous one. Navigation is anchored on the focused agent, wraps at the ends of the list, and focuses the target agent's terminal. The panel header reveals the hint while ⌘ is held, and both commands are rebindable in Settings → Shortcuts. +- **Window tint setting.** A new Window Tint option in Settings → Appearance colors the navigation panel and toolbar. Choose None for the neutral system look, Repository Color to match the active repository's pinned color, or Custom Color to apply a single color everywhere. +- **Custom repository colors.** Beyond the built-in presets, you can now assign any color to a repository from the appearance picker. +- **Canvas remembers your layout.** Card positions and stacking order are now saved and restored on relaunch instead of being auto-arranged over, and focused cards come to the front. + +### Improved + +- **Refined terminal tab bar.** Tabs now float as glass cards with centered titles and stretch to fill the available width. The close button moved to the leading edge and reveals on hover, with a tuned dark-mode brightness ladder and tidier spacing between the tabs and the terminal. +- **Reworked sidebar header.** The view-mode switcher is now a fixed top bar, and Expand/Collapse All is back in the repository header. +- **Redesigned command palette and Find bar.** The command palette adopts the macOS glass material with a softer selection highlight, and the in-terminal Find bar takes on a capsule shape with streamlined icon-only controls. +- **Repository-colored chrome.** The Shelf spine, and the window chrome when tinting is enabled, take on the open repository's color, and the repo color dot stays solid even when the window loses focus. +- **Polish throughout.** Restored the bagua working indicator for agents, improved the empty state shown when no repositories are open, and widened the minimum settings window. + +### Fixed + +- **No flicker when selecting an agent.** Choosing an agent in the Active Agents panel, by click or keyboard, no longer briefly shows the wrong tab or highlights the wrong agent. +- **Stable toolbar title.** Hovering the worktree title in the toolbar no longer shifts the other toolbar items. + ## [2026.5.20](https://github.com/onevcat/Prowl/releases/tag/v2026.5.20) ### Fixed -- 2.51.2 From f4b01867e208ddd2ec6981795c19604635e31521 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 01:12:29 +0900 Subject: [PATCH 192/528] Add Canvas option to Default View setting Default View now offers a "Canvas View" choice alongside Normal and Shelf, booting straight into Canvas on launch when at least one worktree row exists (otherwise it falls back to Normal, like Shelf). - DefaultViewMode: add `.canvas` case and title - RepositoriesFeature: on initial snapshot load (non Layout-Restore launches) dispatch `.toggleCanvas`, reusing its worktree guard - AppFeature: honor `.canvas` on `.layoutRestored` for Layout-Restore launches, entering Canvas after the selection effects so `.selectCanvas` records the right pre-Canvas anchor - AppearanceSettingsView: mention Canvas in the picker help text - Tests: mirror the Shelf default-view tests for the Canvas branch --- .../Features/App/Reducer/AppFeature.swift | 8 ++ .../Reducer/RepositoriesFeature.swift | 17 ++- .../Settings/Models/DefaultViewMode.swift | 8 +- .../Views/AppearanceSettingsView.swift | 2 +- supacodeTests/ShelfFeatureTests.swift | 116 ++++++++++++++++++ 5 files changed, 143 insertions(+), 8 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 6b151dde..402f61fd 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1259,6 +1259,9 @@ struct AppFeature { let shouldEnterShelf = settingsFile.global.defaultViewMode == .shelf && !state.repositories.isShelfActive + let shouldEnterCanvas = + settingsFile.global.defaultViewMode == .canvas + && !state.repositories.isShowingCanvas var effects: [Effect] = [] if let selectedWorktreeID { // Plain folders use .repository selection, not .worktree @@ -1273,6 +1276,11 @@ struct AppFeature { if shouldEnterShelf { effects.append(.send(.repositories(.toggleShelf))) } + // Enter Canvas after the selection effects so `.selectCanvas` + // records the just-selected worktree as the pre-Canvas anchor. + if shouldEnterCanvas { + effects.append(.send(.repositories(.toggleCanvas))) + } return effects.isEmpty ? .none : .merge(effects) case .terminalEvent(.layoutRestoreFailed(let message)): diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 818aaa43..eef95c8a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -495,11 +495,18 @@ struct RepositoriesFeature { // Restore clears the selection we just set, and any books not // in the saved layout would linger as stray spines. @Shared(.settingsFile) var settingsFile - if settingsFile.global.defaultViewMode == .shelf, - state.launchRestoreMode != .restoreLayout, - !state.isShelfActive - { - allEffects.append(.send(.toggleShelf)) + if state.launchRestoreMode != .restoreLayout { + switch settingsFile.global.defaultViewMode { + case .shelf where !state.isShelfActive: + allEffects.append(.send(.toggleShelf)) + case .canvas where !state.isShowingCanvas: + // `.toggleCanvas` shares Shelf's guards: it only enters + // when at least one worktree row exists, otherwise it + // falls back to Normal. + allEffects.append(.send(.toggleCanvas)) + case .normal, .shelf, .canvas: + break + } } return .merge(allEffects) diff --git a/supacode/Features/Settings/Models/DefaultViewMode.swift b/supacode/Features/Settings/Models/DefaultViewMode.swift index fa7c02a6..f0800198 100644 --- a/supacode/Features/Settings/Models/DefaultViewMode.swift +++ b/supacode/Features/Settings/Models/DefaultViewMode.swift @@ -1,10 +1,12 @@ /// Which presentation the app enters on launch. `normal` keeps the /// historical behavior (sidebar + terminal detail); `shelf` boots -/// straight into Shelf so power users who live in Shelf don't have to -/// toggle it every time they open Prowl. +/// straight into Shelf and `canvas` boots straight into Canvas, so +/// power users who live in those views don't have to toggle them +/// every time they open Prowl. enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable { case normal case shelf + case canvas var id: String { rawValue } @@ -14,6 +16,8 @@ enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable { return "Normal View" case .shelf: return "Shelf View" + case .canvas: + return "Canvas View" } } } diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 59780295..1470e04d 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -90,7 +90,7 @@ struct AppearanceSettingsView: View { Text(mode.title).tag(mode) } } - .help("View Prowl starts in on launch. Shelf requires at least one worktree or folder.") + .help("View Prowl starts in on launch. Shelf and Canvas require at least one worktree or folder.") } Section("Default Editor") { Picker( diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 2a942fc6..a801298e 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -930,6 +930,122 @@ struct ShelfFeatureTests { await store.finish() } + @Test(.dependencies) func defaultViewCanvasPreferenceDispatchesToggleAfterSnapshot() async { + let repoRoot = "/tmp/default-canvas-repo" + let rootURL = URL(fileURLWithPath: repoRoot) + let worktree = Worktree( + id: "\(repoRoot)/main", + name: "main", + detail: "", + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), + repositoryRootURL: rootURL + ) + let repository = Repository( + id: repoRoot, + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .canvas + $0.global = updated + } + // Restore settings after the test so `@Shared` state doesn't leak + // across parallel test runs in the same process. + defer { + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .normal + $0.global = updated + } + } + + var initialState = RepositoriesFeature.State() + initialState.lastFocusedWorktreeID = worktree.id + initialState.shouldRestoreLastFocusedWorktree = true + initialState.snapshotPersistencePhase = .restoring + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.repositorySnapshotLoaded([repository])) { + $0.repositories = [repository] + $0.repositoryRoots = [rootURL] + $0.selection = .worktree(worktree.id) + $0.shouldRestoreLastFocusedWorktree = false + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.delegate.selectedWorktreeChanged) + // `.toggleCanvas` enters Canvas via `.selectCanvas`, which records the + // current worktree as the pre-Canvas anchor before flipping selection. + await store.receive(\.toggleCanvas) + await store.receive(\.selectCanvas) { + $0.preCanvasWorktreeID = worktree.id + $0.preCanvasTerminalTargetID = worktree.id + $0.selection = .canvas + } + await store.finish() + } + + @Test(.dependencies) func defaultViewCanvasDefersDuringLayoutRestore() async { + // Mirrors the Shelf deferral: during Layout Restore the snapshot-load + // hook stays quiet and the AppFeature `.layoutRestored` path takes over. + let repoRoot = "/tmp/default-canvas-restore-repo" + let rootURL = URL(fileURLWithPath: repoRoot) + let worktree = Worktree( + id: "\(repoRoot)/main", + name: "main", + detail: "", + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), + repositoryRootURL: rootURL + ) + let repository = Repository( + id: repoRoot, + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [worktree]) + ) + + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .canvas + $0.global = updated + } + defer { + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = .normal + $0.global = updated + } + } + + var initialState = RepositoriesFeature.State() + initialState.lastFocusedWorktreeID = worktree.id + initialState.shouldRestoreLastFocusedWorktree = true + initialState.snapshotPersistencePhase = .restoring + initialState.launchRestoreMode = .restoreLayout + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.repositorySnapshotLoaded([repository])) { + $0.repositories = [repository] + $0.repositoryRoots = [rootURL] + $0.selection = .worktree(worktree.id) + $0.shouldRestoreLastFocusedWorktree = false + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.delegate.selectedWorktreeChanged) + // No `.toggleCanvas` here — the Layout Restore path is responsible. + await store.finish() + } + @Test func isShowingShelfRequiresAtLeastOneRepository() { let rootURL = URL(fileURLWithPath: "/tmp/repo") let repository = Repository( -- 2.51.2 From 303d44bafb54bbd6389205f61d47b3121aec1f7c Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 01:33:48 +0900 Subject: [PATCH 193/528] Seed selected worktree's terminal when entering Canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas only renders cards for worktrees that already have a live terminal surface. Normal and Shelf open the previously-focused worktree's tab lazily when its per-worktree view mounts and calls `ensureInitialTab`, but Canvas mounts no such view — so launching straight into Canvas (Default View = Canvas, Layout Restore OFF) showed an empty board even though a repo was selected at the previous shutdown. `selectCanvas` now sends `.ensureInitialTab` for the worktree being entered from before flipping into canvas mode, matching the single-tab restore Normal and Shelf perform. `ensureInitialTab` is idempotent, so this no-ops when entering Canvas at runtime with tabs already open, and the Layout-Restore path is unaffected (surfaces already exist). Adds tests covering the seeding command and the no-selection case. --- .../Reducer/RepositoriesFeature.swift | 16 +++++- supacodeTests/RepositoriesFeatureTests.swift | 54 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index eef95c8a..459bad13 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -750,12 +750,26 @@ struct RepositoriesFeature { case .selectCanvas: // Remember the current worktree so toggleCanvas can restore it. + let canvasSeedWorktree = state.selectedTerminalWorktree state.preCanvasWorktreeID = state.selectedWorktreeID - state.preCanvasTerminalTargetID = state.selectedTerminalWorktree?.id + state.preCanvasTerminalTargetID = canvasSeedWorktree?.id state.isShelfActive = false state.selection = .canvas state.sidebarSelectedWorktreeIDs = [] + // Canvas only renders cards for worktrees that already have a live + // terminal surface. Normal/Shelf get the previously-focused worktree + // opened lazily when its view mounts and calls `ensureInitialTab`; + // Canvas mounts no such per-worktree view, so launching straight into + // Canvas would show an empty board. Seed the surface for the worktree + // we're entering from so at least that card appears — matching the + // single-tab restore Normal and Shelf perform. `ensureInitialTab` is + // idempotent, so this no-ops once the worktree already has tabs. return .run { _ in + if let canvasSeedWorktree { + await terminalClient.send( + .ensureInitialTab(canvasSeedWorktree, runSetupScriptIfNew: false, focusing: false) + ) + } await terminalClient.send(.setCanvasMode(true)) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 1ac6b481..2e999075 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1098,6 +1098,60 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test func selectCanvasSeedsInitialTabForSelectedWorktree() async { + // Canvas only renders cards for worktrees with a live terminal surface. + // Entering Canvas must seed the selected worktree's tab so launching + // straight into Canvas shows that worktree's card — Normal/Shelf open one + // tab on launch, but Canvas mounts no per-worktree view to do it lazily. + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .worktree(worktree.id) + + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.selectCanvas) { + $0.preCanvasWorktreeID = worktree.id + $0.preCanvasTerminalTargetID = worktree.id + $0.selection = .canvas + } + await store.finish() + + #expect( + sentCommands.value == [ + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false), + .setCanvasMode(true), + ] + ) + } + + @Test func selectCanvasWithoutSelectionOnlyEntersCanvasMode() async { + // With nothing selected there is no surface to seed — Canvas just flips + // into canvas mode and renders an empty board. + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.selectCanvas) { + $0.selection = .canvas + } + await store.finish() + + #expect(sentCommands.value == [.setCanvasMode(true)]) + } + @Test func selectRepositoryIgnoresUnknownRepository() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() -- 2.51.2 From 5380a7dc77028b628f6fc357d96103930fdfb336 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 19:28:02 +0900 Subject: [PATCH 194/528] Fix fullscreen toolbar tint --- supacode/App/ContentView.swift | 3 + supacode/Domain/WindowChromeTint.swift | 130 +++++++++++++++++++++- supacodeTests/WindowChromeTintTests.swift | 12 ++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 7364bcba..b81660e3 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -82,6 +82,9 @@ struct ContentView: View { } .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) + .background { + WindowAppearanceSetter(colorScheme: store.settings.appearanceMode.colorScheme) + } .overlay { CommandPaletteOverlayView( store: store.scope(state: \.commandPalette, action: \.commandPalette), diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 199c13a1..80bf5a7d 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Unified color logic for the window *chrome* tint — the nav-panel band @@ -74,6 +75,13 @@ enum WindowChromeTint { return Fill(color: customColor, alpha: saturatedPeakAlpha) } } + + /// Resolves the actual top chrome band height. SwiftUI's top safe-area + /// inset can collapse to zero when macOS moves the titlebar/toolbar into + /// full-screen chrome, so keep an AppKit-derived window chrome fallback. + static func topBandHeight(safeAreaTop: CGFloat, windowChromeTop: CGFloat) -> CGFloat { + max(safeAreaTop, windowChromeTop) + } } extension View { @@ -100,8 +108,14 @@ private struct WindowChromeTintModifier: ViewModifier { @State private var topInset: CGFloat = 0 @State private var leadingInset: CGFloat = 0 + @State private var windowChromeTopInset: CGFloat = 0 func body(content: Content) -> some View { + let resolvedTopInset = WindowChromeTint.topBandHeight( + safeAreaTop: topInset, + windowChromeTop: windowChromeTopInset + ) + content // Content draws under the transparent titlebar, so the top // safe-area inset equals the toolbar/titlebar height — exactly the @@ -122,7 +136,7 @@ private struct WindowChromeTintModifier: ViewModifier { .overlay(alignment: .top) { if let fill, edges.contains(.top) { band(fill) - .frame(height: topInset) + .frame(height: resolvedTopInset) .frame(maxWidth: .infinity) .ignoresSafeArea(.container, edges: .top) } @@ -135,6 +149,9 @@ private struct WindowChromeTintModifier: ViewModifier { .ignoresSafeArea(.container, edges: [.leading, .top, .bottom]) } } + .background { + WindowChromeMetricsReader(topChromeHeight: $windowChromeTopInset) + } } private func band(_ fill: WindowChromeTint.Fill) -> some View { @@ -143,3 +160,114 @@ private struct WindowChromeTintModifier: ViewModifier { .allowsHitTesting(false) } } + +private struct WindowChromeMetricsReader: NSViewRepresentable { + @Binding var topChromeHeight: CGFloat + + func makeNSView(context: Context) -> WindowChromeMetricsView { + let view = WindowChromeMetricsView() + view.onTopChromeHeightChange = makeHeightChangeHandler() + return view + } + + func updateNSView(_ nsView: WindowChromeMetricsView, context: Context) { + nsView.onTopChromeHeightChange = makeHeightChangeHandler() + nsView.refresh() + } + + private func makeHeightChangeHandler() -> (CGFloat) -> Void { + let binding = $topChromeHeight + return { height in + guard binding.wrappedValue != height else { return } + DispatchQueue.main.async { + guard binding.wrappedValue != height else { return } + binding.wrappedValue = height + } + } + } +} + +private final class WindowChromeMetricsView: NSView { + var onTopChromeHeightChange: ((CGFloat) -> Void)? + + private weak var observedWindow: NSWindow? + private var observers: [NSObjectProtocol] = [] + + deinit { + removeObservers() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateObservedWindow() + } + + override func layout() { + super.layout() + publishTopChromeHeight() + } + + func refresh() { + updateObservedWindow() + publishTopChromeHeight() + } + + private func updateObservedWindow() { + guard observedWindow !== window else { return } + + removeObservers() + observedWindow = window + + guard let window else { + onTopChromeHeightChange?(0) + return + } + + let notificationCenter = NotificationCenter.default + for name in Self.observedWindowNotifications { + let observer = notificationCenter.addObserver( + forName: name, + object: window, + queue: .main + ) { [weak self] _ in + self?.schedulePublishTopChromeHeight() + } + observers.append(observer) + } + + publishTopChromeHeight() + } + + private func removeObservers() { + let notificationCenter = NotificationCenter.default + for observer in observers { + notificationCenter.removeObserver(observer) + } + observers.removeAll() + } + + private func schedulePublishTopChromeHeight() { + publishTopChromeHeight() + DispatchQueue.main.async { [weak self] in + self?.publishTopChromeHeight() + } + } + + private func publishTopChromeHeight() { + guard let window else { + onTopChromeHeightChange?(0) + return + } + + let topChromeHeight = max(0, window.frame.height - window.contentLayoutRect.height) + onTopChromeHeightChange?(topChromeHeight) + } + + private static let observedWindowNotifications: [NSNotification.Name] = [ + NSWindow.didResizeNotification, + NSWindow.didEndLiveResizeNotification, + NSWindow.didEnterFullScreenNotification, + NSWindow.didExitFullScreenNotification, + NSWindow.didChangeScreenNotification, + ] +} diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 453d661f..050ac157 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -43,6 +43,18 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) } + // MARK: - Chrome metrics + + @Test + func topBandHeightUsesWindowChromeFallbackWhenSafeAreaCollapses() { + #expect(WindowChromeTint.topBandHeight(safeAreaTop: 0, windowChromeTop: 52) == 52) + } + + @Test + func topBandHeightPrefersLargerSafeAreaMeasurement() { + #expect(WindowChromeTint.topBandHeight(safeAreaTop: 54, windowChromeTop: 28) == 54) + } + // MARK: - TintColor persistence @Test -- 2.51.2 From 01de2d929f8b0f6f6b302ac2bc1240f8d225adf9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 19:41:58 +0900 Subject: [PATCH 195/528] Confirm closing protected terminal work --- .../TerminalCloseConfirmationPolicy.swift | 63 +++++++ .../Models/WorktreeTerminalState.swift | 154 ++++++++++++++++-- ...TerminalCloseConfirmationPolicyTests.swift | 100 ++++++++++++ 3 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift create mode 100644 supacodeTests/TerminalCloseConfirmationPolicyTests.swift diff --git a/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift b/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift new file mode 100644 index 00000000..46c2318a --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalCloseConfirmationPolicy.swift @@ -0,0 +1,63 @@ +import Foundation + +struct TerminalCloseProtectionCandidate: Equatable { + let hasAgent: Bool + let agentDisplayState: AgentDisplayState? + let commandRunningDuration: TimeInterval? +} + +enum TerminalCloseProtectionReason: Equatable, Hashable { + case agentActive + case longRunningCommand +} + +struct TerminalCloseConfirmationDecision: Equatable { + let protectedPaneCount: Int + let reasons: Set + + var requiresConfirmation: Bool { + protectedPaneCount > 0 + } +} + +enum TerminalCloseConfirmationPolicy { + static let longRunningCommandThreshold: TimeInterval = 10 + + static func decision( + for candidates: [TerminalCloseProtectionCandidate], + threshold: TimeInterval = Self.longRunningCommandThreshold + ) -> TerminalCloseConfirmationDecision { + var protectedPaneCount = 0 + var reasons: Set = [] + + for candidate in candidates { + guard let reason = protectionReason(for: candidate, threshold: threshold) else { continue } + protectedPaneCount += 1 + reasons.insert(reason) + } + + return TerminalCloseConfirmationDecision( + protectedPaneCount: protectedPaneCount, + reasons: reasons + ) + } + + private static func protectionReason( + for candidate: TerminalCloseProtectionCandidate, + threshold: TimeInterval + ) -> TerminalCloseProtectionReason? { + if candidate.hasAgent { + switch candidate.agentDisplayState { + case .working, .blocked, .done: + return .agentActive + case .idle, .none: + return nil + } + } + + guard let duration = candidate.commandRunningDuration, duration >= threshold else { + return nil + } + return .longRunningCommand + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index bdcc52eb..95be8d1d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -9,6 +9,39 @@ private let terminalStateLogger = SupaLogger("TerminalState") private let activeAgentDetectionInterval: Duration = .milliseconds(300) private let idleAgentDetectionInterval: Duration = .seconds(2) +private enum TerminalCloseConfirmationMode { + case prompt(TerminalCloseConfirmationTarget) + case skip +} + +private enum TerminalCloseConfirmationTarget { + case pane + case tab + case tabs(count: Int) + + var messageText: String { + switch self { + case .pane: + return "Close Terminal Pane?" + case .tab: + return "Close Terminal Tab?" + case .tabs(let count): + return count == 1 ? "Close Terminal Tab?" : "Close Terminal Tabs?" + } + } + + var confirmButtonTitle: String { + switch self { + case .pane: + return "Close Pane" + case .tab: + return "Close Tab" + case .tabs(let count): + return count == 1 ? "Close Tab" : "Close Tabs" + } + } +} + private struct AgentDetectionDiagnostic { let tabId: TerminalTabID let childPID: pid_t? @@ -43,6 +76,7 @@ final class WorktreeTerminalState { private var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] private var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] + private var surfaceRunningStartedAtById: [UUID: Date] = [:] private var runScriptTabId: TerminalTabID? private var pendingSetupScript: Bool private var defaultFontSize: Float32? @@ -297,7 +331,7 @@ final class WorktreeTerminalState { func runScript(_ script: String) -> TerminalTabID? { guard let input = runScriptInput(script) else { return nil } if let existing = runScriptTabId { - closeTab(existing) + closeTab(existing, confirmation: .skip) } let tabId = createTab( TabCreation( @@ -324,8 +358,7 @@ final class WorktreeTerminalState { @discardableResult func stopRunScript() -> Bool { guard let runScriptTabId else { return false } - closeTab(runScriptTabId) - return true + return closeTab(runScriptTabId, confirmation: .skip) } private struct TabCreation: Equatable { @@ -468,8 +501,7 @@ final class WorktreeTerminalState { @discardableResult func closeFocusedTab() -> Bool { guard let tabId = tabManager.selectedTabId else { return false } - closeTab(tabId) - return true + return closeTab(tabId) } @discardableResult @@ -508,7 +540,14 @@ final class WorktreeTerminalState { return true } - func closeTab(_ tabId: TerminalTabID) { + @discardableResult + func closeTab(_ tabId: TerminalTabID) -> Bool { + closeTab(tabId, confirmation: .prompt(.tab)) + } + + @discardableResult + private func closeTab(_ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode) -> Bool { + guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) tabManager.closeTab(tabId) @@ -522,28 +561,97 @@ final class WorktreeTerminalState { setRunScriptTabId(nil) } onTabClosed?() + return true } func closeOtherTabs(keeping tabId: TerminalTabID) { let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } + guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } for id in ids { - closeTab(id) + closeTab(id, confirmation: .skip) } } func closeTabsToRight(of tabId: TerminalTabID) { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let ids = tabManager.tabs.dropFirst(index + 1).map(\.id) + guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } for id in ids { - closeTab(id) + closeTab(id, confirmation: .skip) } } func closeAllTabs() { let ids = tabManager.tabs.map(\.id) + guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } for id in ids { - closeTab(id) + closeTab(id, confirmation: .skip) + } + } + + private func confirmCloseIfNeeded( + tabIds: [TerminalTabID], + mode: TerminalCloseConfirmationMode + ) -> Bool { + let surfaceIDs = tabIds.flatMap { tabId in + trees[tabId]?.leaves().map(\.id) ?? [] + } + return confirmCloseIfNeeded(surfaceIDs: surfaceIDs, mode: mode) + } + + private func confirmCloseIfNeeded( + surfaceIDs: [UUID], + mode: TerminalCloseConfirmationMode + ) -> Bool { + guard !surfaceIDs.isEmpty else { return true } + switch mode { + case .skip: + return true + case .prompt(let target): + let candidates = closeProtectionCandidates(surfaceIDs: surfaceIDs) + let decision = TerminalCloseConfirmationPolicy.decision(for: candidates) + guard decision.requiresConfirmation else { return true } + return presentCloseConfirmation(target: target, decision: decision) + } + } + + private func closeProtectionCandidates(surfaceIDs: [UUID]) -> [TerminalCloseProtectionCandidate] { + let now = Date() + return surfaceIDs.map { surfaceID in + let agentState = surfaceAgentStates[surfaceID] + let runningDuration = surfaceRunningStartedAtById[surfaceID].map { now.timeIntervalSince($0) } + return TerminalCloseProtectionCandidate( + hasAgent: agentState?.detectedAgent != nil, + agentDisplayState: agentState?.displayState, + commandRunningDuration: runningDuration + ) + } + } + + private func presentCloseConfirmation( + target: TerminalCloseConfirmationTarget, + decision: TerminalCloseConfirmationDecision + ) -> Bool { + let alert = NSAlert() + alert.messageText = target.messageText + alert.informativeText = closeConfirmationMessage(for: decision) + alert.alertStyle = .warning + alert.addButton(withTitle: target.confirmButtonTitle) + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + + private func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { + let paneText = decision.protectedPaneCount == 1 ? "pane" : "panes" + let reasonText: String + if decision.reasons == Set([.agentActive]) { + reasonText = "active agent work or an unseen agent result" + } else if decision.reasons == Set([.longRunningCommand]) { + reasonText = "a command that has been running for at least 10 seconds" + } else { + reasonText = "active agent work, unseen agent results, or long-running commands" } + return "This will close \(decision.protectedPaneCount) \(paneText) with \(reasonText)." } func splitTree( @@ -604,6 +712,7 @@ final class WorktreeTerminalState { } catch { newSurface.closeSurface() surfaces.removeValue(forKey: newSurface.id) + surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) cleanupCommandDetectorState(forSurfaceId: newSurface.id) cleanupAgentDetectionState(forSurfaceId: newSurface.id) return nil @@ -689,6 +798,7 @@ final class WorktreeTerminalState { } catch { newSurface.closeSurface() surfaces.removeValue(forKey: newSurface.id) + surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) cleanupCommandDetectorState(forSurfaceId: newSurface.id) cleanupAgentDetectionState(forSurfaceId: newSurface.id) @@ -1124,8 +1234,7 @@ final class WorktreeTerminalState { } view.bridge.onCloseTab = { [weak self] _ in guard let self else { return false } - self.closeTab(tabId) - return true + return self.closeTab(tabId) } view.bridge.onGotoTab = { [weak self] target in guard let self else { return false } @@ -1493,6 +1602,7 @@ final class WorktreeTerminalState { continuation.finish() } + surfaceRunningStartedAtById.removeValue(forKey: surfaceId) noteCommandFinishedForCommandDetection(surfaceId: surfaceId) // Custom command success toast. One-shot: removed regardless of outcome. @@ -1859,6 +1969,7 @@ final class WorktreeTerminalState { for surface in tree.leaves() { surface.closeSurface() surfaces.removeValue(forKey: surface.id) + surfaceRunningStartedAtById.removeValue(forKey: surface.id) autoCloseSurfaceIds.remove(surface.id) pendingCustomCommands.removeValue(forKey: surface.id) cleanupCommandDetectorState(forSurfaceId: surface.id) @@ -1888,8 +1999,17 @@ final class WorktreeTerminalState { private func updateRunningState(for tabId: TerminalTabID) { guard let tree = trees[tabId] else { return } - let isRunningNow = tree.leaves().contains { surface in - isRunningProgressState(surface.bridge.state.progressState) + let now = Date() + var isRunningNow = false + for surface in tree.leaves() { + if isRunningProgressState(surface.bridge.state.progressState) { + isRunningNow = true + if surfaceRunningStartedAtById[surface.id] == nil { + surfaceRunningStartedAtById[surface.id] = now + } + } else { + surfaceRunningStartedAtById.removeValue(forKey: surface.id) + } } tabIsRunningById[tabId] = isRunningNow tabManager.updateDirty(tabId, isDirty: isRunningNow) @@ -2003,11 +2123,15 @@ final class WorktreeTerminalState { } } - private func handleCloseRequest(for view: GhosttySurfaceView, processAlive _: Bool) { + private func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { guard surfaces[view.id] != nil else { return } + if processAlive { + guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } + } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() surfaces.removeValue(forKey: view.id) + surfaceRunningStartedAtById.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) @@ -2017,6 +2141,7 @@ final class WorktreeTerminalState { guard let node = tree.find(id: view.id) else { view.closeSurface() surfaces.removeValue(forKey: view.id) + surfaceRunningStartedAtById.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) @@ -2030,6 +2155,7 @@ final class WorktreeTerminalState { let newTree = tree.removing(node) view.closeSurface() surfaces.removeValue(forKey: view.id) + surfaceRunningStartedAtById.removeValue(forKey: view.id) autoCloseSurfaceIds.remove(view.id) pendingCustomCommands.removeValue(forKey: view.id) cleanupCommandDetectorState(forSurfaceId: view.id) diff --git a/supacodeTests/TerminalCloseConfirmationPolicyTests.swift b/supacodeTests/TerminalCloseConfirmationPolicyTests.swift new file mode 100644 index 00000000..b3553fa2 --- /dev/null +++ b/supacodeTests/TerminalCloseConfirmationPolicyTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing + +@testable import supacode + +struct TerminalCloseConfirmationPolicyTests { + @Test func agentWorkingBlockedAndDoneRequireConfirmation() { + let protectedStates: [AgentDisplayState] = [.working, .blocked, .done] + + for state in protectedStates { + let decision = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: state, + commandRunningDuration: nil + ) + ] + ) + + #expect(decision.requiresConfirmation) + #expect(decision.protectedPaneCount == 1) + } + } + + @Test func agentIdleDoesNotRequireConfirmation() { + let decision = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: .idle, + commandRunningDuration: 30 + ) + ] + ) + + #expect(decision.requiresConfirmation == false) + } + + @Test func nonAgentCommandRequiresConfirmationOnlyAfterThreshold() { + let shortCommand = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: false, + agentDisplayState: nil, + commandRunningDuration: 9.9 + ) + ] + ) + let longCommand = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: false, + agentDisplayState: nil, + commandRunningDuration: 10 + ) + ] + ) + let finishedCommand = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: false, + agentDisplayState: nil, + commandRunningDuration: nil + ) + ] + ) + + #expect(shortCommand.requiresConfirmation == false) + #expect(longCommand.requiresConfirmation) + #expect(finishedCommand.requiresConfirmation == false) + } + + @Test func countsProtectedPanesAcrossTab() { + let decision = TerminalCloseConfirmationPolicy.decision( + for: [ + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: .working, + commandRunningDuration: nil + ), + TerminalCloseProtectionCandidate( + hasAgent: false, + agentDisplayState: nil, + commandRunningDuration: 14 + ), + TerminalCloseProtectionCandidate( + hasAgent: true, + agentDisplayState: .idle, + commandRunningDuration: 20 + ), + ] + ) + + #expect(decision.requiresConfirmation) + #expect(decision.protectedPaneCount == 2) + #expect(decision.reasons.contains(.agentActive)) + #expect(decision.reasons.contains(.longRunningCommand)) + } +} -- 2.51.2 From 1df7b8b6936392b3786157eea5bb7ba605838d9a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 19:42:08 +0900 Subject: [PATCH 196/528] Use explicit toolbar chrome background --- supacode/App/ContentView.swift | 3 - supacode/Domain/WindowChromeTint.swift | 145 +++--------------- .../Views/WorktreeDetailView.swift | 10 +- supacodeTests/WindowChromeTintTests.swift | 12 -- 4 files changed, 30 insertions(+), 140 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index b81660e3..7364bcba 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -82,9 +82,6 @@ struct ContentView: View { } .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) - .background { - WindowAppearanceSetter(colorScheme: store.settings.appearanceMode.colorScheme) - } .overlay { CommandPaletteOverlayView( store: store.scope(state: \.commandPalette, action: \.commandPalette), diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 80bf5a7d..af73bf17 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI /// Unified color logic for the window *chrome* tint — the nav-panel band @@ -75,13 +74,6 @@ enum WindowChromeTint { return Fill(color: customColor, alpha: saturatedPeakAlpha) } } - - /// Resolves the actual top chrome band height. SwiftUI's top safe-area - /// inset can collapse to zero when macOS moves the titlebar/toolbar into - /// full-screen chrome, so keep an AppKit-derived window chrome fallback. - static func topBandHeight(safeAreaTop: CGFloat, windowChromeTop: CGFloat) -> CGFloat { - max(safeAreaTop, windowChromeTop) - } } extension View { @@ -98,6 +90,15 @@ extension View { func windowChromeTint(_ fill: WindowChromeTint.Fill?, edges: Edge.Set) -> some View { modifier(WindowChromeTintModifier(fill: fill, edges: edges)) } + + /// Sets the real SwiftUI/AppKit window toolbar background. This is kept + /// separate from `windowChromeTint`: the tint bands color the content + /// behind full-bleed chrome, while the toolbar itself must also have an + /// explicit background because macOS can stop sampling that content when + /// a window is zoomed or fullscreen. + func windowToolbarChromeBackground(_ fill: WindowChromeTint.Fill?) -> some View { + modifier(WindowToolbarChromeBackgroundModifier(fill: fill)) + } } /// Measures the toolbar (top) and sidebar (leading) safe-area insets of @@ -108,14 +109,8 @@ private struct WindowChromeTintModifier: ViewModifier { @State private var topInset: CGFloat = 0 @State private var leadingInset: CGFloat = 0 - @State private var windowChromeTopInset: CGFloat = 0 func body(content: Content) -> some View { - let resolvedTopInset = WindowChromeTint.topBandHeight( - safeAreaTop: topInset, - windowChromeTop: windowChromeTopInset - ) - content // Content draws under the transparent titlebar, so the top // safe-area inset equals the toolbar/titlebar height — exactly the @@ -136,7 +131,7 @@ private struct WindowChromeTintModifier: ViewModifier { .overlay(alignment: .top) { if let fill, edges.contains(.top) { band(fill) - .frame(height: resolvedTopInset) + .frame(height: topInset) .frame(maxWidth: .infinity) .ignoresSafeArea(.container, edges: .top) } @@ -149,9 +144,6 @@ private struct WindowChromeTintModifier: ViewModifier { .ignoresSafeArea(.container, edges: [.leading, .top, .bottom]) } } - .background { - WindowChromeMetricsReader(topChromeHeight: $windowChromeTopInset) - } } private func band(_ fill: WindowChromeTint.Fill) -> some View { @@ -161,113 +153,18 @@ private struct WindowChromeTintModifier: ViewModifier { } } -private struct WindowChromeMetricsReader: NSViewRepresentable { - @Binding var topChromeHeight: CGFloat - - func makeNSView(context: Context) -> WindowChromeMetricsView { - let view = WindowChromeMetricsView() - view.onTopChromeHeightChange = makeHeightChangeHandler() - return view - } - - func updateNSView(_ nsView: WindowChromeMetricsView, context: Context) { - nsView.onTopChromeHeightChange = makeHeightChangeHandler() - nsView.refresh() - } - - private func makeHeightChangeHandler() -> (CGFloat) -> Void { - let binding = $topChromeHeight - return { height in - guard binding.wrappedValue != height else { return } - DispatchQueue.main.async { - guard binding.wrappedValue != height else { return } - binding.wrappedValue = height - } - } - } -} - -private final class WindowChromeMetricsView: NSView { - var onTopChromeHeightChange: ((CGFloat) -> Void)? - - private weak var observedWindow: NSWindow? - private var observers: [NSObjectProtocol] = [] - - deinit { - removeObservers() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - updateObservedWindow() - } - - override func layout() { - super.layout() - publishTopChromeHeight() - } - - func refresh() { - updateObservedWindow() - publishTopChromeHeight() - } - - private func updateObservedWindow() { - guard observedWindow !== window else { return } - - removeObservers() - observedWindow = window - - guard let window else { - onTopChromeHeightChange?(0) - return - } - - let notificationCenter = NotificationCenter.default - for name in Self.observedWindowNotifications { - let observer = notificationCenter.addObserver( - forName: name, - object: window, - queue: .main - ) { [weak self] _ in - self?.schedulePublishTopChromeHeight() - } - observers.append(observer) - } - - publishTopChromeHeight() - } - - private func removeObservers() { - let notificationCenter = NotificationCenter.default - for observer in observers { - notificationCenter.removeObserver(observer) - } - observers.removeAll() - } - - private func schedulePublishTopChromeHeight() { - publishTopChromeHeight() - DispatchQueue.main.async { [weak self] in - self?.publishTopChromeHeight() - } - } +private struct WindowToolbarChromeBackgroundModifier: ViewModifier { + let fill: WindowChromeTint.Fill? - private func publishTopChromeHeight() { - guard let window else { - onTopChromeHeightChange?(0) - return + @ViewBuilder + func body(content: Content) -> some View { + if let fill { + content + .toolbarBackground(fill.color.opacity(fill.alpha), for: .windowToolbar) + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) + } else { + content + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) } - - let topChromeHeight = max(0, window.frame.height - window.contentLayoutRect.height) - onTopChromeHeightChange?(topChromeHeight) } - - private static let observedWindowNotifications: [NSNotification.Name] = [ - NSWindow.didResizeNotification, - NSWindow.didEndLiveResizeNotification, - NSWindow.didEnterFullScreenNotification, - NSWindow.didExitFullScreenNotification, - NSWindow.didChangeScreenNotification, - ] } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index bb45946a..8c01e927 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -68,7 +68,6 @@ struct WorktreeDetailView: View { ) .navigationTitle(WindowTitle.compute(repositories: repositories, terminalManager: terminalManager)) .toolbar(removing: repositories.isShowingCanvas ? nil : .title) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) .toolbar { if repositories.isShowingCanvas { canvasToolbarContent( @@ -103,6 +102,7 @@ struct WorktreeDetailView: View { ) } } + .windowToolbarChromeBackground(toolbarChromeFill(repositories: repositories)) let actions = makeFocusedActions( repositories: repositories, hasActiveWorktree: hasActiveTerminalTarget, @@ -365,6 +365,14 @@ struct WorktreeDetailView: View { ) } + /// Resolves the real window toolbar background. Unlike the content tint + /// bands, this applies to the AppKit/SwiftUI toolbar surface itself, which + /// remains visible when macOS changes the zoomed/fullscreen window layout. + private func toolbarChromeFill(repositories: RepositoriesFeature.State) -> WindowChromeTint.Fill? { + guard !repositories.isShowingCanvas else { return nil } + return chromeFill(repositories: repositories, context: .normal) + } + private func applyFocusedActions( content: Content, actions: FocusedActions diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 050ac157..453d661f 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -43,18 +43,6 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) } - // MARK: - Chrome metrics - - @Test - func topBandHeightUsesWindowChromeFallbackWhenSafeAreaCollapses() { - #expect(WindowChromeTint.topBandHeight(safeAreaTop: 0, windowChromeTop: 52) == 52) - } - - @Test - func topBandHeightPrefersLargerSafeAreaMeasurement() { - #expect(WindowChromeTint.topBandHeight(safeAreaTop: 54, windowChromeTop: 28) == 54) - } - // MARK: - TintColor persistence @Test -- 2.51.2 From 3914a2b940d0c967641ae9d3f2eaee34d071a6a7 Mon Sep 17 00:00:00 2001 From: onevclaw Date: Mon, 25 May 2026 19:42:48 +0900 Subject: [PATCH 197/528] Fix active agent selection for plain folders --- .../Reducer/RepositoriesFeature.swift | 19 +++++-- supacodeTests/RepositoriesFeatureTests.swift | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 459bad13..3e81549c 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -418,14 +418,23 @@ struct RepositoriesFeature { case .activeAgents(.entryTapped(let id)): guard let entry = state.activeAgents.entries[id: id] else { return .none } + let isPlainFolder = + state.repositories[id: entry.worktreeID]?.kind == .plain + if isPlainFolder { + state.pendingTerminalFocusWorktreeIDs.insert(entry.worktreeID) + } return .run { send in // Focus the target surface (which selects its tab) before making the - // worktree visible, so it shows the right tab immediately instead of - // flashing its previously-focused tab. Then select the worktree with - // `focusTerminal` so the terminal view focuses the now-correct selected - // tab once it appears, regardless of where first responder currently is. + // terminal target visible, so it shows the right tab immediately instead + // of flashing its previously-focused tab. Plain folders are represented + // by their repository id, not a real worktree row, so they must select the + // repository rather than attempting a worktree selection. _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) - await send(.selectWorktree(entry.worktreeID, focusTerminal: true)) + if isPlainFolder { + await send(.selectRepository(entry.worktreeID)) + } else { + await send(.selectWorktree(entry.worktreeID, focusTerminal: true)) + } } case .activeAgents: diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2e999075..54a7d4a7 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -977,6 +977,55 @@ struct RepositoriesFeatureTests { #expect(focusedSurface.value?.1 == surfaceID) } + @Test func activeAgentEntryTappedSelectsPlainRepository() async { + let repository = makeRepository( + id: "/tmp/folder", + name: "folder", + kind: .plain, + worktrees: [] + ) + var state = makeState(repositories: [repository]) + let surfaceID = UUID() + let entry = ActiveAgentEntry( + id: surfaceID, + worktreeID: repository.id, + worktreeName: repository.name, + tabID: TerminalTabID(rawValue: UUID()), + tabTitle: "agent", + surfaceID: surfaceID, + paneIndex: 0, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + state.activeAgents.entries = [entry] + + let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.focusSurface = { worktreeID, surface in + focusedSurface.setValue((worktreeID, surface)) + return true + } + } + + await store.send(.activeAgents(.entryTapped(entry.id))) { + $0.pendingTerminalFocusWorktreeIDs = [repository.id] + $0.activeAgents.focusedSurfaceID = surfaceID + } + await store.receive(\.selectRepository) { + $0.selection = .repository(repository.id) + $0.sidebarSelectedWorktreeIDs = [] + $0.openedWorktreeIDs = [repository.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + + #expect(focusedSurface.value?.0 == repository.id) + #expect(focusedSurface.value?.1 == surfaceID) + } + @Test func selectWorktreeCollapsesSidebarSelectedWorktreeIDs() async { let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") -- 2.51.2 From 98402117a44069fcb8738382ee8feaec115a5a46 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 19:54:31 +0900 Subject: [PATCH 198/528] Resolve toolbar tint for app appearance --- supacode/Domain/WindowChromeTint.swift | 84 ++++++++++++++++++++--- supacodeTests/WindowChromeTintTests.swift | 33 +++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index af73bf17..c712fbad 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Unified color logic for the window *chrome* tint — the nav-panel band @@ -17,6 +18,20 @@ import SwiftUI /// pair the Shelf spine uses, so the chrome and the open spine match. /// - `.custom` ⇒ a single user-chosen color, ignoring per-repo colors. enum WindowChromeTint { + struct RGBComponents: Equatable { + var red: Double + var green: Double + var blue: Double + + var color: Color { + Color(.sRGB, red: red, green: green, blue: blue) + } + + var luminance: Double { + 0.2126 * red + 0.7152 * green + 0.0722 * blue + } + } + /// A resolved chrome band: a base color plus the alpha it should be /// filled at. `nil` from `fill(...)` means "draw no band". struct Fill: Equatable { @@ -74,6 +89,58 @@ enum WindowChromeTint { return Fill(color: customColor, alpha: saturatedPeakAlpha) } } + + /// Resolves the toolbar background to an opaque sRGB color under the + /// requested app appearance. `toolbarBackground` sits on the real AppKit + /// toolbar surface, so a translucent semantic color can otherwise be + /// resolved against the system appearance captured when the app launched. + static func toolbarBackgroundComponents( + fill: Fill?, + colorScheme: ColorScheme + ) -> RGBComponents { + let base = resolvedComponents(for: NSColor.windowBackgroundColor, colorScheme: colorScheme) + guard let fill else { + return base + } + + let overlay = resolvedComponents(for: fill.color, colorScheme: colorScheme) + let alpha = min(max(fill.alpha, 0), 1) + return RGBComponents( + red: overlay.red * alpha + base.red * (1 - alpha), + green: overlay.green * alpha + base.green * (1 - alpha), + blue: overlay.blue * alpha + base.blue * (1 - alpha) + ) + } + + private static func resolvedComponents(for color: Color, colorScheme: ColorScheme) -> RGBComponents { + resolvedComponents(for: NSColor(color), colorScheme: colorScheme) + } + + private static func resolvedComponents(for color: NSColor, colorScheme: ColorScheme) -> RGBComponents { + withAppearance(for: colorScheme) { + let resolved = color.usingColorSpace(.sRGB) ?? fallbackColor(for: colorScheme) + return RGBComponents( + red: Double(resolved.redComponent), + green: Double(resolved.greenComponent), + blue: Double(resolved.blueComponent) + ) + } + } + + private static func withAppearance(for colorScheme: ColorScheme, _ body: () -> T) -> T { + guard let appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) else { + return body() + } + + let previous = NSAppearance.current + NSAppearance.current = appearance + defer { NSAppearance.current = previous } + return body() + } + + private static func fallbackColor(for colorScheme: ColorScheme) -> NSColor { + colorScheme == .dark ? .black : .white + } } extension View { @@ -155,16 +222,17 @@ private struct WindowChromeTintModifier: ViewModifier { private struct WindowToolbarChromeBackgroundModifier: ViewModifier { let fill: WindowChromeTint.Fill? + @Environment(\.colorScheme) private var colorScheme @ViewBuilder func body(content: Content) -> some View { - if let fill { - content - .toolbarBackground(fill.color.opacity(fill.alpha), for: .windowToolbar) - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) - } else { - content - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) - } + let background = WindowChromeTint.toolbarBackgroundComponents( + fill: fill, + colorScheme: colorScheme + ).color + + content + .toolbarBackground(background, for: .windowToolbar) + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) } } diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 453d661f..a947734c 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import Testing @@ -43,6 +44,38 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) } + // MARK: - Toolbar background resolution + + @Test + func toolbarBackgroundUsesRequestedColorSchemeForNeutralChrome() { + let light = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + let dark = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .dark) + + #expect(light.luminance > dark.luminance) + } + + @Test + func toolbarBackgroundUsesRequestedColorSchemeForPrimaryTintFallback() { + let fill = WindowChromeTint.Fill(color: .primary, alpha: WindowChromeTint.neutralPeakAlpha) + let light = WindowChromeTint.toolbarBackgroundComponents(fill: fill, colorScheme: .light) + let dark = WindowChromeTint.toolbarBackgroundComponents(fill: fill, colorScheme: .dark) + + #expect(light.luminance > dark.luminance) + } + + @Test + func toolbarBackgroundIgnoresCurrentSystemAppearance() { + let previous = NSAppearance.current + defer { NSAppearance.current = previous } + + NSAppearance.current = NSAppearance(named: .darkAqua) + let lightWhileCurrentIsDark = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + NSAppearance.current = NSAppearance(named: .aqua) + let lightWhileCurrentIsLight = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + + #expect(lightWhileCurrentIsDark == lightWhileCurrentIsLight) + } + // MARK: - TintColor persistence @Test -- 2.51.2 From 5d74bc24212cebb744ba2533fa87fb84bfd4f2ed Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 20:01:14 +0900 Subject: [PATCH 199/528] Avoid deprecated appearance API --- supacode/Domain/WindowChromeTint.swift | 9 ++++---- supacodeTests/WindowChromeTintTests.swift | 25 ++++++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index c712fbad..689afb3a 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -132,10 +132,11 @@ enum WindowChromeTint { return body() } - let previous = NSAppearance.current - NSAppearance.current = appearance - defer { NSAppearance.current = previous } - return body() + var result: T? + appearance.performAsCurrentDrawingAppearance { + result = body() + } + return result! } private static func fallbackColor(for colorScheme: ColorScheme) -> NSColor { diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index a947734c..42ebc737 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -65,13 +65,12 @@ struct WindowChromeTintTests { @Test func toolbarBackgroundIgnoresCurrentSystemAppearance() { - let previous = NSAppearance.current - defer { NSAppearance.current = previous } - - NSAppearance.current = NSAppearance(named: .darkAqua) - let lightWhileCurrentIsDark = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) - NSAppearance.current = NSAppearance(named: .aqua) - let lightWhileCurrentIsLight = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + let lightWhileCurrentIsDark = withDrawingAppearance(.darkAqua) { + WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + } + let lightWhileCurrentIsLight = withDrawingAppearance(.aqua) { + WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + } #expect(lightWhileCurrentIsDark == lightWhileCurrentIsLight) } @@ -85,4 +84,16 @@ struct WindowChromeTintTests { let decoded = try JSONDecoder().decode(TintColor.self, from: data) #expect(decoded == original) } + + private func withDrawingAppearance(_ name: NSAppearance.Name, _ body: () -> T) -> T { + guard let appearance = NSAppearance(named: name) else { + return body() + } + + var result: T? + appearance.performAsCurrentDrawingAppearance { + result = body() + } + return result! + } } -- 2.51.2 From fff715b9659304ddbee4268b73d0cb61b88e5415 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 20:12:18 +0900 Subject: [PATCH 200/528] Align shelf spine chrome color --- supacode/Domain/WindowChromeTint.swift | 15 ++++++---- .../Features/Shelf/Views/ShelfSpineView.swift | 8 ++++-- supacodeTests/WindowChromeTintTests.swift | 28 +++++++++++++------ 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 689afb3a..14d4fae5 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -90,11 +90,14 @@ enum WindowChromeTint { } } - /// Resolves the toolbar background to an opaque sRGB color under the - /// requested app appearance. `toolbarBackground` sits on the real AppKit - /// toolbar surface, so a translucent semantic color can otherwise be - /// resolved against the system appearance captured when the app launched. - static func toolbarBackgroundComponents( + /// Resolves a chrome surface background to an opaque sRGB color under + /// the requested app appearance. `toolbarBackground` sits on the real + /// AppKit toolbar surface, so a translucent semantic color can otherwise + /// be resolved against the system appearance captured when the app + /// launched. Shelf spines use this same precomposed color so the open + /// spine and toolbar read as one continuous surface in light and dark + /// appearances. + static func surfaceBackgroundComponents( fill: Fill?, colorScheme: ColorScheme ) -> RGBComponents { @@ -227,7 +230,7 @@ private struct WindowToolbarChromeBackgroundModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { - let background = WindowChromeTint.toolbarBackgroundComponents( + let background = WindowChromeTint.surfaceBackgroundComponents( fill: fill, colorScheme: colorScheme ).color diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index f121ec84..eff20ef6 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -42,6 +42,7 @@ struct ShelfSpineView: View { @State private var isHovering = false @Shared(.repositoryAppearances) private var repositoryAppearances @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts + @Environment(\.colorScheme) private var colorScheme var body: some View { // Body-invocation counter signpost. With ~10 spines visible during @@ -134,8 +135,11 @@ struct ShelfSpineView: View { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return WindowChromeTint.repositoryBase(for: appearance.color) - .opacity(WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier) + let fill = WindowChromeTint.Fill( + color: WindowChromeTint.repositoryBase(for: appearance.color), + alpha: WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier + ) + return WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: colorScheme).color } /// Repo's pinned color, or `.accentColor` when none — used as the header diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 42ebc737..8fcbf0b6 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -47,34 +47,44 @@ struct WindowChromeTintTests { // MARK: - Toolbar background resolution @Test - func toolbarBackgroundUsesRequestedColorSchemeForNeutralChrome() { - let light = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) - let dark = WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .dark) + func surfaceBackgroundUsesRequestedColorSchemeForNeutralChrome() { + let light = WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) + let dark = WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .dark) #expect(light.luminance > dark.luminance) } @Test - func toolbarBackgroundUsesRequestedColorSchemeForPrimaryTintFallback() { + func surfaceBackgroundUsesRequestedColorSchemeForPrimaryTintFallback() { let fill = WindowChromeTint.Fill(color: .primary, alpha: WindowChromeTint.neutralPeakAlpha) - let light = WindowChromeTint.toolbarBackgroundComponents(fill: fill, colorScheme: .light) - let dark = WindowChromeTint.toolbarBackgroundComponents(fill: fill, colorScheme: .dark) + let light = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) + let dark = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .dark) #expect(light.luminance > dark.luminance) } @Test - func toolbarBackgroundIgnoresCurrentSystemAppearance() { + func surfaceBackgroundIgnoresCurrentSystemAppearance() { let lightWhileCurrentIsDark = withDrawingAppearance(.darkAqua) { - WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) } let lightWhileCurrentIsLight = withDrawingAppearance(.aqua) { - WindowChromeTint.toolbarBackgroundComponents(fill: nil, colorScheme: .light) + WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) } #expect(lightWhileCurrentIsDark == lightWhileCurrentIsLight) } + @Test + func sameFillProducesSameSurfaceBackgroundForToolbarAndShelfSpine() { + let fill = WindowChromeTint.Fill(color: .red, alpha: WindowChromeTint.saturatedPeakAlpha) + + let toolbarBackground = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) + let shelfSpineBackground = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) + + #expect(toolbarBackground == shelfSpineBackground) + } + // MARK: - TintColor persistence @Test -- 2.51.2 From 106feb142e8f9e00432bf766a57f3c9b6d24043f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 20:26:55 +0900 Subject: [PATCH 201/528] Limit toolbar fallback to fullscreen --- supacode/Domain/WindowChromeTint.swift | 143 ++++++++++++++++-- .../Features/Shelf/Views/ShelfSpineView.swift | 8 +- supacodeTests/WindowChromeTintTests.swift | 28 ++-- 3 files changed, 142 insertions(+), 37 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 14d4fae5..60052f84 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -90,14 +90,13 @@ enum WindowChromeTint { } } - /// Resolves a chrome surface background to an opaque sRGB color under - /// the requested app appearance. `toolbarBackground` sits on the real - /// AppKit toolbar surface, so a translucent semantic color can otherwise - /// be resolved against the system appearance captured when the app - /// launched. Shelf spines use this same precomposed color so the open - /// spine and toolbar read as one continuous surface in light and dark - /// appearances. - static func surfaceBackgroundComponents( + /// Resolves the fallback fullscreen toolbar background to an opaque sRGB + /// color under the requested app appearance. The normal window path keeps + /// the toolbar background hidden so the original content tint / system + /// material composition remains untouched; this fallback is only for the + /// fullscreen AppKit toolbar surface, which can stop sampling the content + /// behind it. + static func fullscreenToolbarBackgroundComponents( fill: Fill?, colorScheme: ColorScheme ) -> RGBComponents { @@ -145,6 +144,10 @@ enum WindowChromeTint { private static func fallbackColor(for colorScheme: ColorScheme) -> NSColor { colorScheme == .dark ? .black : .white } + + static func usesExplicitToolbarBackground(isFullScreen: Bool) -> Bool { + isFullScreen + } } extension View { @@ -227,16 +230,126 @@ private struct WindowChromeTintModifier: ViewModifier { private struct WindowToolbarChromeBackgroundModifier: ViewModifier { let fill: WindowChromeTint.Fill? @Environment(\.colorScheme) private var colorScheme + @State private var isFullScreen = false @ViewBuilder func body(content: Content) -> some View { - let background = WindowChromeTint.surfaceBackgroundComponents( - fill: fill, - colorScheme: colorScheme - ).color + if WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: isFullScreen) { + let background = WindowChromeTint.fullscreenToolbarBackgroundComponents( + fill: fill, + colorScheme: colorScheme + ).color - content - .toolbarBackground(background, for: .windowToolbar) - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) + content + .toolbarBackground(background, for: .windowToolbar) + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) + .background { WindowFullScreenReader(isFullScreen: $isFullScreen) } + } else { + content + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + .background { WindowFullScreenReader(isFullScreen: $isFullScreen) } + } + } +} + +private struct WindowFullScreenReader: NSViewRepresentable { + @Binding var isFullScreen: Bool + + func makeNSView(context: Context) -> WindowFullScreenReaderView { + let view = WindowFullScreenReaderView() + view.onFullScreenChange = makeChangeHandler() + return view + } + + func updateNSView(_ nsView: WindowFullScreenReaderView, context: Context) { + nsView.onFullScreenChange = makeChangeHandler() + nsView.refresh() + } + + private func makeChangeHandler() -> (Bool) -> Void { + let binding = $isFullScreen + return { isFullScreen in + guard binding.wrappedValue != isFullScreen else { return } + DispatchQueue.main.async { + guard binding.wrappedValue != isFullScreen else { return } + binding.wrappedValue = isFullScreen + } + } + } +} + +@MainActor +private final class WindowFullScreenReaderView: NSView { + var onFullScreenChange: ((Bool) -> Void)? + + private weak var observedWindow: NSWindow? + private var observers: [NSObjectProtocol] = [] + + deinit { + MainActor.assumeIsolated { + removeObservers() + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateObservedWindow() } + + func refresh() { + updateObservedWindow() + publishFullScreenState() + } + + private func updateObservedWindow() { + guard observedWindow !== window else { return } + + removeObservers() + observedWindow = window + + guard let window else { + onFullScreenChange?(false) + return + } + + let notificationCenter = NotificationCenter.default + for name in Self.observedWindowNotifications { + let observer = notificationCenter.addObserver( + forName: name, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.schedulePublishFullScreenState() + } + } + observers.append(observer) + } + + publishFullScreenState() + } + + private func removeObservers() { + let notificationCenter = NotificationCenter.default + for observer in observers { + notificationCenter.removeObserver(observer) + } + observers.removeAll() + } + + private func schedulePublishFullScreenState() { + publishFullScreenState() + DispatchQueue.main.async { [weak self] in + self?.publishFullScreenState() + } + } + + private func publishFullScreenState() { + onFullScreenChange?(window?.styleMask.contains(.fullScreen) == true) + } + + private static let observedWindowNotifications: [NSNotification.Name] = [ + NSWindow.didEnterFullScreenNotification, + NSWindow.didExitFullScreenNotification, + ] } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index eff20ef6..f121ec84 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -42,7 +42,6 @@ struct ShelfSpineView: View { @State private var isHovering = false @Shared(.repositoryAppearances) private var repositoryAppearances @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts - @Environment(\.colorScheme) private var colorScheme var body: some View { // Body-invocation counter signpost. With ~10 spines visible during @@ -135,11 +134,8 @@ struct ShelfSpineView: View { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - let fill = WindowChromeTint.Fill( - color: WindowChromeTint.repositoryBase(for: appearance.color), - alpha: WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier - ) - return WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: colorScheme).color + return WindowChromeTint.repositoryBase(for: appearance.color) + .opacity(WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier) } /// Repo's pinned color, or `.accentColor` when none — used as the header diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 8fcbf0b6..7d01f500 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -47,42 +47,38 @@ struct WindowChromeTintTests { // MARK: - Toolbar background resolution @Test - func surfaceBackgroundUsesRequestedColorSchemeForNeutralChrome() { - let light = WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) - let dark = WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .dark) + func fullscreenToolbarBackgroundUsesRequestedColorSchemeForNeutralChrome() { + let light = WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: nil, colorScheme: .light) + let dark = WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: nil, colorScheme: .dark) #expect(light.luminance > dark.luminance) } @Test - func surfaceBackgroundUsesRequestedColorSchemeForPrimaryTintFallback() { + func fullscreenToolbarBackgroundUsesRequestedColorSchemeForPrimaryTintFallback() { let fill = WindowChromeTint.Fill(color: .primary, alpha: WindowChromeTint.neutralPeakAlpha) - let light = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) - let dark = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .dark) + let light = WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: fill, colorScheme: .light) + let dark = WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: fill, colorScheme: .dark) #expect(light.luminance > dark.luminance) } @Test - func surfaceBackgroundIgnoresCurrentSystemAppearance() { + func fullscreenToolbarBackgroundIgnoresCurrentSystemAppearance() { let lightWhileCurrentIsDark = withDrawingAppearance(.darkAqua) { - WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) + WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: nil, colorScheme: .light) } let lightWhileCurrentIsLight = withDrawingAppearance(.aqua) { - WindowChromeTint.surfaceBackgroundComponents(fill: nil, colorScheme: .light) + WindowChromeTint.fullscreenToolbarBackgroundComponents(fill: nil, colorScheme: .light) } #expect(lightWhileCurrentIsDark == lightWhileCurrentIsLight) } @Test - func sameFillProducesSameSurfaceBackgroundForToolbarAndShelfSpine() { - let fill = WindowChromeTint.Fill(color: .red, alpha: WindowChromeTint.saturatedPeakAlpha) - - let toolbarBackground = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) - let shelfSpineBackground = WindowChromeTint.surfaceBackgroundComponents(fill: fill, colorScheme: .light) - - #expect(toolbarBackground == shelfSpineBackground) + func explicitToolbarBackgroundIsOnlyForFullscreen() { + #expect(!WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: false)) + #expect(WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: true)) } // MARK: - TintColor persistence -- 2.51.2 From 472b8c32946a56e1da325b254aabd3de5a78aeb6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 21:03:23 +0900 Subject: [PATCH 202/528] Stabilize fullscreen toolbar fallback --- supacode/Domain/WindowChromeTint.swift | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 60052f84..c117f2c7 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -234,21 +234,17 @@ private struct WindowToolbarChromeBackgroundModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { - if WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: isFullScreen) { - let background = WindowChromeTint.fullscreenToolbarBackgroundComponents( - fill: fill, - colorScheme: colorScheme - ).color - - content - .toolbarBackground(background, for: .windowToolbar) - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) - .background { WindowFullScreenReader(isFullScreen: $isFullScreen) } - } else { - content - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - .background { WindowFullScreenReader(isFullScreen: $isFullScreen) } - } + let background = WindowChromeTint.fullscreenToolbarBackgroundComponents( + fill: fill, + colorScheme: colorScheme + ).color + let visibility: Visibility = + WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: isFullScreen) ? .visible : .hidden + + content + .toolbarBackground(background, for: .windowToolbar) + .toolbarBackgroundVisibility(visibility, for: .windowToolbar) + .background { WindowFullScreenReader(isFullScreen: $isFullScreen) } } } @@ -308,7 +304,10 @@ private final class WindowFullScreenReaderView: NSView { observedWindow = window guard let window else { - onFullScreenChange?(false) + // SwiftUI can temporarily detach this reader while rebuilding the + // toolbar host. Detach is not an exit-fullscreen signal; publishing + // `false` here would hide the fallback background for one frame and + // produce a visible flicker. return } @@ -345,7 +344,8 @@ private final class WindowFullScreenReaderView: NSView { } private func publishFullScreenState() { - onFullScreenChange?(window?.styleMask.contains(.fullScreen) == true) + guard let window else { return } + onFullScreenChange?(window.styleMask.contains(.fullScreen)) } private static let observedWindowNotifications: [NSNotification.Name] = [ -- 2.51.2 From 235a3cbfe29acbd7dc58f84e5db3a5096afad52a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 21:12:48 +0900 Subject: [PATCH 203/528] Enable toolbar fallback during fullscreen transition --- supacode/Domain/WindowChromeTint.swift | 56 +++++++++++++++----------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index c117f2c7..09638c41 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -311,23 +311,39 @@ private final class WindowFullScreenReaderView: NSView { return } - let notificationCenter = NotificationCenter.default - for name in Self.observedWindowNotifications { - let observer = notificationCenter.addObserver( - forName: name, - object: window, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.schedulePublishFullScreenState() - } - } - observers.append(observer) + observe(window, name: NSWindow.willEnterFullScreenNotification) { [weak self] in + self?.publishFullScreenState(true) + } + observe(window, name: NSWindow.didEnterFullScreenNotification) { [weak self] in + self?.publishFullScreenState(true) + } + observe(window, name: NSWindow.willExitFullScreenNotification) { [weak self] in + self?.publishFullScreenState(true) + } + observe(window, name: NSWindow.didExitFullScreenNotification) { [weak self] in + self?.publishFullScreenState(false) } publishFullScreenState() } + private func observe( + _ window: NSWindow, + name: NSNotification.Name, + handler: @escaping @MainActor () -> Void + ) { + let observer = NotificationCenter.default.addObserver( + forName: name, + object: window, + queue: .main + ) { _ in + MainActor.assumeIsolated { + handler() + } + } + observers.append(observer) + } + private func removeObservers() { let notificationCenter = NotificationCenter.default for observer in observers { @@ -336,20 +352,12 @@ private final class WindowFullScreenReaderView: NSView { observers.removeAll() } - private func schedulePublishFullScreenState() { - publishFullScreenState() - DispatchQueue.main.async { [weak self] in - self?.publishFullScreenState() - } - } - private func publishFullScreenState() { guard let window else { return } - onFullScreenChange?(window.styleMask.contains(.fullScreen)) + publishFullScreenState(window.styleMask.contains(.fullScreen)) } - private static let observedWindowNotifications: [NSNotification.Name] = [ - NSWindow.didEnterFullScreenNotification, - NSWindow.didExitFullScreenNotification, - ] + private func publishFullScreenState(_ isFullScreen: Bool) { + onFullScreenChange?(isFullScreen) + } } -- 2.51.2 From 4f2606094eaf493626a162840f44c9f57383f72b Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 21:19:09 +0900 Subject: [PATCH 204/528] Cover toolbar fullscreen transition fallback --- supacode/Domain/WindowChromeTint.swift | 38 ++++++++++++++++++----- supacodeTests/WindowChromeTintTests.swift | 32 +++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 09638c41..e221a857 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -18,6 +18,14 @@ import SwiftUI /// pair the Shelf spine uses, so the chrome and the open spine match. /// - `.custom` ⇒ a single user-chosen color, ignoring per-repo colors. enum WindowChromeTint { + enum ToolbarFallbackEvent { + case windowState(isFullScreen: Bool) + case willEnterFullScreen + case didEnterFullScreen + case willExitFullScreen + case didExitFullScreen + } + struct RGBComponents: Equatable { var red: Double var green: Double @@ -148,6 +156,17 @@ enum WindowChromeTint { static func usesExplicitToolbarBackground(isFullScreen: Bool) -> Bool { isFullScreen } + + static func toolbarFallbackState(current: Bool, event: ToolbarFallbackEvent) -> Bool { + switch event { + case .windowState(let isFullScreen): + return isFullScreen + case .willEnterFullScreen, .didEnterFullScreen, .willExitFullScreen: + return true + case .didExitFullScreen: + return false + } + } } extension View { @@ -312,16 +331,16 @@ private final class WindowFullScreenReaderView: NSView { } observe(window, name: NSWindow.willEnterFullScreenNotification) { [weak self] in - self?.publishFullScreenState(true) + self?.publishFullScreenEvent(.willEnterFullScreen) } observe(window, name: NSWindow.didEnterFullScreenNotification) { [weak self] in - self?.publishFullScreenState(true) + self?.publishFullScreenEvent(.didEnterFullScreen) } observe(window, name: NSWindow.willExitFullScreenNotification) { [weak self] in - self?.publishFullScreenState(true) + self?.publishFullScreenEvent(.willExitFullScreen) } observe(window, name: NSWindow.didExitFullScreenNotification) { [weak self] in - self?.publishFullScreenState(false) + self?.publishFullScreenEvent(.didExitFullScreen) } publishFullScreenState() @@ -354,10 +373,15 @@ private final class WindowFullScreenReaderView: NSView { private func publishFullScreenState() { guard let window else { return } - publishFullScreenState(window.styleMask.contains(.fullScreen)) + publishFullScreenEvent(.windowState(isFullScreen: window.styleMask.contains(.fullScreen))) } - private func publishFullScreenState(_ isFullScreen: Bool) { - onFullScreenChange?(isFullScreen) + private func publishFullScreenEvent(_ event: WindowChromeTint.ToolbarFallbackEvent) { + onFullScreenChange?( + WindowChromeTint.toolbarFallbackState( + current: window?.styleMask.contains(.fullScreen) == true, + event: event + ) + ) } } diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index 7d01f500..e52fcc6d 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -81,6 +81,38 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: true)) } + @Test + func toolbarFallbackTurnsOnAtStartOfEnterFullScreenTransition() { + #expect( + WindowChromeTint.toolbarFallbackState( + current: false, + event: .willEnterFullScreen + ) + ) + #expect( + WindowChromeTint.toolbarFallbackState( + current: false, + event: .didEnterFullScreen + ) + ) + } + + @Test + func toolbarFallbackStaysOnUntilExitFullScreenFinishes() { + #expect( + WindowChromeTint.toolbarFallbackState( + current: true, + event: .willExitFullScreen + ) + ) + #expect( + !WindowChromeTint.toolbarFallbackState( + current: true, + event: .didExitFullScreen + ) + ) + } + // MARK: - TintColor persistence @Test -- 2.51.2 From 06c0bd0dc73ae2ed4b821d3a58ccd7775c84b124 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 22:14:39 +0900 Subject: [PATCH 205/528] Deduplicate discovered worktrees --- supacode/Clients/Git/GitClient.swift | 6 ++- .../RepositoryPersistenceClient.swift | 2 +- .../Reducer/RepositoriesFeature.swift | 2 +- .../GitClientWorktreeDiscoveryTests.swift | 25 ++++++++++++ .../RepositoryPersistenceClientTests.swift | 38 +++++++++++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 61f99ea5..9ca5e5ff 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -93,11 +93,15 @@ struct GitClient { let data = Data(trimmed.utf8) let entries = try JSONDecoder().decode([GitWtWorktreeEntry].self, from: data) .filter { !$0.isBare } - let worktreeEntries = entries.enumerated().map { index, entry in + var seenWorktreeIDs = Set() + let worktreeEntries: [WorktreeSortEntry] = entries.enumerated().compactMap { index, entry -> WorktreeSortEntry? in let worktreeURL = URL(fileURLWithPath: entry.path).standardizedFileURL let name = entry.branch.isEmpty ? worktreeURL.lastPathComponent : entry.branch let detail = Self.relativePath(from: repositoryRootURL, to: worktreeURL) let id = worktreeURL.path(percentEncoded: false) + guard seenWorktreeIDs.insert(id).inserted else { + return nil + } let resourceValues = try? worktreeURL.resourceValues(forKeys: [ .creationDateKey, .contentModificationDateKey, ]) diff --git a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift index 410e347c..6bf37c0c 100644 --- a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift +++ b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift @@ -310,7 +310,7 @@ extension RepositorySnapshotCachePayload { rootURL: rootURL, name: repositoryName.isEmpty ? Repository.name(for: rootURL) : repositoryName, kind: kind, - worktrees: IdentifiedArray(uniqueElements: restoredWorktrees) + worktrees: IdentifiedArray(restoredWorktrees, uniquingIDsWith: { current, _ in current }) ) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 3e81549c..74c268c5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1439,7 +1439,7 @@ struct RepositoriesFeature { rootURL: rootURL, name: Repository.name(for: rootURL), kind: .git, - worktrees: IdentifiedArray(uniqueElements: worktrees) + worktrees: IdentifiedArray(worktrees, uniquingIDsWith: { current, _ in current }) ), errorMessage: nil ) diff --git a/supacodeTests/GitClientWorktreeDiscoveryTests.swift b/supacodeTests/GitClientWorktreeDiscoveryTests.swift index db905ee2..a7202de5 100644 --- a/supacodeTests/GitClientWorktreeDiscoveryTests.swift +++ b/supacodeTests/GitClientWorktreeDiscoveryTests.swift @@ -140,6 +140,31 @@ struct GitClientWorktreeDiscoveryTests { #expect(recorder.loginInvocations().isEmpty) } + @Test func worktreesDeduplicateStandardizedPaths() async throws { + let output = """ + [ + {"branch":"main","path":"/tmp/repo","head":"abc","is_bare":false}, + {"branch":"feature","path":"/tmp/repo/.worktrees/feature","head":"def","is_bare":false}, + {"branch":"feature","path":"/tmp/repo/.worktrees/./feature","head":"def","is_bare":false} + ] + """ + let shell = ShellClient( + run: { _, _, _ in + ShellOutput(stdout: output, stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in + Issue.record("worktrees should not use runLogin when direct execution succeeds") + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + let client = GitClient(shell: shell) + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + + let worktrees = try await client.worktrees(for: repoRoot) + + #expect(worktrees.map(\.id) == ["/tmp/repo", "/tmp/repo/.worktrees/feature"]) + } + @Test func repoRootFallsBackToLoginShellWhenDirectExecutionCannotResolveGit() async throws { let recorder = GitWorktreeDiscoveryRecorder() let shell = ShellClient( diff --git a/supacodeTests/RepositoryPersistenceClientTests.swift b/supacodeTests/RepositoryPersistenceClientTests.swift index 030fd0e1..713740ef 100644 --- a/supacodeTests/RepositoryPersistenceClientTests.swift +++ b/supacodeTests/RepositoryPersistenceClientTests.swift @@ -167,6 +167,44 @@ struct RepositoryPersistenceClientTests { #expect(restored == [repository]) } + @Test func repositorySnapshotPayloadDeduplicatesRestoredWorktreeIDs() throws { + let payloadData = Data( + """ + { + "version": 2, + "repositories": [ + { + "rootPath": "/tmp/repo", + "name": "repo", + "kind": "git", + "worktrees": [ + { + "name": "feature", + "detail": ".worktrees/feature", + "workingDirectoryPath": "/tmp/repo/.worktrees/feature", + "createdAt": null + }, + { + "name": "duplicate", + "detail": ".worktrees/feature", + "workingDirectoryPath": "/tmp/repo/.worktrees/./feature", + "createdAt": null + } + ] + } + ] + } + """.utf8 + ) + let payload = try JSONDecoder().decode(RepositorySnapshotCachePayload.self, from: payloadData) + + let restored = payload.restoreRepositories { path in + path == "/tmp/repo" || path == "/tmp/repo/.worktrees/feature" + } + + #expect(restored?.first?.worktrees.map(\.name) == ["feature"]) + } + @Test func repositorySnapshotPayloadRejectsMissingWorktreePath() { let repoRoot = "/tmp/repo" let worktree = Worktree( -- 2.51.2 From eb7c868e62eef94d8dbf0bcf80a59469a433465c Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 22:53:02 +0900 Subject: [PATCH 206/528] Upgrade Sparkle to 2.9.2 and harden update driver isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sparkle was pinned to the January beta 2.9.0-beta.2; bump to the latest stable 2.9.2, which picks up three stable releases of fixes (including a crash fix and memory-safety guards) and gets the auto-updater off a beta in notarized builds. SPUUserDriver is declared NS_SWIFT_UI_ACTOR (main-actor isolated) as of Sparkle 2.9, so SilentUpdateDriver no longer needs the `nonisolated` + `MainActor.assumeIsolated` boilerplate on every callback. Implement them as plain @MainActor methods and let the compiler enforce isolation; this removes ~16 assumeIsolated trap points that would crash on any off-main delivery. Sparkle ships as a prebuilt binaryTarget (Sparkle.xcframework), so Xcode never emits a dSYM for it into the archive — the archive-dSYM upload in release.sh therefore never covers Sparkle. Its symbols only reached Sentry via a one-off manual bulk upload, so a new Sparkle version would otherwise ship unsymbolicated. Upload the dSYMs bundled inside the xcframework from release.sh so each new Sparkle version symbolicates automatically. --- doc-onevcat/scripts/release.sh | 13 ++ supacode.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- supacode/Clients/Updates/UpdaterClient.swift | 124 +++++++----------- 4 files changed, 63 insertions(+), 80 deletions(-) diff --git a/doc-onevcat/scripts/release.sh b/doc-onevcat/scripts/release.sh index 17511041..2317ad54 100755 --- a/doc-onevcat/scripts/release.sh +++ b/doc-onevcat/scripts/release.sh @@ -223,6 +223,19 @@ if [[ "$SENTRY_ENABLED" -eq 1 ]]; then log "WARNING: $DSYM_DIR not found, skipping dSYM upload" fi + # Sparkle ships as a prebuilt binaryTarget (Sparkle.xcframework), so Xcode never emits a + # dSYM for it into the archive — the archive-dSYM upload above therefore never covers + # Sparkle. Upload the dSYMs bundled inside the xcframework directly so each new Sparkle + # version is symbolicated automatically, instead of relying on a one-off manual bulk upload. + SPARKLE_XCFRAMEWORK="$HOME/Library/Caches/supacode-spm-cache/SourcePackages/artifacts/sparkle/Sparkle/Sparkle.xcframework" + if [[ -d "$SPARKLE_XCFRAMEWORK" ]]; then + log "uploading Sparkle xcframework dSYMs to Sentry..." + sentry-cli debug-files upload --wait "$SPARKLE_XCFRAMEWORK" \ + || log "WARNING: Sparkle dSYM upload failed (release will continue)" + else + log "WARNING: $SPARKLE_XCFRAMEWORK not found, skipping Sparkle dSYM upload" + fi + log "associating commits with Sentry release..." sentry-cli releases set-commits "$SENTRY_RELEASE_NAME" --auto \ || log "WARNING: failed to associate commits (continuing)" diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 815775ff..e4f1e5d7 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -670,7 +670,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = exactVersion; - version = "2.9.0-beta.2"; + version = "2.9.2"; }; }; 911B4AAD84AF4B34BE232133 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { diff --git a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fe38be89..89359eb8 100644 --- a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "afea2cda87819c960114f26e26f369a1a0945b17", - "version" : "2.9.0-beta.2" + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" } }, { diff --git a/supacode/Clients/Updates/UpdaterClient.swift b/supacode/Clients/Updates/UpdaterClient.swift index e0e9e532..ef8cf140 100644 --- a/supacode/Clients/Updates/UpdaterClient.swift +++ b/supacode/Clients/Updates/UpdaterClient.swift @@ -43,135 +43,105 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { self.continuation = continuation } - nonisolated func show( + // `SPUUserDriver` is declared `NS_SWIFT_UI_ACTOR` (main-actor isolated) as of Sparkle 2.9, + // so these callbacks are guaranteed to arrive on the main thread. Implement them as plain + // `@MainActor` methods and let the compiler enforce isolation, rather than reaching for + // `MainActor.assumeIsolated`, which would crash on any future off-main delivery. + func show( _ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void ) { - MainActor.assumeIsolated { - standard.show(request, reply: reply) - } + standard.show(request, reply: reply) } - nonisolated func showUserInitiatedUpdateCheck(cancellation: @escaping @Sendable () -> Void) { - MainActor.assumeIsolated { - standard.showUserInitiatedUpdateCheck(cancellation: cancellation) - } + func showUserInitiatedUpdateCheck(cancellation: @escaping @Sendable () -> Void) { + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) } - nonisolated func showUpdateFound( + func showUpdateFound( with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void ) { - MainActor.assumeIsolated { - if state.userInitiated { - standard.showUpdateFound(with: appcastItem, state: state, reply: reply) - return - } - // Background check: surface the availability silently, then defer so Sparkle - // will re-offer the same update on the next (user-initiated) check. - continuation?.yield(.silentUpdateFound(version: appcastItem.displayVersionString)) - reply(.dismiss) + if state.userInitiated { + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) + return } + // Background check: surface the availability silently, then defer so Sparkle + // will re-offer the same update on the next (user-initiated) check. + continuation?.yield(.silentUpdateFound(version: appcastItem.displayVersionString)) + reply(.dismiss) } - nonisolated func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { - MainActor.assumeIsolated { - standard.showUpdateReleaseNotes(with: downloadData) - } + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + standard.showUpdateReleaseNotes(with: downloadData) } - nonisolated func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { - MainActor.assumeIsolated { - standard.showUpdateReleaseNotesFailedToDownloadWithError(error) - } + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + standard.showUpdateReleaseNotesFailedToDownloadWithError(error) } - nonisolated func showUpdateNotFoundWithError( + func showUpdateNotFoundWithError( _ error: any Error, acknowledgement: @escaping @Sendable () -> Void ) { - MainActor.assumeIsolated { - standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) - } + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) } - nonisolated func showUpdaterError( + func showUpdaterError( _ error: any Error, acknowledgement: @escaping @Sendable () -> Void ) { - MainActor.assumeIsolated { - standard.showUpdaterError(error, acknowledgement: acknowledgement) - } + standard.showUpdaterError(error, acknowledgement: acknowledgement) } - nonisolated func showDownloadInitiated(cancellation: @escaping @Sendable () -> Void) { - MainActor.assumeIsolated { - standard.showDownloadInitiated(cancellation: cancellation) - } + func showDownloadInitiated(cancellation: @escaping @Sendable () -> Void) { + standard.showDownloadInitiated(cancellation: cancellation) } - nonisolated func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { - MainActor.assumeIsolated { - standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) - } + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) } - nonisolated func showDownloadDidReceiveData(ofLength length: UInt64) { - MainActor.assumeIsolated { - standard.showDownloadDidReceiveData(ofLength: length) - } + func showDownloadDidReceiveData(ofLength length: UInt64) { + standard.showDownloadDidReceiveData(ofLength: length) } - nonisolated func showDownloadDidStartExtractingUpdate() { - MainActor.assumeIsolated { - standard.showDownloadDidStartExtractingUpdate() - } + func showDownloadDidStartExtractingUpdate() { + standard.showDownloadDidStartExtractingUpdate() } - nonisolated func showExtractionReceivedProgress(_ progress: Double) { - MainActor.assumeIsolated { - standard.showExtractionReceivedProgress(progress) - } + func showExtractionReceivedProgress(_ progress: Double) { + standard.showExtractionReceivedProgress(progress) } - nonisolated func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { - MainActor.assumeIsolated { - standard.showReady(toInstallAndRelaunch: reply) - } + func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + standard.showReady(toInstallAndRelaunch: reply) } - nonisolated func showInstallingUpdate( + func showInstallingUpdate( withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping @Sendable () -> Void ) { - MainActor.assumeIsolated { - standard.showInstallingUpdate( - withApplicationTerminated: applicationTerminated, - retryTerminatingApplication: retryTerminatingApplication - ) - } + standard.showInstallingUpdate( + withApplicationTerminated: applicationTerminated, + retryTerminatingApplication: retryTerminatingApplication + ) } - nonisolated func showUpdateInstalledAndRelaunched( + func showUpdateInstalledAndRelaunched( _ relaunched: Bool, acknowledgement: @escaping @Sendable () -> Void ) { - MainActor.assumeIsolated { - standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) - } + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) } - nonisolated func showUpdateInFocus() { - MainActor.assumeIsolated { - standard.showUpdateInFocus() - } + func showUpdateInFocus() { + standard.showUpdateInFocus() } - nonisolated func dismissUpdateInstallation() { - MainActor.assumeIsolated { - standard.dismissUpdateInstallation() - } + func dismissUpdateInstallation() { + standard.dismissUpdateInstallation() } } -- 2.51.2 From 7a2af90aa550a0e1e66fffa5cd9727e53d2ea2af Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 23:10:43 +0900 Subject: [PATCH 207/528] Decode Ghostty text using explicit lengths --- .../Ghostty/GhosttySurfaceView.swift | 20 ++++++++++++++----- supacodeTests/GhosttySurfaceViewTests.swift | 11 ++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 47c53b91..9032565f 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -216,6 +216,16 @@ final class GhosttySurfaceView: NSView, Identifiable { return String(content[swiftRange]) } + static func stringFromGhosttyText(pointer: UnsafePointer?, length: UInt) -> String { + guard let pointer, length > 0, length <= UInt(Int.max) else { return "" } + let buffer = UnsafeRawBufferPointer(start: pointer, count: Int(length)) + return String(bytes: buffer, encoding: .utf8) ?? "" + } + + private static func string(from text: ghostty_text_s) -> String { + stringFromGhosttyText(pointer: text.text, length: text.text_len) + } + override var acceptsFirstResponder: Bool { true } init( @@ -577,7 +587,7 @@ final class GhosttySurfaceView: NSView, Identifiable { var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } - let value = String(cString: text.text) + let value = Self.string(from: text) return value.isEmpty ? nil : value } @@ -662,7 +672,7 @@ final class GhosttySurfaceView: NSView, Identifiable { ) guard ghostty_surface_read_text(surface, selection, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } - return String(cString: text.text) + return Self.string(from: text) } private func readScreenContents() -> String { @@ -908,7 +918,7 @@ final class GhosttySurfaceView: NSView, Identifiable { font.release() } - let str = NSAttributedString(string: String(cString: text.text), attributes: attributes) + let str = NSAttributedString(string: Self.string(from: text), attributes: attributes) let point = NSPoint(x: text.tl_px_x, y: frame.size.height - text.tl_px_y) showDefinition(for: str, at: point) } @@ -1875,7 +1885,7 @@ extension GhosttySurfaceView: NSTextInputClient { attributes[.font] = font.takeUnretainedValue() font.release() } - return NSAttributedString(string: String(cString: text.text), attributes: attributes) + return NSAttributedString(string: Self.string(from: text), attributes: attributes) } func characterIndex(for point: NSPoint) -> Int { @@ -2373,7 +2383,7 @@ extension GhosttySurfaceView: NSServicesMenuRequestor { guard ghostty_surface_read_selection(surface, &text) else { return false } defer { ghostty_surface_free_text(surface, &text) } pboard.declareTypes([.string], owner: nil) - pboard.setString(String(cString: text.text), forType: .string) + pboard.setString(Self.string(from: text), forType: .string) return true } diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index d0ece057..50469f1f 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -431,6 +431,17 @@ struct GhosttySurfaceViewTests { ) } + @Test func stringFromGhosttyTextUsesExplicitLength() { + let bytes: [UInt8] = Array("alpha".utf8) + [0] + Array("omega".utf8) + + let decoded = bytes.withUnsafeBufferPointer { buffer in + let pointer = UnsafeRawPointer(buffer.baseAddress!).assumingMemoryBound(to: CChar.self) + return GhosttySurfaceView.stringFromGhosttyText(pointer: pointer, length: UInt(bytes.count)) + } + + #expect(Array(decoded.utf8) == bytes) + } + @Test func keyboardLayoutChangeKeyUpSuppressionSuppressesMatchingKeyUp() { let suppression = GhosttySurfaceView.KeyboardLayoutChangeKeyUpSuppression( keyCode: 49, -- 2.51.2 From ef0b24c59a47f1e1a971d133625de9b3e6a5b396 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 23:38:56 +0900 Subject: [PATCH 208/528] Remove redundant try in GithubCLIClientTests resolveRemoteInfo is non-throwing, so the try expression produced a no-effect warning. Drop try and keep await. --- supacodeTests/GithubCLIClientTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index 083ca5b0..98c004b9 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -90,7 +90,7 @@ struct GithubCLIClientTests { ) let client = GithubCLIClient.live(shell: shell) - let remoteInfo = try await client.resolveRemoteInfo(repoRoot) + let remoteInfo = await client.resolveRemoteInfo(repoRoot) #expect(remoteInfo == GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode")) let calls = await probe.snapshot() -- 2.51.2 From 2899652d3642f11b0939d44e7053e65604b415dc Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 23:47:43 +0900 Subject: [PATCH 209/528] bump v2026.5.25 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index e4f1e5d7..215e274d 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260524; + CURRENT_PROJECT_VERSION = 20260525; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.24; + MARKETING_VERSION = 2026.5.25; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260524; + CURRENT_PROJECT_VERSION = 20260525; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.24; + MARKETING_VERSION = 2026.5.25; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260524; + CURRENT_PROJECT_VERSION = 20260525; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.24; + MARKETING_VERSION = 2026.5.25; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260524; + CURRENT_PROJECT_VERSION = 20260525; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.24; + MARKETING_VERSION = 2026.5.25; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index a1652a02..c911d7cd 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.24" + public static let current = "2026.5.25" } -- 2.51.2 From fe9b0c67138bef39ea90493686ebf24120a2c881 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 25 May 2026 23:47:44 +0900 Subject: [PATCH 210/528] Update CHANGELOG for 2026.5.25 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1066e9c..9d2251b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [2026.5.25](https://github.com/onevcat/Prowl/releases/tag/v2026.5.25) + +This release focuses on reliability improvements and a handful of UX polish items. + +### New + +- **Canvas is now available as a default view.** Go to Settings → Appearance → Default View and choose "Canvas View" to have Prowl open directly into Canvas on launch. If no worktrees are present at launch, it falls back to Normal view. +- Prowl now asks for confirmation before closing a pane, tab, or group of tabs that contain active or recently-completed-but-unseen terminal work, preventing accidental data loss. + +### Fixed + +- The toolbar no longer loses its tint color when entering or exiting macOS fullscreen. Prowl's chosen Light or Dark appearance is now used correctly throughout the fullscreen transition. +- Clicking an entry in the Active Agents list for a plain folder now navigates correctly instead of silently doing nothing. +- Worktrees that resolve to the same path are no longer listed twice after a repository refresh. +- Terminal text reads (selection, clipboard, accessibility, Quick Look, Services) now correctly handle content that contains embedded NUL bytes, preventing silent truncation. + +### Improved + +- Updated Sparkle to 2.9.2, addressing a potential crash in the auto-update framework that was observed in the previous beta build. + ## [2026.5.24](https://github.com/onevcat/Prowl/releases/tag/v2026.5.24) This release redesigns the terminal tab bar and sidebar to sit more naturally within macOS, introduces a window tint that colors the chrome to match your repository, and adds keyboard navigation for the Active Agents panel. -- 2.51.2 From 87015a296583e8f51dfe96013cd36c31de4bc58f Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 19:20:48 +0900 Subject: [PATCH 211/528] Respect explicit dual themes and adapt the no-theme default `ghostty +show-config` collapses an explicit same-name light/dark pair (`theme = light:X,dark:X`) back into a single `theme = X`, so the runtime single-theme fallback was overriding the user's explicit per-mode choice. Re-derive the theme mode from the raw user config (which preserves the `light:`/`dark:` intent) while keeping the resolved background tone from the CLI. Also fold the no-theme case into the same fallback via `GhosttyThemeMode.allowsMismatchFallback`: Ghostty's no-theme default is a fixed dark scheme (#282C34) that ignores the app appearance, so a light app would otherwise stay stuck on a dark terminal. --- .../Ghostty/GhosttyRuntime.swift | 92 ++++++++++++++++++- .../GhosttyUserConfigSnapshotTests.swift | 35 +++++++ 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index 6e0cc908..da7981b4 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -609,7 +609,7 @@ final class GhosttyRuntime { Task { [weak self] in let snapshot = await Self.probeUserConfigSnapshot() let pair: GhosttyThemePair? = - snapshot?.themeMode == .single ? await Self.probeFallbackThemePair() : nil + snapshot?.themeMode.allowsMismatchFallback == true ? await Self.probeFallbackThemePair() : nil self?.applyResolvedThemeFallback(for: scheme, snapshot: snapshot, pair: pair) } } @@ -621,7 +621,12 @@ final class GhosttyRuntime { pair: GhosttyThemePair? ) { guard currentColorScheme == scheme else { return } - guard let snapshot, snapshot.themeMode == .single else { + // `.none` (no user theme) is treated like a single dark theme here: Ghostty's + // no-theme default is the fixed dark `#282C34` reported by `+show-config`, so + // its `backgroundTone` resolves to `.dark` and adapts to a light app the same + // way an explicit single dark theme does. `.dual` is the user's explicit + // per-mode choice and is always respected. + guard let snapshot, snapshot.themeMode.allowsMismatchFallback else { setThemeFallbackOverride("") return } @@ -724,7 +729,54 @@ final class GhosttyRuntime { nonisolated private static func userConfigSnapshotFromCLI() -> GhosttyUserConfigSnapshot? { guard let output = runGhosttyCommand(arguments: ["+show-config"]) else { return nil } - return GhosttyUserConfigSnapshot.parse(showConfigOutput: output) + let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: output) + + // `ghostty +show-config` collapses an explicit same-name light/dark pair + // (`theme = light:X,dark:X`) back into a single `theme = X`. Trusting that + // alone would make us apply the single-theme fallback over the user's + // explicit light/dark choice, so re-derive the theme mode from the raw + // config text when we can read it. The background tone still comes from the + // resolved CLI output. + guard let rawMode = rawUserThemeMode() else { return snapshot } + return GhosttyUserConfigSnapshot(themeMode: rawMode, backgroundTone: snapshot.backgroundTone) + } + + nonisolated private static func rawUserThemeMode() -> GhosttyThemeMode? { + guard let url = preferredGhosttyConfigURL(), + let contents = try? String(contentsOf: url, encoding: .utf8), + let spec = GhosttyUserConfigSnapshot.rawThemeSpec(fromConfig: contents) + else { return nil } + return GhosttyUserConfigSnapshot.parseThemeMode(from: spec) + } + + /// Mirrors Ghostty's macOS default-config selection: prefer the Application + /// Support file when present, otherwise fall back to the XDG config. Within + /// each location the modern `config.ghostty` wins over the legacy `config`. + /// `theme` set through `config-file` includes isn't resolved here; those rare + /// setups simply keep the previous `+show-config` behavior. + nonisolated private static func preferredGhosttyConfigURL() -> URL? { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + + let appSupport = home.appending( + path: "Library/Application Support/com.mitchellh.ghostty", + directoryHint: .isDirectory + ) + let xdgRoot: URL + if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty { + xdgRoot = URL(fileURLWithPath: xdg, isDirectory: true) + } else { + xdgRoot = home.appending(path: ".config", directoryHint: .isDirectory) + } + let xdg = xdgRoot.appending(path: "ghostty", directoryHint: .isDirectory) + + let candidates = [ + appSupport.appending(path: "config.ghostty"), + appSupport.appending(path: "config"), + xdg.appending(path: "config.ghostty"), + xdg.appending(path: "config"), + ] + return candidates.first { fileManager.fileExists(atPath: $0.path) } } nonisolated private static func runGhosttyCommand(arguments: [String]) -> String? { @@ -1100,6 +1152,21 @@ nonisolated enum GhosttyThemeMode: Equatable, Sendable { case none case single case dual + + /// Whether a tone mismatch with the app appearance should fall back to a + /// default light/dark pair. A `.dual` theme is the user's explicit per-mode + /// choice and is always respected; `.single` and `.none` are adapted so a + /// light app never shows a dark terminal — `.none` because Ghostty's + /// no-theme default is a fixed dark scheme that otherwise ignores the + /// app appearance. + var allowsMismatchFallback: Bool { + switch self { + case .single, .none: + return true + case .dual: + return false + } + } } nonisolated enum GhosttyTerminalTone: Equatable, Sendable { @@ -1136,7 +1203,24 @@ nonisolated struct GhosttyUserConfigSnapshot: Equatable, Sendable { return .init(themeMode: themeMode, backgroundTone: backgroundTone) } - private static func parseThemeMode(from spec: String?) -> GhosttyThemeMode { + /// The raw `theme` value as written in a user's Ghostty config file, or `nil` + /// when none is set. Unlike `ghostty +show-config`, this preserves an explicit + /// same-name light/dark pair (`theme = light:X,dark:X`) instead of collapsing + /// it back to a single `theme = X`. Later lines win, matching Ghostty. + static func rawThemeSpec(fromConfig contents: String) -> String? { + var lastSpec: String? + for rawLine in contents.split(whereSeparator: \.isNewline) { + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.hasPrefix("#"), let separator = line.firstIndex(of: "=") else { continue } + let key = line[.. GhosttyThemeMode { guard let spec, !spec.isEmpty else { return .none } var hasLight = false diff --git a/supacodeTests/GhosttyUserConfigSnapshotTests.swift b/supacodeTests/GhosttyUserConfigSnapshotTests.swift index cc976709..00489d9e 100644 --- a/supacodeTests/GhosttyUserConfigSnapshotTests.swift +++ b/supacodeTests/GhosttyUserConfigSnapshotTests.swift @@ -32,6 +32,41 @@ struct GhosttyUserConfigSnapshotTests { #expect(snapshot.themeMode == .none) } + @Test func detectsSameNameDualThemeFromRawConfig() { + // Ghostty's `+show-config` collapses `light:X,dark:X` to `theme = X`, but the + // raw config keeps the explicit pair, which must be honored as dual. + let spec = GhosttyUserConfigSnapshot.rawThemeSpec( + fromConfig: """ + # my ghostty config + theme = light:Everforest Dark Hard,dark:Everforest Dark Hard + background-opacity = 0.95 + """) + + #expect(spec == "light:Everforest Dark Hard,dark:Everforest Dark Hard") + #expect(GhosttyUserConfigSnapshot.parseThemeMode(from: spec) == .dual) + } + + @Test func fallbackEligibilityByThemeMode() { + // `.single` and `.none` adapt to the app appearance; `.dual` is the user's + // explicit per-mode choice and must be left untouched. + #expect(GhosttyThemeMode.single.allowsMismatchFallback) + #expect(GhosttyThemeMode.none.allowsMismatchFallback) + #expect(!GhosttyThemeMode.dual.allowsMismatchFallback) + } + + @Test func rawThemeSpecIgnoresCommentsAndTakesLastWins() { + #expect( + GhosttyUserConfigSnapshot.rawThemeSpec( + fromConfig: """ + # theme = should-be-ignored + theme = kanagawabones + font-size = 14 + theme = Everforest Dark Hard + """) == "Everforest Dark Hard") + + #expect(GhosttyUserConfigSnapshot.rawThemeSpec(fromConfig: "font-size = 14\n") == nil) + } + @Test func classifiesBackgroundToneLightDarkUnknown() { let dark = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #1a1a1a") #expect(dark.backgroundTone == .dark) -- 2.51.2 From 47f226d7967699b56f61522d4e9887792cf0afaa Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 22:07:08 +0900 Subject: [PATCH 212/528] Fix main window surfacing after relaunch --- supacode/App/MainWindowOpener.swift | 75 ++++++++ supacode/App/WindowSurfacing.swift | 200 ++++++++++++++++++++++ supacode/App/supacodeApp.swift | 20 ++- supacode/Commands/WindowCommands.swift | 3 + supacodeTests/MainWindowOpenerTests.swift | 32 ++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 supacode/App/MainWindowOpener.swift create mode 100644 supacodeTests/MainWindowOpenerTests.swift diff --git a/supacode/App/MainWindowOpener.swift b/supacode/App/MainWindowOpener.swift new file mode 100644 index 00000000..935f9030 --- /dev/null +++ b/supacode/App/MainWindowOpener.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Bridges AppKit-side "bring the main window back" requests to SwiftUI's +/// `openWindow` action. +/// +/// The main window is a singleton `Window(_:id:)` scene. SwiftUI tears its +/// `NSWindow` down when the window closes, and on a macOS restart relaunch the +/// scene is often not recreated at all (the app launches with zero windows). A +/// bare `NSApp.activate(ignoringOtherApps:)` cannot bring back a scene SwiftUI +/// has torn down; only `openWindow(id:)` rebuilds it. Until #297 this only +/// happened implicitly when `applicationShouldHandleReopen` returned `true` +/// (Dock icon click); activation paths that do not trigger reopen, such as +/// Cmd-Tab, Mission Control, CLI `open`, and menu commands, left the app stuck +/// windowless. +/// +/// SwiftUI registers the opener from both the app command tree and the main +/// window content. The command registration is important for loginwindow +/// relaunches that start the process with zero windows: the main window content +/// never appears, so an `onAppear`-only registration cannot recreate it. +/// `NSApplication.surfaceMainWindow()` then calls `openMainWindow()` from any +/// path that finds no existing main window. +@MainActor +final class MainWindowOpener { + static let shared = MainWindowOpener() + + private var opener: (() -> Void)? + var hasRegisteredOpener: Bool { + opener != nil + } + + init() {} + + func register(_ opener: @escaping () -> Void) { + self.opener = opener + } + + /// Requests a new main window. Returns `false` when no opener has been + /// registered yet (e.g. the main window has never appeared this launch), so + /// callers can fall back to AppKit's default reopen handling. + @discardableResult + func openMainWindow() -> Bool { + guard let opener else { return false } + opener() + return true + } +} + +/// Registers SwiftUI's `openWindow` action with `MainWindowOpener.shared` so +/// AppKit paths can recreate the main window. Attach to the main window's +/// content; registration refreshes on every appearance. +private struct MainWindowOpenerRegistrar: ViewModifier { + @Environment(\.openWindow) private var openWindow + + func body(content: Content) -> some View { + content.onAppear { + MainWindowOpener.shared.register(openWindow: openWindow) + } + } +} + +extension View { + func registersMainWindowOpener() -> some View { + modifier(MainWindowOpenerRegistrar()) + } +} + +extension MainWindowOpener { + @discardableResult + func register(openWindow: OpenWindowAction) -> Bool { + register { + openWindow(id: WindowID.main) + } + return hasRegisteredOpener + } +} diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index efb65645..4092b8ba 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -1,4 +1,6 @@ import AppKit +import Foundation +import Sentry enum WindowID { static let main = "main" @@ -10,9 +12,24 @@ extension NSApplication { @discardableResult func surfaceMainWindow() -> Bool { guard let window = mainWindowCandidate() else { + // SwiftUI tore down (or never created) the singleton main Window scene. + // A bare activate() cannot bring it back; only openWindow(id:) rebuilds + // it. See MainWindowOpener / issue #297. + if MainWindowOpener.shared.openMainWindow() { + WindowLifecycleDiagnostics.log("surfaceMainWindow: no candidate -> openWindow(id:.main) requested") + WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(openWindowRequested)") + activate(ignoringOtherApps: true) + return true + } + WindowLifecycleDiagnostics.log("surfaceMainWindow: no candidate and opener unavailable -> activate only") + WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(noOpener)") activate(ignoringOtherApps: true) return false } + WindowLifecycleDiagnostics.log( + "surfaceMainWindow: candidate id=\(window.identifier?.rawValue ?? "nil") " + + "miniaturized=\(window.isMiniaturized) visible=\(window.isVisible) -> makeKeyAndOrderFront" + ) if window.isMiniaturized { window.deminiaturize(nil) } @@ -32,3 +49,186 @@ extension NSApplication { return candidates.first } } + +// MARK: - Issue #297 diagnostics + +@MainActor +enum WindowLifecycleDiagnostics { + private static let logger = SupaLogger("WindowLifecycle") + private static let heartbeatInterval: TimeInterval = 1.0 + private static let stallThreshold: TimeInterval = 0.3 + private static let windowlessReminderInterval: TimeInterval = 5.0 + private static let windowlessSentryThreshold: TimeInterval = 10.0 + private static let windowlessStallSentryThreshold: TimeInterval = 5.0 + + private static var windowlessSince: Date? + private static var windowlessContext: String? + private static var windowlessReminderScheduled = false + private static var didReportWindowlessTimeout = false + private static var didReportWindowlessStall = false + private static var maxHeartbeatLagDuringWindowless: TimeInterval = 0 + private static var heartbeatRunning = false + private static var mainThreadStalling = false + + static func log(_ event: String) { + logger.info(event) + } + + static func logWithWindows(_ event: String) { + log("\(event) | \(windowsSummary())") + } + + static func noteWindowless(_ context: String) { + if windowlessSince == nil { + windowlessSince = .now + windowlessContext = context + didReportWindowlessTimeout = false + didReportWindowlessStall = false + maxHeartbeatLagDuringWindowless = 0 + log("windowless ENTERED (\(context))") + } else { + windowlessContext = context + } + scheduleWindowlessReminder() + } + + static func noteWindowlessIfNoMainWindow(_ context: String) { + let hasMain = NSApplication.shared.windows.contains { $0.identifier?.rawValue == WindowID.main } + guard !hasMain else { return } + noteWindowless(context) + } + + static func noteMainWindowAppeared() { + guard let since = windowlessSince else { return } + let seconds = Date.now.timeIntervalSince(since) + log(String(format: "windowless RESOLVED: main window appeared after %.3fs", seconds)) + windowlessSince = nil + windowlessContext = nil + } + + private static func scheduleWindowlessReminder() { + guard !windowlessReminderScheduled else { return } + windowlessReminderScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + windowlessReminderInterval) { + MainActor.assumeIsolated { + windowlessReminderScheduled = false + guard let since = windowlessSince else { return } + let seconds = Date.now.timeIntervalSince(since) + log( + String( + format: "still windowless after %.1fs (context=%@, maxLag=%.3fs)", + seconds, + windowlessContext ?? "unknown", + maxHeartbeatLagDuringWindowless + ) + ) + if seconds >= windowlessSentryThreshold { + reportWindowlessTimeoutIfNeeded(elapsed: seconds) + } + scheduleWindowlessReminder() + } + } + } + + static func startMainThreadHeartbeat() { + guard !heartbeatRunning else { return } + heartbeatRunning = true + log("main-thread heartbeat started (interval=\(heartbeatInterval)s, stallThreshold=\(stallThreshold)s)") + scheduleHeartbeatTick() + } + + private static func scheduleHeartbeatTick() { + let scheduledAt = Date.now + DispatchQueue.main.asyncAfter(deadline: .now() + heartbeatInterval) { + MainActor.assumeIsolated { + let lag = Date.now.timeIntervalSince(scheduledAt) - heartbeatInterval + if lag >= stallThreshold { + mainThreadStalling = true + if windowlessSince != nil { + maxHeartbeatLagDuringWindowless = max(maxHeartbeatLagDuringWindowless, lag) + if lag >= windowlessStallSentryThreshold { + reportWindowlessStallIfNeeded(lag: lag) + } + } + log(String(format: "HEARTBEAT STALL: main thread blocked ~%.3fs", lag)) + } else if mainThreadStalling { + mainThreadStalling = false + log(String(format: "heartbeat recovered (lag back to %.3fs)", lag)) + } + scheduleHeartbeatTick() + } + } + } + + static func applyLaunchStallIfConfigured() { + let seconds = UserDefaults.standard.double(forKey: "ProwlDebugLaunchStallSeconds") + guard seconds > 0 else { return } + log("DEBUG launch stall: blocking main thread for \(seconds)s (ProwlDebugLaunchStallSeconds)") + Thread.sleep(forTimeInterval: seconds) + log("DEBUG launch stall: resumed") + } + + private static func reportWindowlessTimeoutIfNeeded(elapsed: TimeInterval) { + guard !didReportWindowlessTimeout else { return } + guard windowlessContext != "launch" || NSApp.isActive else { return } + didReportWindowlessTimeout = true + captureSentryEvent(kind: "main_window_timeout", elapsed: elapsed, lag: nil) + } + + private static func reportWindowlessStallIfNeeded(lag: TimeInterval) { + guard !didReportWindowlessStall else { return } + didReportWindowlessStall = true + let elapsed = windowlessSince.map { Date.now.timeIntervalSince($0) } ?? 0 + captureSentryEvent(kind: "windowless_main_thread_stall", elapsed: elapsed, lag: lag) + } + + private static func captureSentryEvent(kind: String, elapsed: TimeInterval, lag: TimeInterval?) { + let context = windowlessContext ?? "unknown" + log( + String( + format: "Sentry window lifecycle report kind=%@ elapsed=%.3fs context=%@", + kind, + elapsed, + context + ) + ) + + #if !DEBUG + let event = Event(level: .warning) + event.message = SentryMessage(formatted: "Prowl main window surfacing \(kind)") + event.logger = "WindowLifecycle" + event.fingerprint = ["prowl", "main-window-surfacing", kind] + event.tags = [ + "window_lifecycle_kind": kind, + "app_active": NSApp.isActive ? "true" : "false", + "main_window_opener_registered": MainWindowOpener.shared.hasRegisteredOpener ? "true" : "false", + ] + var extra: [String: Any] = [ + "elapsed_seconds": elapsed, + "windowless_context": context, + "windows": windowsSummary(), + "max_heartbeat_lag_seconds": maxHeartbeatLagDuringWindowless, + "arguments": ProcessInfo.processInfo.arguments.joined(separator: " "), + ] + if let lag { + extra["heartbeat_lag_seconds"] = lag + } + event.extra = extra + SentrySDK.capture(event: event) + #endif + } + + private static func windowsSummary() -> String { + let windows = NSApplication.shared.windows + guard !windows.isEmpty else { return "windows[0]=(none)" } + let parts = windows.map { window -> String in + let id = window.identifier?.rawValue ?? "nil" + let type = String(describing: Swift.type(of: window)) + let visibility = window.isVisible ? "visible" : "hidden" + let miniaturized = window.isMiniaturized ? ",mini" : "" + let key = window.isKeyWindow ? ",key" : "" + return "{id=\(id),\(type),\(visibility)\(miniaturized)\(key)}" + } + return "windows[\(windows.count)]=\(parts.joined(separator: " "))" + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 1b99106a..9b4f1189 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -41,6 +41,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { var cliSocketServer: CLISocketServer? func applicationDidFinishLaunching(_ notification: Notification) { + WindowLifecycleDiagnostics.startMainThreadHeartbeat() + WindowLifecycleDiagnostics.logWithWindows("applicationDidFinishLaunching") + WindowLifecycleDiagnostics.noteWindowlessIfNoMainWindow("launch") + WindowLifecycleDiagnostics.applyLaunchStallIfConfigured() // Disable press-and-hold accent menu so that key repeat works in the terminal. UserDefaults.standard.register(defaults: [ "ApplePressAndHoldEnabled": false @@ -53,16 +57,24 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { let hasVisibleMainWindow = app.windows.contains { window in window.isVisible && !(window is NSPanel) } + WindowLifecycleDiagnostics.logWithWindows( + "applicationDidBecomeActive hasVisibleMainWindow=\(hasVisibleMainWindow)" + ) guard !hasVisibleMainWindow else { return } + WindowLifecycleDiagnostics.log("applicationDidBecomeActive -> surfaceMainWindow()") app.surfaceMainWindow() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + WindowLifecycleDiagnostics.logWithWindows("applicationShouldHandleReopen hasVisibleWindows=\(flag)") if flag { return true } - return !sender.surfaceMainWindow() + let surfaced = sender.surfaceMainWindow() + WindowLifecycleDiagnostics.log("applicationShouldHandleReopen surfaced=\(surfaced) -> handled=\(!surfaced)") + return !surfaced } func applicationWillTerminate(_ notification: Notification) { + WindowLifecycleDiagnostics.logWithWindows("applicationWillTerminate") defer { cliSocketServer?.stop() } guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } guard appStore?.state.suppressLayoutSaveUntilRelaunch != true else { return } @@ -619,9 +631,15 @@ struct SupacodeApp: App { .environment(commandKeyObserver) .environment(\.resolvedKeybindings, store.resolvedKeybindings) } + .registersMainWindowOpener() .onAppear { + WindowLifecycleDiagnostics.logWithWindows("mainWindow content onAppear") + WindowLifecycleDiagnostics.noteMainWindowAppeared() syncGhosttyManagedShortcuts(with: store.resolvedKeybindings) } + .onDisappear { + WindowLifecycleDiagnostics.logWithWindows("mainWindow content onDisappear") + } .onChange(of: store.resolvedKeybindings) { _, newValue in syncGhosttyManagedShortcuts(with: newValue) } diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index 8cc73dd5..f16f94d7 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -8,6 +8,7 @@ struct WindowCommands: Commands { let resolvedKeybindings: ResolvedKeybindingMap @Bindable var settingsWindowManager: SettingsWindowManager @Dependency(SettingsWindowClient.self) private var settingsWindowClient + @Environment(\.openWindow) private var openWindow @FocusedValue(\.closeTabAction) private var closeTabAction @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction @@ -20,6 +21,7 @@ struct WindowCommands: Commands { @FocusedValue(\.selectTerminalPaneRightAction) private var selectTerminalPaneRightAction var body: some Commands { + let mainWindowOpenerRegistered = MainWindowOpener.shared.register(openWindow: openWindow) let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") let closeTabHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_tab") let shelfHasOpenBooks = @@ -136,6 +138,7 @@ struct WindowCommands: Commands { Divider() Button(mainWindowTitle) { + guard mainWindowOpenerRegistered else { return } NSApp.surfaceMainWindow() } .help("Show main window") diff --git a/supacodeTests/MainWindowOpenerTests.swift b/supacodeTests/MainWindowOpenerTests.swift new file mode 100644 index 00000000..531a838d --- /dev/null +++ b/supacodeTests/MainWindowOpenerTests.swift @@ -0,0 +1,32 @@ +import Testing + +@testable import supacode + +@MainActor +struct MainWindowOpenerTests { + @Test func returnsFalseWhenNoOpenerRegistered() { + let opener = MainWindowOpener() + #expect(opener.hasRegisteredOpener == false) + #expect(opener.openMainWindow() == false) + } + + @Test func invokesRegisteredOpenerAndReturnsTrue() { + let opener = MainWindowOpener() + var callCount = 0 + opener.register { callCount += 1 } + #expect(opener.hasRegisteredOpener == true) + #expect(opener.openMainWindow() == true) + #expect(callCount == 1) + } + + @Test func reregisteringReplacesPreviousOpener() { + let opener = MainWindowOpener() + var firstCount = 0 + var secondCount = 0 + opener.register { firstCount += 1 } + opener.register { secondCount += 1 } + _ = opener.openMainWindow() + #expect(firstCount == 0) + #expect(secondCount == 1) + } +} -- 2.51.2 From 903070ae751d5126a288354d1ad7a4f9268cfc6f Mon Sep 17 00:00:00 2001 From: onevclaw Date: Tue, 26 May 2026 22:14:35 +0900 Subject: [PATCH 213/528] Recognize OMX as Codex agent --- .../AgentDetection/AgentClassifier.swift | 2 +- supacodeTests/AgentClassifierTests.swift | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 289c1295..9e8b0a67 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -6,7 +6,7 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .pi case "claude", "claude-code": return .claude - case "codex": + case "codex", "omx", "oh-my-codex": return .codex case "gemini": return .gemini diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index ab8c2c34..912a91e2 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -8,6 +8,8 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "claude") == .claude) #expect(identifyAgent(processName: "claude-code") == .claude) #expect(identifyAgent(processName: "codex") == .codex) + #expect(identifyAgent(processName: "omx") == .codex) + #expect(identifyAgent(processName: "oh-my-codex") == .codex) #expect(identifyAgent(processName: "gemini") == .gemini) #expect(identifyAgent(processName: "cursor") == .cursor) #expect(identifyAgent(processName: "cursor-agent") == .cursor) @@ -104,6 +106,24 @@ struct AgentClassifierTests { #expect(result.name == "codex") } + @Test func identifiesOmxAsCodexWrapper() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "node", + argv0: "node", + cmdline: "node /opt/homebrew/bin/omx --madmax --high" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .codex) + #expect(result.name == "omx") + } + @Test func prefersDirectAgentProcessOverWrapper() throws { let job = ForegroundJob( processGroupID: 42, -- 2.51.2 From c15e98b6754c88aaed7ea28a05e0a92d45884f23 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 23:05:37 +0900 Subject: [PATCH 214/528] Fix active agent detection while scrolled --- .../Features/Terminal/Models/WorktreeTerminalState.swift | 6 +++--- supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift | 4 ++++ supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift | 7 +++++++ supacodeTests/GhosttySurfaceBridgeTests.swift | 6 ++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 95be8d1d..f030d193 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1744,14 +1744,14 @@ final class WorktreeTerminalState { let now = Date() let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) - let viewportText = view.bridge.readViewportText() ?? "" + let activeText = view.bridge.readActiveText() ?? "" // `detectState` is a `nonisolated` pure function that runs in well under a - // millisecond on a 24-line viewport, so the prior `Task.detached` hop + // millisecond on a terminal-sized active screen, so the prior `Task.detached` hop // bought nothing but allocator churn. In long sessions, each detection // tick (300 ms or 2 s per surface) was leaving a task stack + closure // capture behind that never reached ARC; over a 24 h session this added // up to hundreds of MB of unreferenced allocations. - let raw = agent.detectState(in: viewportText) + let raw = agent.detectState(in: activeText) guard surfaces[surfaceID] != nil else { return } var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 0905855d..59c0c7e7 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -55,6 +55,10 @@ final class GhosttySurfaceBridge { surfaceView?.readViewportContentsForCLI() } + func readActiveText() -> String? { + surfaceView?.readActiveContentsForCLI() + } + func childPID() -> pid_t? { guard let surface else { return nil } let pid = ghostty_surface_pid(surface) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 9032565f..88e975b8 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -689,6 +689,13 @@ final class GhosttySurfaceView: NSView, Identifiable { ) } + func readActiveContentsForCLI() -> String? { + readText( + topLeftTag: GHOSTTY_POINT_ACTIVE, + bottomRightTag: GHOSTTY_POINT_ACTIVE + ) + } + func readScreenContentsForCLI() -> String? { readText( topLeftTag: GHOSTTY_POINT_SCREEN, diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index 0d8bec15..bb75132d 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -5,6 +5,12 @@ import Testing @MainActor struct GhosttySurfaceBridgeTests { + @Test func activeTextReturnsNilWithoutSurfaceView() { + let bridge = GhosttySurfaceBridge() + + #expect(bridge.readActiveText() == nil) + } + @Test func desktopNotificationEmitsCallback() { let bridge = GhosttySurfaceBridge() var received: (String, String)? -- 2.51.2 From 5e3e1affaa8f21ca1400b509dd0d45ff076005d5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 23:27:17 +0900 Subject: [PATCH 215/528] bump v2026.5.26 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 215e274d..8ff5e1d5 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260525; + CURRENT_PROJECT_VERSION = 20260526; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.25; + MARKETING_VERSION = 2026.5.26; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260525; + CURRENT_PROJECT_VERSION = 20260526; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.25; + MARKETING_VERSION = 2026.5.26; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260525; + CURRENT_PROJECT_VERSION = 20260526; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.25; + MARKETING_VERSION = 2026.5.26; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260525; + CURRENT_PROJECT_VERSION = 20260526; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.25; + MARKETING_VERSION = 2026.5.26; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index c911d7cd..0e4c77ff 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.25" + public static let current = "2026.5.26" } -- 2.51.2 From 75cb6b4180e1ed62715658b132a7a2003cc67680 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 23:27:20 +0900 Subject: [PATCH 216/528] Update CHANGELOG for 2026.5.26 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2251b3..0fe64544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2026.5.26](https://github.com/onevcat/Prowl/releases/tag/v2026.5.26) + +### Fixed + +- The active agent indicator now correctly reflects agent state even when you have scrolled up in the terminal, instead of reading only the visible viewport. +- Prowl's main window now reliably reopens when relaunching the app after all windows have been closed. +- Using an explicit `light:X,dark:X` theme (same name on both sides, as an opt-out of the automatic fallback) is now respected; previously Ghostty's config normalization caused it to be overridden. The no-theme default also now adapts to system appearance, so a Light-mode app no longer stays stuck on a dark terminal. +- `omx` and `oh-my-codex` process names are now recognized as the Codex agent type, so the active agent panel correctly identifies sessions running through the OMX wrapper. + ## [2026.5.25](https://github.com/onevcat/Prowl/releases/tag/v2026.5.25) This release focuses on reliability improvements and a handful of UX polish items. -- 2.51.2 From 3cfe7973dba1fa0bf443ef6c929adc0b45778818 Mon Sep 17 00:00:00 2001 From: Abhishek Maurya <42537316+abhi21git@users.noreply.github.com> Date: Mon, 25 May 2026 13:39:01 +0530 Subject: [PATCH 217/528] Added an option to hide Editor and Run options --- .../Views/WorktreeDetailView.swift | 37 +++++++++++++------ .../Settings/Models/GlobalSettings.swift | 22 ++++++++++- .../Settings/Reducer/SettingsFeature.swift | 10 ++++- .../Views/AppearanceSettingsView.swift | 12 ++++++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 8c01e927..d4cdb0ec 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -16,6 +16,8 @@ struct WorktreeDetailView: View { let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool let availableUpdateVersion: String? + let showRunButtonInToolbar: Bool + let showDefaultEditorInToolbar: Bool } @Bindable var store: StoreOf @@ -89,7 +91,9 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, - availableUpdateVersion: state.updates.availableVersion + availableUpdateVersion: state.updates.availableVersion, + showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar, + showDefaultEditorInToolbar: settingsFile.global.showDefaultEditorInToolbar ) ) { @@ -206,7 +210,9 @@ struct WorktreeDetailView: View { runScriptIsRunning: input.runScriptIsRunning, customCommands: input.customCommands, isUpdateAvailable: input.isUpdateAvailable, - availableUpdateVersion: input.availableUpdateVersion + availableUpdateVersion: input.availableUpdateVersion, + showRunButtonInToolbar: input.showRunButtonInToolbar, + showDefaultEditorInToolbar: input.showDefaultEditorInToolbar ) } @@ -572,6 +578,8 @@ struct WorktreeDetailView: View { let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool let availableUpdateVersion: String? + let showRunButtonInToolbar: Bool + let showDefaultEditorInToolbar: Bool } fileprivate struct WorktreeToolbarContent: ToolbarContent { @@ -624,15 +632,15 @@ struct WorktreeDetailView: View { } } - ToolbarSpacer(.fixed) - - ToolbarItemGroup { - openMenu( - openActionSelection: toolbarState.openActionSelection, - showExtras: toolbarState.showExtras - ) + if toolbarState.showDefaultEditorInToolbar { + ToolbarSpacer(.fixed) + ToolbarItemGroup { + openMenu( + openActionSelection: toolbarState.openActionSelection, + showExtras: toolbarState.showExtras + ) + } } - ToolbarSpacer(.fixed) commandToolbarItems } @@ -691,7 +699,10 @@ struct WorktreeDetailView: View { @ToolbarContentBuilder private var commandToolbarItems: some ToolbarContent { - if toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled { + if toolbarState.showRunButtonInToolbar, + toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled + { + ToolbarSpacer(.fixed) ToolbarItem { RunScriptToolbarButton( isRunning: toolbarState.runScriptIsRunning, @@ -1060,7 +1071,9 @@ private struct WorktreeToolbarPreview: View { ) ], isUpdateAvailable: true, - availableUpdateVersion: "2026.5.1" + availableUpdateVersion: "2026.5.1", + showRunButtonInToolbar: true, + showDefaultEditorInToolbar: true ) let observer = CommandKeyObserver() observer.isPressed = false diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index cc1cbe72..b57b416e 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -31,6 +31,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var autoShowActiveAgentsPanel: Bool var windowTintMode: WindowTintMode var windowTintCustomColor: TintColor + var showRunButtonInToolbar: Bool + var showDefaultEditorInToolbar: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -64,7 +66,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: true, autoShowActiveAgentsPanel: false, windowTintMode: .repositoryColor, - windowTintCustomColor: .default + windowTintCustomColor: .default, + showRunButtonInToolbar: true, + showDefaultEditorInToolbar: true ) init( @@ -99,7 +103,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: Bool = true, autoShowActiveAgentsPanel: Bool = false, windowTintMode: WindowTintMode = .repositoryColor, - windowTintCustomColor: TintColor = .default + windowTintCustomColor: TintColor = .default, + showRunButtonInToolbar: Bool = true, + showDefaultEditorInToolbar: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -133,6 +139,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel self.windowTintMode = windowTintMode self.windowTintCustomColor = windowTintCustomColor + self.showRunButtonInToolbar = showRunButtonInToolbar + self.showDefaultEditorInToolbar = showDefaultEditorInToolbar } func encode(to encoder: any Encoder) throws { @@ -169,6 +177,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) try container.encode(windowTintMode, forKey: .windowTintMode) try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) + try container.encode(showRunButtonInToolbar, forKey: .showRunButtonInToolbar) + try container.encode(showDefaultEditorInToolbar, forKey: .showDefaultEditorInToolbar) } private enum CodingKeys: String, CodingKey { @@ -204,6 +214,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case autoShowActiveAgentsPanel case windowTintMode case windowTintCustomColor + case showRunButtonInToolbar + case showDefaultEditorInToolbar // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -308,5 +320,11 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { windowTintCustomColor = try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) ?? Self.default.windowTintCustomColor + showRunButtonInToolbar = + try container.decodeIfPresent(Bool.self, forKey: .showRunButtonInToolbar) + ?? Self.default.showRunButtonInToolbar + showDefaultEditorInToolbar = + try container.decodeIfPresent(Bool.self, forKey: .showDefaultEditorInToolbar) + ?? Self.default.showDefaultEditorInToolbar } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 237c517c..51258eb1 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -41,6 +41,8 @@ struct SettingsFeature { /// the `ColorPicker` can bind to it directly; converted back to the /// persistable `TintColor` at the `globalSettings` boundary. var windowTintCustomColor: Color + var showRunButtonInToolbar: Bool + var showDefaultEditorInToolbar: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true var selection: SettingsSection? = .general @@ -82,6 +84,8 @@ struct SettingsFeature { autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel windowTintMode = settings.windowTintMode windowTintCustomColor = settings.windowTintCustomColor.color + showRunButtonInToolbar = settings.showRunButtonInToolbar + showDefaultEditorInToolbar = settings.showDefaultEditorInToolbar } var globalSettings: GlobalSettings { @@ -119,7 +123,9 @@ struct SettingsFeature { dimUnfocusedSplits: dimUnfocusedSplits, autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, windowTintMode: windowTintMode, - windowTintCustomColor: TintColor(windowTintCustomColor) + windowTintCustomColor: TintColor(windowTintCustomColor), + showRunButtonInToolbar: showRunButtonInToolbar, + showDefaultEditorInToolbar: showDefaultEditorInToolbar ) } } @@ -224,6 +230,8 @@ struct SettingsFeature { state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel state.windowTintMode = normalizedSettings.windowTintMode state.windowTintCustomColor = normalizedSettings.windowTintCustomColor.color + state.showRunButtonInToolbar = normalizedSettings.showRunButtonInToolbar + state.showDefaultEditorInToolbar = normalizedSettings.showDefaultEditorInToolbar state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 1470e04d..a8dc03a3 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -93,6 +93,11 @@ struct AppearanceSettingsView: View { .help("View Prowl starts in on launch. Shelf and Canvas require at least one worktree or folder.") } Section("Default Editor") { + Toggle( + "Show in toolbar", + isOn: $store.showDefaultEditorInToolbar + ) + .help("Show the Open in Editor button in the worktree toolbar.") Picker( "Default editor", selection: $store.defaultEditorID @@ -106,6 +111,13 @@ struct AppearanceSettingsView: View { } .help("Applies to worktrees without repository overrides.") } + Section("Run") { + Toggle( + "Show in toolbar", + isOn: $store.showRunButtonInToolbar + ) + .help("Show the Run button in the worktree toolbar.") + } Section("Quit") { Toggle( "Confirm before quitting", -- 2.51.2 From 42717e75008320255aba5b9c5c001bcf397196fe Mon Sep 17 00:00:00 2001 From: Abhishek Maurya <42537316+abhi21git@users.noreply.github.com> Date: Mon, 25 May 2026 14:43:22 +0530 Subject: [PATCH 218/528] Added option to show notification dot and bounce app icon when app is not focused --- .../Features/App/Reducer/AppFeature.swift | 29 ++++++++++++++++++- .../Settings/Models/DockBounceMode.swift | 27 +++++++++++++++++ .../Settings/Models/GlobalSettings.swift | 22 ++++++++++++-- .../Settings/Reducer/SettingsFeature.swift | 10 ++++++- .../Views/NotificationsSettingsView.swift | 14 +++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 supacode/Features/Settings/Models/DockBounceMode.swift diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 402f61fd..cfd2fb4c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -499,6 +499,7 @@ struct AppFeature { settings: state.settings, customCommands: state.selectedCustomCommands ) + let showDot = settings.showNotificationDotOnDock && state.notificationIndicatorCount > 0 return .merge( .send(.repositories(.githubIntegration(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled)))), .send( @@ -570,6 +571,11 @@ struct AppFeature { case .denied: await send(.systemNotificationsPermissionFailed(errorMessage: "Authorization status is denied.")) } + }, + .run { _ in + await MainActor.run { + NSApplication.shared.dockTile.badgeLabel = showDot ? "●" : nil + } } ) @@ -1194,13 +1200,34 @@ struct AppFeature { } ) } + switch state.settings.dockBounceMode { + case .off: + break + case .once: + effects.append( + .run { _ in + await MainActor.run { + _ = NSApp.requestUserAttention(.informationalRequest) + } + } + ) + case .continuous: + effects.append( + .run { _ in + await MainActor.run { + _ = NSApp.requestUserAttention(.criticalRequest) + } + } + ) + } return .merge(effects) case .terminalEvent(.notificationIndicatorChanged(let count)): state.notificationIndicatorCount = count + let showDot = state.settings.showNotificationDotOnDock && count > 0 return .run { _ in await MainActor.run { - NSApplication.shared.dockTile.badgeLabel = nil + NSApplication.shared.dockTile.badgeLabel = showDot ? "●" : nil } } diff --git a/supacode/Features/Settings/Models/DockBounceMode.swift b/supacode/Features/Settings/Models/DockBounceMode.swift new file mode 100644 index 00000000..14a6ab8b --- /dev/null +++ b/supacode/Features/Settings/Models/DockBounceMode.swift @@ -0,0 +1,27 @@ +/// Controls whether and how the Prowl dock icon bounces when an in-app +/// notification is received. +/// +/// Persistence: encoded as the raw `String` (case name). Cases must never +/// be renamed once shipped because user JSON references them by name. +enum DockBounceMode: String, CaseIterable, Identifiable, Codable, Sendable { + /// Do not bounce the dock icon. + case off + /// Bounce the dock icon once (`NSRequestUserAttentionType.informationalRequest`). + case once + /// Bounce the dock icon repeatedly until Prowl is brought to the front + /// (`NSRequestUserAttentionType.criticalRequest`). + case continuous + + var id: String { rawValue } + + var title: String { + switch self { + case .off: + return "Off" + case .once: + return "Once" + case .continuous: + return "Continuously" + } + } +} diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index b57b416e..3db21620 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -33,6 +33,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var windowTintCustomColor: TintColor var showRunButtonInToolbar: Bool var showDefaultEditorInToolbar: Bool + var dockBounceMode: DockBounceMode + var showNotificationDotOnDock: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -68,7 +70,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { windowTintMode: .repositoryColor, windowTintCustomColor: .default, showRunButtonInToolbar: true, - showDefaultEditorInToolbar: true + showDefaultEditorInToolbar: true, + dockBounceMode: .off, + showNotificationDotOnDock: false ) init( @@ -105,7 +109,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { windowTintMode: WindowTintMode = .repositoryColor, windowTintCustomColor: TintColor = .default, showRunButtonInToolbar: Bool = true, - showDefaultEditorInToolbar: Bool = true + showDefaultEditorInToolbar: Bool = true, + dockBounceMode: DockBounceMode = .off, + showNotificationDotOnDock: Bool = false ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -141,6 +147,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.windowTintCustomColor = windowTintCustomColor self.showRunButtonInToolbar = showRunButtonInToolbar self.showDefaultEditorInToolbar = showDefaultEditorInToolbar + self.dockBounceMode = dockBounceMode + self.showNotificationDotOnDock = showNotificationDotOnDock } func encode(to encoder: any Encoder) throws { @@ -179,6 +187,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) try container.encode(showRunButtonInToolbar, forKey: .showRunButtonInToolbar) try container.encode(showDefaultEditorInToolbar, forKey: .showDefaultEditorInToolbar) + try container.encode(dockBounceMode, forKey: .dockBounceMode) + try container.encode(showNotificationDotOnDock, forKey: .showNotificationDotOnDock) } private enum CodingKeys: String, CodingKey { @@ -216,6 +226,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case windowTintCustomColor case showRunButtonInToolbar case showDefaultEditorInToolbar + case dockBounceMode + case showNotificationDotOnDock // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -326,5 +338,11 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { showDefaultEditorInToolbar = try container.decodeIfPresent(Bool.self, forKey: .showDefaultEditorInToolbar) ?? Self.default.showDefaultEditorInToolbar + dockBounceMode = + try container.decodeIfPresent(DockBounceMode.self, forKey: .dockBounceMode) + ?? Self.default.dockBounceMode + showNotificationDotOnDock = + try container.decodeIfPresent(Bool.self, forKey: .showNotificationDotOnDock) + ?? Self.default.showNotificationDotOnDock } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 51258eb1..2138df14 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -43,6 +43,8 @@ struct SettingsFeature { var windowTintCustomColor: Color var showRunButtonInToolbar: Bool var showDefaultEditorInToolbar: Bool + var dockBounceMode: DockBounceMode + var showNotificationDotOnDock: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true var selection: SettingsSection? = .general @@ -86,6 +88,8 @@ struct SettingsFeature { windowTintCustomColor = settings.windowTintCustomColor.color showRunButtonInToolbar = settings.showRunButtonInToolbar showDefaultEditorInToolbar = settings.showDefaultEditorInToolbar + dockBounceMode = settings.dockBounceMode + showNotificationDotOnDock = settings.showNotificationDotOnDock } var globalSettings: GlobalSettings { @@ -125,7 +129,9 @@ struct SettingsFeature { windowTintMode: windowTintMode, windowTintCustomColor: TintColor(windowTintCustomColor), showRunButtonInToolbar: showRunButtonInToolbar, - showDefaultEditorInToolbar: showDefaultEditorInToolbar + showDefaultEditorInToolbar: showDefaultEditorInToolbar, + dockBounceMode: dockBounceMode, + showNotificationDotOnDock: showNotificationDotOnDock ) } } @@ -232,6 +238,8 @@ struct SettingsFeature { state.windowTintCustomColor = normalizedSettings.windowTintCustomColor.color state.showRunButtonInToolbar = normalizedSettings.showRunButtonInToolbar state.showDefaultEditorInToolbar = normalizedSettings.showDefaultEditorInToolbar + state.dockBounceMode = normalizedSettings.dockBounceMode + state.showNotificationDotOnDock = normalizedSettings.showNotificationDotOnDock state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index b83751f1..639414c1 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -29,6 +29,20 @@ struct NotificationsSettingsView: View { isOn: $store.moveNotifiedWorktreeToTop ) .help("Bring the worktree to the top when the terminal receives a notification") + Picker( + "Bounce dock icon", + selection: $store.dockBounceMode + ) { + ForEach(DockBounceMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .help("Bounce the Prowl app icon in the Dock when a notification is received.") + Toggle( + "Show notification dot on dock icon", + isOn: $store.showNotificationDotOnDock + ) + .help("Show a badge on the Prowl dock icon while notifications are pending.") } Section("Command Finished") { Toggle( -- 2.51.2 From 0dc4611164b59b618c58e54d8447e693a67b92e9 Mon Sep 17 00:00:00 2001 From: Abhishek Maurya <42537316+abhi21git@users.noreply.github.com> Date: Mon, 25 May 2026 18:23:08 +0530 Subject: [PATCH 219/528] lint error --- supacode/Features/Settings/Models/GlobalSettings.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 3db21620..e4047529 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -232,6 +232,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case automaticallyArchiveMergedWorktrees } + // swiftlint:disable:next function_body_length init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) appearanceMode = try container.decode(AppearanceMode.self, forKey: .appearanceMode) -- 2.51.2 From 01318b20d3d4f16f7f6db4bb228fdb8d3109fbae Mon Sep 17 00:00:00 2001 From: onevclaw Date: Wed, 27 May 2026 09:18:30 +0900 Subject: [PATCH 220/528] Add Shelf spine tint preferences --- supacode/Domain/WindowChromeTint.swift | 44 +++++++++++++-- .../Settings/Models/GlobalSettings.swift | 39 ++++++++++++-- .../Models/ShelfSpineTintFallback.swift | 23 ++++++++ .../Settings/Models/WindowTintMode.swift | 8 +-- .../Settings/Reducer/SettingsFeature.swift | 10 +++- .../Views/AppearanceSettingsView.swift | 37 +++++++++++-- .../Features/Shelf/Views/ShelfSpineView.swift | 43 +++++++++------ supacode/Features/Shelf/Views/ShelfView.swift | 9 ++-- supacodeTests/WindowChromeTintTests.swift | 54 +++++++++++++++++++ 9 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 supacode/Features/Settings/Models/ShelfSpineTintFallback.swift diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index e221a857..98dbf727 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -14,8 +14,8 @@ import SwiftUI /// `WindowTintMode` selects the source: /// - `.none` ⇒ no band (neutral system chrome). /// - `.repositoryColor` ⇒ the active repo's pinned color, or a neutral -/// surface when uncolored — the same `repositoryBase`/`repositoryPeakAlpha` -/// pair the Shelf spine uses, so the chrome and the open spine match. +/// surface when uncolored. Shelf spines can use the same repo-color pair, +/// or their own user-selected fallback behavior. /// - `.custom` ⇒ a single user-chosen color, ignoring per-repo colors. enum WindowChromeTint { enum ToolbarFallbackEvent { @@ -58,8 +58,8 @@ enum WindowChromeTint { static let neutralPeakAlpha: Double = 0.10 /// Base hue for a repository-color surface: the pinned color, or - /// `Color.primary` for the neutral fallback. Used by the Shelf spine - /// (always) and by `.repositoryColor` chrome bands. + /// `Color.primary` for the neutral fallback. Used by `.repositoryColor` + /// chrome bands. static func repositoryBase(for color: RepositoryColorChoice?) -> Color { color?.color ?? .primary } @@ -70,6 +70,42 @@ enum WindowChromeTint { color == nil ? neutralPeakAlpha : saturatedPeakAlpha } + /// Base hue for a Shelf spine surface. When `followsRepositoryColor` is + /// true, a pinned repo color wins; otherwise every spine uses the user's + /// fallback style. + static func shelfSpineBase( + for color: RepositoryColorChoice?, + fallback: ShelfSpineTintFallback, + followsRepositoryColor: Bool + ) -> Color { + if followsRepositoryColor, let color { + return color.color + } + return shelfSpineFallbackBase(fallback) + } + + /// Peak alpha for a Shelf spine surface. Neutral fallbacks stay gentler; + /// pinned repo colors and system tint use the saturated shelf/chrome peak. + static func shelfSpinePeakAlpha( + for color: RepositoryColorChoice?, + fallback: ShelfSpineTintFallback, + followsRepositoryColor: Bool + ) -> Double { + if followsRepositoryColor, color != nil { + return saturatedPeakAlpha + } + return fallback == .neutral ? neutralPeakAlpha : saturatedPeakAlpha + } + + private static func shelfSpineFallbackBase(_ fallback: ShelfSpineTintFallback) -> Color { + switch fallback { + case .neutral: + return .primary + case .systemTint: + return .accentColor + } + } + /// Resolves the chrome band for the given tint mode. Returns `nil` when /// no band should be drawn (`.none`, or `.custom` without a color). /// diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index cc1cbe72..95f4ebdd 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -31,6 +31,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var autoShowActiveAgentsPanel: Bool var windowTintMode: WindowTintMode var windowTintCustomColor: TintColor + var shelfSpineTintFallback: ShelfSpineTintFallback + var shelfSpineTintFollowsRepositoryColor: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -64,7 +66,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: true, autoShowActiveAgentsPanel: false, windowTintMode: .repositoryColor, - windowTintCustomColor: .default + windowTintCustomColor: .default, + shelfSpineTintFallback: .neutral, + shelfSpineTintFollowsRepositoryColor: true ) init( @@ -99,7 +103,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: Bool = true, autoShowActiveAgentsPanel: Bool = false, windowTintMode: WindowTintMode = .repositoryColor, - windowTintCustomColor: TintColor = .default + windowTintCustomColor: TintColor = .default, + shelfSpineTintFallback: ShelfSpineTintFallback = .neutral, + shelfSpineTintFollowsRepositoryColor: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -133,6 +139,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel self.windowTintMode = windowTintMode self.windowTintCustomColor = windowTintCustomColor + self.shelfSpineTintFallback = shelfSpineTintFallback + self.shelfSpineTintFollowsRepositoryColor = shelfSpineTintFollowsRepositoryColor } func encode(to encoder: any Encoder) throws { @@ -169,6 +177,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) try container.encode(windowTintMode, forKey: .windowTintMode) try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) + try container.encode(shelfSpineTintFallback, forKey: .shelfSpineTintFallback) + try container.encode(shelfSpineTintFollowsRepositoryColor, forKey: .shelfSpineTintFollowsRepositoryColor) } private enum CodingKeys: String, CodingKey { @@ -204,6 +214,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case autoShowActiveAgentsPanel case windowTintMode case windowTintCustomColor + case shelfSpineTintFallback + case shelfSpineTintFollowsRepositoryColor // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -305,8 +317,27 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { windowTintMode = try container.decodeIfPresent(WindowTintMode.self, forKey: .windowTintMode) ?? Self.default.windowTintMode - windowTintCustomColor = - try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) + windowTintCustomColor = try Self.decodeWindowTintCustomColor(from: container) + (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = + try Self.decodeShelfSpineTint(from: container) + } + + private static func decodeWindowTintCustomColor( + from container: KeyedDecodingContainer + ) throws -> TintColor { + try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) ?? Self.default.windowTintCustomColor } + + private static func decodeShelfSpineTint( + from container: KeyedDecodingContainer + ) throws -> (ShelfSpineTintFallback, Bool) { + let fallback = + try container.decodeIfPresent(ShelfSpineTintFallback.self, forKey: .shelfSpineTintFallback) + ?? Self.default.shelfSpineTintFallback + let followsRepositoryColor = + try container.decodeIfPresent(Bool.self, forKey: .shelfSpineTintFollowsRepositoryColor) + ?? Self.default.shelfSpineTintFollowsRepositoryColor + return (fallback, followsRepositoryColor) + } } diff --git a/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift b/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift new file mode 100644 index 00000000..e3b5117d --- /dev/null +++ b/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift @@ -0,0 +1,23 @@ +/// Fallback color used by Shelf spine surfaces when a repository has no +/// pinned color, or for every spine when repository colors are ignored. +/// +/// Persistence: encoded as the raw `String` (case name). Cases must never +/// be renamed once shipped because user JSON references them by name. +enum ShelfSpineTintFallback: String, CaseIterable, Identifiable, Codable, Sendable { + /// Use the neutral primary-color surface (near-black in dark mode / + /// near-white in light) matching the current Shelf behavior. + case neutral + /// Use the system accent tint for the spine surface. + case systemTint + + var id: String { rawValue } + + var title: String { + switch self { + case .neutral: + return "Neutral" + case .systemTint: + return "System Tint" + } + } +} diff --git a/supacode/Features/Settings/Models/WindowTintMode.swift b/supacode/Features/Settings/Models/WindowTintMode.swift index 8b81fec3..4865cd0b 100644 --- a/supacode/Features/Settings/Models/WindowTintMode.swift +++ b/supacode/Features/Settings/Models/WindowTintMode.swift @@ -2,8 +2,8 @@ /// floating sidebar and the toolbar band behind the titlebar — across /// every view mode (Normal, Shelf, Canvas). /// -/// The Shelf spine is *not* governed by this setting: it always tints with -/// the open repo's pinned color (see `WindowChromeTint.repositoryBase`). +/// Shelf spine tinting has its own adjacent settings: the fallback style for +/// uncolored repositories and whether spines follow per-repository colors. /// /// Persistence: encoded as the raw `String` (case name). Cases must never /// be renamed once shipped because user JSON references them by name. @@ -11,8 +11,8 @@ enum WindowTintMode: String, CaseIterable, Identifiable, Codable, Sendable { /// No chrome tint. The nav and toolbar fall back to the neutral system /// chrome (the default, untinted look). case none - /// Tint the chrome with the active repository's pinned color, matching - /// the Shelf spine. An uncolored repo falls back to a neutral surface + /// Tint the chrome with the active repository's pinned color. An + /// uncolored repo falls back to a neutral surface /// (near-black in dark mode / near-white in light). case repositoryColor /// Tint the chrome with a single user-chosen color, unconditionally — diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 237c517c..fe7ee635 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -37,6 +37,8 @@ struct SettingsFeature { var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool var windowTintMode: WindowTintMode + var shelfSpineTintFallback: ShelfSpineTintFallback + var shelfSpineTintFollowsRepositoryColor: Bool /// Mirrors `GlobalSettings.windowTintCustomColor` as a live `Color` so /// the `ColorPicker` can bind to it directly; converted back to the /// persistable `TintColor` at the `globalSettings` boundary. @@ -81,6 +83,8 @@ struct SettingsFeature { dimUnfocusedSplits = settings.dimUnfocusedSplits autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel windowTintMode = settings.windowTintMode + shelfSpineTintFallback = settings.shelfSpineTintFallback + shelfSpineTintFollowsRepositoryColor = settings.shelfSpineTintFollowsRepositoryColor windowTintCustomColor = settings.windowTintCustomColor.color } @@ -119,7 +123,9 @@ struct SettingsFeature { dimUnfocusedSplits: dimUnfocusedSplits, autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, windowTintMode: windowTintMode, - windowTintCustomColor: TintColor(windowTintCustomColor) + windowTintCustomColor: TintColor(windowTintCustomColor), + shelfSpineTintFallback: shelfSpineTintFallback, + shelfSpineTintFollowsRepositoryColor: shelfSpineTintFollowsRepositoryColor ) } } @@ -223,6 +229,8 @@ struct SettingsFeature { state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel state.windowTintMode = normalizedSettings.windowTintMode + state.shelfSpineTintFallback = normalizedSettings.shelfSpineTintFallback + state.shelfSpineTintFollowsRepositoryColor = normalizedSettings.shelfSpineTintFollowsRepositoryColor state.windowTintCustomColor = normalizedSettings.windowTintCustomColor.color state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 1470e04d..7a30d77b 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -52,9 +52,7 @@ struct AppearanceSettingsView: View { Text(mode.title).tag(mode) } } - .help( - "Color the navigation panel and toolbar. The Shelf spine always uses its repository color." - ) + .help("Color the navigation panel and toolbar.") if store.windowTintMode == .custom { ColorPicker( "Custom tint color", @@ -66,6 +64,23 @@ struct AppearanceSettingsView: View { Text(tintFootnote) .font(.callout) .foregroundStyle(.secondary) + + Divider() + + Picker("Tint spines in Shelf View", selection: $store.shelfSpineTintFallback) { + ForEach(ShelfSpineTintFallback.allCases) { fallback in + Text(fallback.title).tag(fallback) + } + } + .help("Choose how Shelf spines are tinted when no repository color is available.") + Toggle( + "Follow Repo Color Setting", + isOn: $store.shelfSpineTintFollowsRepositoryColor + ) + .help("When disabled, all Shelf spines use the selected Neutral or System Tint style.") + Text(shelfSpineTintFootnote) + .font(.callout) + .foregroundStyle(.secondary) } Section("Splits") { Toggle( @@ -129,4 +144,20 @@ struct AppearanceSettingsView: View { return "Uses your chosen color everywhere, regardless of per-repository colors." } } + + private var shelfSpineTintFootnote: String { + let fallback = + switch store.shelfSpineTintFallback { + case .neutral: + "Uncolored repositories use a neutral spine." + case .systemTint: + "Uncolored repositories use the system tint color." + } + + if store.shelfSpineTintFollowsRepositoryColor { + return fallback + " Repositories with a custom color still use that color." + } else { + return fallback + " Repository colors are ignored for Shelf spines." + } + } } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index f121ec84..ba115b8f 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -20,6 +20,8 @@ struct ShelfSpineView: View { /// glance instead of every non-open spine looking identical. let distanceFromOpen: Int? let terminalState: WorktreeTerminalState? + let tintFallback: ShelfSpineTintFallback + let followsRepositoryColor: Bool let onOpenBook: () -> Void let onSelectTab: (TerminalTabID) -> Void /// Bottom controls — provided only for the open book's spine. `nil` @@ -121,30 +123,41 @@ struct ShelfSpineView: View { /// "this is interactable" affordance that sits just below the open /// book and animates in/out smoothly. /// - /// The surface hue/alpha come from `WindowChromeTint`'s repository-color - /// surface: a repo with a user-pinned color tints its spine with that + /// The surface hue/alpha come from the Shelf spine tint settings. By + /// default, a repo with a user-pinned color tints its spine with that /// color, while an uncolored repo uses a neutral surface (near-black in - /// dark mode, near-white in light) so the shelf stays calm and the open - /// book's spine reads as one continuous "L" with the toolbar tint band - /// above it. The spine always uses the repo color and ignores the global - /// `WindowTintMode` — only the chrome bands honor that setting. The - /// proximity ladder is unchanged — we only swap the base. + /// dark mode, near-white in light). Users can switch uncolored spines to + /// the system tint, or disable repository-color following so every spine + /// uses the selected fallback style. The proximity ladder is unchanged — + /// we only swap the base. private var spineBackgroundColor: Color { guard distanceFromOpen != nil else { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return WindowChromeTint.repositoryBase(for: appearance.color) - .opacity(WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier) + return WindowChromeTint.shelfSpineBase( + for: appearance.color, + fallback: tintFallback, + followsRepositoryColor: followsRepositoryColor + ) + .opacity( + WindowChromeTint.shelfSpinePeakAlpha( + for: appearance.color, + fallback: tintFallback, + followsRepositoryColor: followsRepositoryColor + ) * multiplier + ) } - /// Repo's pinned color, or `.accentColor` when none — used as the header - /// icon tint and the active-tab highlight. The spine *surface* fill - /// instead routes through `WindowChromeTint` (neutral when uncolored), so - /// an uncolored repo keeps an accent icon / tab marker on an otherwise - /// neutral spine. + /// Tint used as the header icon color and the active-tab highlight. It + /// follows the same repo-color override as the spine surface, but falls + /// back to system tint so the neutral default preserves the previous + /// accent icon / tab marker on an otherwise neutral spine. private var effectiveTintColor: Color { - appearance.color?.color ?? .accentColor + if followsRepositoryColor, let color = appearance.color { + return color.color + } + return .accentColor } private var appearance: RepositoryAppearance { diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 7fb0b40f..897ee1b5 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -24,9 +24,8 @@ struct ShelfView: View { /// terminal surface and empty-state area. @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity @Shared(.repositoryAppearances) private var repositoryAppearances - /// Drives the chrome tint mode / custom color. The Shelf *spine* always - /// uses the open repo's color regardless of this setting; only the - /// toolbar / nav bands below honor it. + /// Drives the chrome tint mode / custom color and the Shelf spine tint + /// preferences. @Shared(.settingsFile) private var settingsFile var body: some View { @@ -48,7 +47,7 @@ struct ShelfView: View { let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } // Chrome band fill for the toolbar (top) and nav (leading), honoring the // user's window tint mode. Only shown when a book is open; an empty - // shelf keeps its bare chrome. The spine itself is unaffected. + // shelf keeps its bare chrome. let chromeFill = openBook == nil ? nil @@ -92,6 +91,8 @@ struct ShelfView: View { isOpen: open, distanceFromOpen: distance, terminalState: terminalManager.stateIfExists(for: book.id), + tintFallback: settingsFile.global.shelfSpineTintFallback, + followsRepositoryColor: settingsFile.global.shelfSpineTintFollowsRepositoryColor, onOpenBook: { openBook(book, selectingTab: nil) }, onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, onNewTab: { diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index e52fcc6d..d861a4b5 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -44,6 +44,60 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) } + @Test + func shelfSpineUsesNeutralFallbackByDefaultForUncoloredRepositories() { + #expect( + WindowChromeTint.shelfSpineBase( + for: nil, + fallback: .neutral, + followsRepositoryColor: true + ) == .primary + ) + #expect( + WindowChromeTint.shelfSpinePeakAlpha( + for: nil, + fallback: .neutral, + followsRepositoryColor: true + ) == WindowChromeTint.neutralPeakAlpha + ) + } + + @Test + func shelfSpineCanUseSystemTintFallbackForUncoloredRepositories() { + #expect( + WindowChromeTint.shelfSpineBase( + for: nil, + fallback: .systemTint, + followsRepositoryColor: true + ) == .accentColor + ) + #expect( + WindowChromeTint.shelfSpinePeakAlpha( + for: nil, + fallback: .systemTint, + followsRepositoryColor: true + ) == WindowChromeTint.saturatedPeakAlpha + ) + } + + @Test + func shelfSpineCanIgnoreRepositoryColors() { + #expect( + WindowChromeTint.shelfSpineBase( + for: .green, + fallback: .neutral, + followsRepositoryColor: false + ) == .primary + ) + #expect( + WindowChromeTint.shelfSpineBase( + for: .green, + fallback: .systemTint, + followsRepositoryColor: false + ) == .accentColor + ) + } + // MARK: - Toolbar background resolution @Test -- 2.51.2 From f5f638df6c878c3abf7998408303549296da47d0 Mon Sep 17 00:00:00 2001 From: vince Date: Wed, 27 May 2026 11:42:49 +0800 Subject: [PATCH 221/528] Support custom actions in canvas mode --- supacode/App/ContentView.swift | 3 + supacode/Commands/WorktreeCommands.swift | 4 +- .../Features/App/Reducer/AppFeature.swift | 57 +++++++- .../Features/Canvas/Views/CanvasView.swift | 3 + .../Reducer/CommandPaletteFeature.swift | 19 ++- .../Views/WorktreeDetailView.swift | 127 +++++++++++++++--- .../AppFeatureCustomCommandTests.swift | 92 +++++++++++++ supacodeTests/AppFeatureRunScriptTests.swift | 25 ++++ .../CommandPaletteFeatureTests.swift | 17 +++ 9 files changed, 319 insertions(+), 28 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 7364bcba..2218f37e 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -89,6 +89,9 @@ struct ContentView: View { from: store.repositories, customCommands: store.selectedCustomCommands, runScriptStatusByWorktreeID: store.runScriptStatusByWorktreeID, + actionTargetWorktreeID: store.repositories.isShowingCanvas + ? terminalManager.canvasFocusedWorktreeID + : nil, ghosttyCommands: ghosttyShortcuts.commandPaletteEntries ), resolvedKeybindings: store.resolvedKeybindings diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 7acd8ca8..9e0e6a1a 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -18,7 +18,9 @@ struct WorktreeCommands: Commands { var body: some Commands { let repositories = store.repositories - let hasActiveWorktree = repositories.worktree(for: repositories.selectedWorktreeID) != nil + let hasActiveWorktree = + repositories.selectedTerminalWorktree != nil + || (repositories.isShowingCanvas && !store.selectedCustomCommands.isEmpty) let orderedRows = visibleHotkeyWorktreeRows ?? repositories.orderedWorktreeRows() let codeHostWorktreeID = selectedCodeHostWorktreeID let codeHostLabel = "Open on \(repositories.codeHost(forWorktreeID: codeHostWorktreeID).displayName)" diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 402f61fd..382039bb 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -89,6 +89,7 @@ struct AppFeature { case setLeftSidebarVisibility(NavigationSplitViewVisibility) case runScript case runCustomCommand(Int) + case canvasFocusedWorktreeChanged(Worktree.ID?) case runScriptDraftChanged(String) case runScriptPromptPresented(Bool) case saveRunScriptAndRun @@ -154,11 +155,29 @@ struct AppFeature { if let worktree = repositories.selectedTerminalWorktree { return worktree } + return canvasFocusedTerminalWorktree(repositories: repositories) + } + + private func actionTargetWorktree(repositories: RepositoriesFeature.State) -> Worktree? { + if let worktree = repositories.selectedTerminalWorktree { + return worktree + } + return canvasFocusedTerminalWorktree(repositories: repositories) + } + + private func canvasFocusedTerminalWorktree(repositories: RepositoriesFeature.State) -> Worktree? { guard repositories.isShowingCanvas, let worktreeID = terminalClient.canvasFocusedWorktreeID() else { return nil } + return terminalWorktree(for: worktreeID, repositories: repositories) + } + + private func terminalWorktree( + for worktreeID: Worktree.ID, + repositories: RepositoriesFeature.State + ) -> Worktree? { if let worktree = repositories.worktree(for: worktreeID) { return worktree } @@ -714,7 +733,7 @@ struct AppFeature { return .none case .runScript: - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } let trimmed = state.selectedRunScript.trimmingCharacters(in: .whitespacesAndNewlines) @@ -733,7 +752,7 @@ struct AppFeature { } case .runCustomCommand(let index): - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } guard state.selectedCustomCommands.indices.contains(index) else { @@ -789,6 +808,32 @@ struct AppFeature { } } + case .canvasFocusedWorktreeChanged(let worktreeID): + guard state.repositories.isShowingCanvas, + let worktreeID, + let worktree = terminalWorktree(for: worktreeID, repositories: state.repositories) + else { + state.openActionSelection = .finder + state.selectedRunScript = "" + state.selectedCustomCommands = [] + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) + state.runScriptDraft = "" + state.isRunScriptPromptPresented = false + return .run { _ in + await customShortcutRegistryClient.setShortcuts([]) + } + } + let rootURL = worktree.repositoryRootURL + @Shared(.repositorySettings(rootURL)) var repositorySettings + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings + return .concatenate( + .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), + .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) + ) + case .runScriptDraftChanged(let script): state.runScriptDraft = script return .none @@ -801,7 +846,7 @@ struct AppFeature { return .none case .saveRunScriptAndRun: - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { state.isRunScriptPromptPresented = false state.runScriptDraft = "" return .none @@ -823,7 +868,7 @@ struct AppFeature { return .send(.runScript) case .stopRunScript: - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } return .run { _ in @@ -908,7 +953,7 @@ struct AppFeature { ) case .worktreeSettingsLoaded(let settings, let worktreeID): - guard state.repositories.selectedTerminalWorktree?.id == worktreeID else { + guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { return .none } @Shared(.settingsFile) var settingsFile @@ -923,7 +968,7 @@ struct AppFeature { return .none case .worktreeUserSettingsLoaded(let settings, let worktreeID): - guard state.repositories.selectedTerminalWorktree?.id == worktreeID else { + guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { return .none } state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 68cc033a..e7b336c2 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -13,6 +13,7 @@ struct CanvasView: View { /// per-frame canvas hot path. var repositoryCustomTitles: [Repository.ID: String] = [:] var onExitToTab: () -> Void = {} + var onFocusedWorktreeChanged: (Worktree.ID?) -> Void = { _ in } @State private var layoutStore = CanvasLayoutStore() @Shared(.repositoryAppearances) private var repositoryAppearances @@ -712,12 +713,14 @@ struct CanvasView: View { let surfaceView = ownerState.surfaceView(for: newTabID) else { terminalManager.canvasFocusedWorktreeID = nil + onFocusedWorktreeChanged(nil) return } layoutStore.moveToFront(newTabID.rawValue.uuidString) ownerState.tabManager.selectTab(newTabID) terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID + onFocusedWorktreeChanged(ownerState.worktreeID) surfaceView.focusDidChange(true) surfaceView.requestFocus() } diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6993ba2e..4cb9b4a9 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -216,12 +216,14 @@ struct CommandPaletteFeature { from repositories: RepositoriesFeature.State, customCommands: [UserCustomCommand] = [], runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:], + actionTargetWorktreeID: Worktree.ID? = nil, ghosttyCommands: [GhosttyCommand] = [] ) -> [CommandPaletteItem] { let showsNewWorktreeAction = repositories.repositories.isEmpty || repositories.repositories.contains { $0.capabilities.supportsWorktrees } var items = globalCommandItems(showsNewWorktreeAction: showsNewWorktreeAction) + let worktreeActionTargetID = actionTargetWorktreeID ?? repositories.selectedWorktreeID if repositories.selectedWorktreeID != nil { items.append( .appShortcut( @@ -236,9 +238,19 @@ struct CommandPaletteFeature { items.append( contentsOf: worktreeActionCommandItems( repositories: repositories, + worktreeID: worktreeActionTargetID, runScriptStatusByWorktreeID: runScriptStatusByWorktreeID ) ) + } else if worktreeActionTargetID != nil { + items.append( + contentsOf: worktreeActionCommandItems( + repositories: repositories, + worktreeID: worktreeActionTargetID, + runScriptStatusByWorktreeID: runScriptStatusByWorktreeID, + includeSelectionScopedItems: false + ) + ) } items.append(contentsOf: customCommandItems(customCommands)) if let terminalWorktree = repositories.selectedTerminalWorktree { @@ -384,9 +396,11 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette private func worktreeActionCommandItems( repositories: RepositoriesFeature.State, - runScriptStatusByWorktreeID: [Worktree.ID: Bool] + worktreeID: Worktree.ID?, + runScriptStatusByWorktreeID: [Worktree.ID: Bool], + includeSelectionScopedItems: Bool = true ) -> [CommandPaletteItem] { - guard let worktreeID = repositories.selectedWorktreeID else { return [] } + guard let worktreeID else { return [] } var items: [CommandPaletteItem] = [] let isRunScriptRunning = runScriptStatusByWorktreeID[worktreeID] ?? false if isRunScriptRunning { @@ -410,6 +424,7 @@ private func worktreeActionCommandItems( ) ) } + guard includeSelectionScopedItems else { return items } guard let row = repositories.selectedRow(for: worktreeID) else { return items } // Rename Branch works on any worktree (main included). items.append( diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 8c01e927..7c17be9c 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -18,6 +18,16 @@ struct WorktreeDetailView: View { let availableUpdateVersion: String? } + private struct CanvasToolbarState { + let notificationGroups: [ToolbarNotificationRepositoryGroup] + let unseenNotificationWorktreeCount: Int + let runScriptEnabled: Bool + let runScriptIsRunning: Bool + let customCommands: [UserCustomCommand] + let isUpdateAvailable: Bool + let availableUpdateVersion: String? + } + @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @Environment(CommandKeyObserver.self) private var commandKeyObserver @@ -34,6 +44,8 @@ struct WorktreeDetailView: View { let selectedRow = repositories.selectedRow(for: repositories.selectedWorktreeID) let selectedWorktree = repositories.worktree(for: repositories.selectedWorktreeID) let selectedTerminalWorktree = repositories.selectedTerminalWorktree + let canvasFocusedTerminalWorktree = canvasFocusedTerminalWorktree(repositories: repositories) + let actionTargetWorktree = selectedTerminalWorktree ?? canvasFocusedTerminalWorktree let selectedWorktreeSummaries = selectedWorktreeSummaries(from: repositories) let showsMultiSelectionSummary = shouldShowMultiSelectionSummary( repositories: repositories, @@ -45,12 +57,12 @@ struct WorktreeDetailView: View { repositories: repositories ) let hasActiveTerminalTarget = - selectedTerminalWorktree != nil + actionTargetWorktree != nil && loadingInfo == nil && !showsMultiSelectionSummary let openActionSelection = state.openActionSelection let runScriptEnabled = hasActiveTerminalTarget - let runScriptIsRunning = selectedTerminalWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true + let runScriptIsRunning = actionTargetWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true let customCommands = state.selectedCustomCommands let notificationGroups = repositories.toolbarNotificationGroups( terminalManager: terminalManager, @@ -71,10 +83,15 @@ struct WorktreeDetailView: View { .toolbar { if repositories.isShowingCanvas { canvasToolbarContent( - notificationGroups: notificationGroups, - unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, - isUpdateAvailable: state.updates.isUpdateAvailable, - availableUpdateVersion: state.updates.availableVersion + state: CanvasToolbarState( + notificationGroups: notificationGroups, + unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, + runScriptEnabled: runScriptEnabled, + runScriptIsRunning: runScriptIsRunning, + customCommands: customCommands, + isUpdateAvailable: state.updates.isUpdateAvailable, + availableUpdateVersion: state.updates.availableVersion + ) ) } else if hasActiveTerminalTarget, let toolbarState = toolbarState( @@ -97,7 +114,7 @@ struct WorktreeDetailView: View { toolbarState: toolbarState, repositories: repositories, selectedWorktree: selectedWorktree, - selectedTerminalWorktree: selectedTerminalWorktree, + actionTargetWorktree: actionTargetWorktree, notificationGroups: notificationGroups ) } @@ -117,7 +134,7 @@ struct WorktreeDetailView: View { toolbarState: WorktreeToolbarState, repositories: RepositoriesFeature.State, selectedWorktree: Worktree?, - selectedTerminalWorktree: Worktree?, + actionTargetWorktree: Worktree?, notificationGroups: [ToolbarNotificationRepositoryGroup] ) -> some ToolbarContent { WorktreeToolbarContent( @@ -140,9 +157,9 @@ struct WorktreeDetailView: View { store.send(.openActionSelectionChanged(action)) }, onCopyPath: { - guard let selectedTerminalWorktree else { return } + guard let actionTargetWorktree else { return } NSPasteboard.general.clearContents() - NSPasteboard.general.setString(selectedTerminalWorktree.workingDirectory.path, forType: .string) + NSPasteboard.general.setString(actionTargetWorktree.workingDirectory.path, forType: .string) }, onSelectNotification: selectToolbarNotification, onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, @@ -157,23 +174,68 @@ struct WorktreeDetailView: View { @ToolbarContentBuilder private func canvasToolbarContent( - notificationGroups: [ToolbarNotificationRepositoryGroup], - unseenNotificationWorktreeCount: Int, - isUpdateAvailable: Bool, - availableUpdateVersion: String? + state: CanvasToolbarState ) -> some ToolbarContent { ToolbarItemGroup(placement: .primaryAction) { ToolbarNotificationsPopoverButton( - groups: notificationGroups, - unseenWorktreeCount: unseenNotificationWorktreeCount, + groups: state.notificationGroups, + unseenWorktreeCount: state.unseenNotificationWorktreeCount, onSelectNotification: selectToolbarNotification, - onDismissAll: { dismissAllToolbarNotifications(in: notificationGroups) } + onDismissAll: { dismissAllToolbarNotifications(in: state.notificationGroups) } ) - if isUpdateAvailable { - ToolbarUpdateButton(availableVersion: availableUpdateVersion) { + if state.isUpdateAvailable { + ToolbarUpdateButton(availableVersion: state.availableUpdateVersion) { store.send(.updates(.checkForUpdates)) } } + if state.runScriptIsRunning || state.runScriptEnabled { + RunScriptToolbarButton( + isRunning: state.runScriptIsRunning, + isEnabled: state.runScriptEnabled, + runHelpText: AppShortcuts.helpText( + title: "Run Script", + commandID: AppShortcuts.CommandID.runScript, + in: store.resolvedKeybindings + ), + stopHelpText: AppShortcuts.helpText( + title: "Stop Script", + commandID: AppShortcuts.CommandID.stopScript, + in: store.resolvedKeybindings + ), + runShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.runScript), + stopShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.stopScript), + runAction: { store.send(.runScript) }, + stopAction: { store.send(.stopRunScript) } + ) + } + ForEach(Array(state.customCommands.enumerated()).prefix(3), id: \.element.id) { index, command in + UserCustomCommandToolbarButton( + title: command.resolvedTitle, + systemImage: command.resolvedSystemImage, + shortcut: store.resolvedKeybindings.display( + for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + ), + isEnabled: command.hasRunnableCommand, + action: { + store.send(.runCustomCommand(index)) + } + ) + } + if state.customCommands.count > 3 { + CustomCommandOverflowButton( + entries: Array(state.customCommands.enumerated().dropFirst(3)).map { + (index: $0.offset, command: $0.element) + }, + shortcutDisplay: { command in + store.resolvedKeybindings.display( + for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + ) + }, + onRunCustomCommand: { index in + store.send(.runCustomCommand(index)) + } + ) + } } } @@ -242,6 +304,30 @@ struct WorktreeDetailView: View { && selectedWorktreeSummaries.count > 1 } + private func canvasFocusedTerminalWorktree(repositories: RepositoriesFeature.State) -> Worktree? { + guard repositories.isShowingCanvas, + let worktreeID = terminalManager.canvasFocusedWorktreeID + else { + return nil + } + if let worktree = repositories.worktree(for: worktreeID) { + return worktree + } + guard let repository = repositories.repositories[id: worktreeID], + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + else { + return nil + } + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + @ViewBuilder private func detailContent( repositories: RepositoriesFeature.State, @@ -256,6 +342,9 @@ struct WorktreeDetailView: View { repositoryCustomTitles: repositories.repositoryCustomTitles, onExitToTab: { store.send(.repositories(.toggleCanvas)) + }, + onFocusedWorktreeChanged: { worktreeID in + store.send(.canvasFocusedWorktreeChanged(worktreeID)) } ) // Canvas tints the nav (leading) only; the toolbar is left untinted so diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 5c80b646..9b7830cc 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import DependenciesTestSupport import Foundation +import Sharing import Testing @testable import supacode @@ -426,6 +427,97 @@ struct AppFeatureCustomCommandTests { } } + @Test(.dependencies) func customCommandUsesCanvasFocusedWorktree() async { + let worktree = makeWorktree() + let sent = LockIsolated<[TerminalClient.Command]>([]) + var repositories = makeRepositoriesState(worktree: worktree) + repositories.selection = .canvas + var state = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + state.selectedCustomCommands = [ + UserCustomCommand( + title: "Canvas Build", + systemImage: "hammer", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + ] + + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.runCustomCommand(0)) + await store.finish() + + #expect( + sent.value == [ + .createTabWithInput( + worktree, + input: "make build", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Canvas Build", + customCommandIcon: "hammer" + ) + ] + ) + } + + @Test(.dependencies) func canvasFocusLoadsFocusedWorktreeCustomCommands() async { + let worktree = makeWorktree() + var repositories = makeRepositoriesState(worktree: worktree) + repositories.selection = .canvas + let settings = UserRepositorySettings( + customCommands: [ + UserCustomCommand( + title: "Canvas Test", + systemImage: "checkmark.circle", + command: "swift test", + execution: .terminalInput, + shortcut: nil + ) + ] + ) + let store = withDependencies { + $0.defaultFileStorage = .inMemory + } operation: { + @Shared(.userRepositorySettings(worktree.repositoryRootURL)) var userRepositorySettings + $userRepositorySettings.withLock { $0 = settings } + return TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + } + } + + await store.send(.canvasFocusedWorktreeChanged(worktree.id)) + await store.receive(\.worktreeSettingsLoaded) + await store.receive(\.worktreeUserSettingsLoaded) { + $0.selectedCustomCommands = settings.customCommands + $0.resolvedKeybindings = KeybindingResolver.resolve( + schema: .appResolverSchema(customCommands: settings.customCommands), + migratedOverrides: + LegacyCustomCommandShortcutMigration + .migrate(commands: settings.customCommands) + .overrides + ) + } + } + private func makeWorktree() -> Worktree { Worktree( id: "/tmp/repo/wt-1", diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 59103e98..5bd396a5 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -98,6 +98,31 @@ struct AppFeatureRunScriptTests { #expect(store.state.isRunScriptPromptPresented) } + @Test(.dependencies) func runScriptUsesCanvasFocusedWorktree() async { + let worktree = makeWorktree() + var repositories = makeRepositoriesState(worktree: worktree) + repositories.selection = .canvas + let sent = LockIsolated<[TerminalClient.Command]>([]) + var state = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + state.selectedRunScript = "npm test" + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.runScript) + await store.finish() + + #expect(sent.value == [.runScript(worktree, script: "npm test")]) + } + private func makeWorktree() -> Worktree { Worktree( id: "/tmp/repo/wt-1", diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 86978a49..4f141cb8 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -85,6 +85,23 @@ struct CommandPaletteFeatureTests { #expect(!ids.contains("global.stop-run-script")) } + @Test func commandPaletteItems_includesRunScriptForCanvasActionTarget() { + let rootPath = "/tmp/repo-canvas-run" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .canvas + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + actionTargetWorktreeID: worktree.id + ) + let ids = Set(items.map(\.id)) + #expect(ids.contains("global.run-script")) + #expect(!ids.contains("global.rename-branch")) + #expect(!ids.contains("global.toggle-pin-worktree")) + } + @Test func commandPaletteItems_includesStopScriptWhenRunning() { let rootPath = "/tmp/repo-run" let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) -- 2.51.2 From 057dec1cd9b09caa2f9925423959b30ac1b798da Mon Sep 17 00:00:00 2001 From: vince Date: Wed, 27 May 2026 12:13:37 +0800 Subject: [PATCH 222/528] Add canvas multi-repo custom action test --- .../AppFeatureCustomCommandTests.swift | 124 ++++++++++++++++-- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 9b7830cc..400ab80e 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import DependenciesTestSupport import Foundation +import IdentifiedCollections import Sharing import Testing @@ -518,26 +519,127 @@ struct AppFeatureCustomCommandTests { } } + @Test(.dependencies) func canvasFocusWithMultipleReposUsesPrimaryFocusedWorktree() async { + let worktreeA = makeWorktree(id: "/tmp/repo-a/wt-a", name: "wt-a", repoRoot: "/tmp/repo-a") + let worktreeB = makeWorktree(id: "/tmp/repo-b/wt-b", name: "wt-b", repoRoot: "/tmp/repo-b") + let commandA = UserCustomCommand( + title: "Repo A Build", + systemImage: "a.circle", + command: "make repo-a", + execution: .shellScript, + shortcut: nil + ) + let commandB = UserCustomCommand( + title: "Repo B Build", + systemImage: "b.circle", + command: "make repo-b", + execution: .shellScript, + shortcut: nil + ) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = withDependencies { + $0.defaultFileStorage = .inMemory + } operation: { + @Shared(.repositorySettings(worktreeA.repositoryRootURL)) var repositorySettingsA + @Shared(.repositorySettings(worktreeB.repositoryRootURL)) var repositorySettingsB + @Shared(.userRepositorySettings(worktreeA.repositoryRootURL)) var userRepositorySettingsA + @Shared(.userRepositorySettings(worktreeB.repositoryRootURL)) var userRepositorySettingsB + $repositorySettingsA.withLock { $0.runScript = "npm run repo-a" } + $repositorySettingsB.withLock { $0.runScript = "npm run repo-b" } + $userRepositorySettingsA.withLock { $0.customCommands = [commandA] } + $userRepositorySettingsB.withLock { $0.customCommands = [commandB] } + + var repositories = makeRepositoriesState(worktrees: [worktreeA, worktreeB]) + repositories.selection = .canvas + var state = AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + state.selectedRunScript = "npm run repo-a" + state.selectedCustomCommands = [commandA] + + return TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktreeB.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + } + + await store.send(.canvasFocusedWorktreeChanged(worktreeB.id)) + await store.receive(\.worktreeSettingsLoaded) { + $0.selectedRunScript = "npm run repo-b" + } + await store.receive(\.worktreeUserSettingsLoaded) { + $0.selectedCustomCommands = [commandB] + $0.resolvedKeybindings = KeybindingResolver.resolve( + schema: .appResolverSchema(customCommands: [commandB]), + migratedOverrides: + LegacyCustomCommandShortcutMigration + .migrate(commands: [commandB]) + .overrides + ) + } + + await store.send(.runScript) + await store.send(.runCustomCommand(0)) + await store.finish() + + #expect( + sent.value == [ + .runScript(worktreeB, script: "npm run repo-b"), + .createTabWithInput( + worktreeB, + input: "make repo-b", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Repo B Build", + customCommandIcon: "b.circle" + ), + ] + ) + } + private func makeWorktree() -> Worktree { + makeWorktree(id: "/tmp/repo/wt-1", name: "wt-1", repoRoot: "/tmp/repo") + } + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { Worktree( - id: "/tmp/repo/wt-1", - name: "wt-1", + id: id, + name: name, detail: "detail", - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) ) } private func makeRepositoriesState(worktree: Worktree) -> RepositoriesFeature.State { - let repository = Repository( - id: "/tmp/repo", - rootURL: URL(fileURLWithPath: "/tmp/repo"), - name: "repo", - worktrees: [worktree] + makeRepositoriesState(worktrees: [worktree]) + } + + private func makeRepositoriesState(worktrees: [Worktree]) -> RepositoriesFeature.State { + let worktreesByRepository = Dictionary( + grouping: worktrees, + by: { $0.repositoryRootURL.path(percentEncoded: false) } ) + let repositories = + worktreesByRepository.keys.sorted().compactMap { rootPath in + worktreesByRepository[rootPath].map { worktrees in + let rootURL = URL(fileURLWithPath: rootPath) + return Repository( + id: rootPath, + rootURL: rootURL, + name: rootURL.lastPathComponent, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } + } var repositoriesState = RepositoriesFeature.State() - repositoriesState.repositories = [repository] - repositoriesState.selection = .worktree(worktree.id) + repositoriesState.repositories = IdentifiedArray(uniqueElements: repositories) + repositoriesState.selection = worktrees.first.map { .worktree($0.id) } return repositoriesState } } -- 2.51.2 From 6af00bcbb22ad14278a5d066fa1b73d1562d9be1 Mon Sep 17 00:00:00 2001 From: vince Date: Wed, 27 May 2026 14:09:13 +0800 Subject: [PATCH 223/528] Fix canvas custom action test expectations --- supacodeTests/AppFeatureCustomCommandTests.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 400ab80e..0e181ff2 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -506,7 +506,9 @@ struct AppFeatureCustomCommandTests { } await store.send(.canvasFocusedWorktreeChanged(worktree.id)) - await store.receive(\.worktreeSettingsLoaded) + await store.receive(\.worktreeSettingsLoaded) { + $0.openActionSelection = expectedDefaultOpenAction() + } await store.receive(\.worktreeUserSettingsLoaded) { $0.selectedCustomCommands = settings.customCommands $0.resolvedKeybindings = KeybindingResolver.resolve( @@ -570,6 +572,7 @@ struct AppFeatureCustomCommandTests { await store.send(.canvasFocusedWorktreeChanged(worktreeB.id)) await store.receive(\.worktreeSettingsLoaded) { + $0.openActionSelection = expectedDefaultOpenAction() $0.selectedRunScript = "npm run repo-b" } await store.receive(\.worktreeUserSettingsLoaded) { @@ -642,4 +645,13 @@ struct AppFeatureCustomCommandTests { repositoriesState.selection = worktrees.first.map { .worktree($0.id) } return repositoriesState } + + private func expectedDefaultOpenAction() -> OpenWorktreeAction { + OpenWorktreeAction.fromSettingsID( + RepositorySettings.default.openActionID, + defaultEditorID: OpenWorktreeAction.normalizedDefaultEditorID( + SettingsFile.default.global.defaultEditorID + ) + ) + } } -- 2.51.2 From adf09427b88d5df5f8641839c23a65288de89edb Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 21:03:39 +0900 Subject: [PATCH 224/528] Remove redundant Divider in Window Tint settings section --- supacode/Features/Settings/Views/AppearanceSettingsView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 7a30d77b..3b1a23c7 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -65,8 +65,6 @@ struct AppearanceSettingsView: View { .font(.callout) .foregroundStyle(.secondary) - Divider() - Picker("Tint spines in Shelf View", selection: $store.shelfSpineTintFallback) { ForEach(ShelfSpineTintFallback.allCases) { fallback in Text(fallback.title).tag(fallback) -- 2.51.2 From da5b8b61400f0a7ecd9a883148deb71346b22e55 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 21:16:16 +0900 Subject: [PATCH 225/528] Refine Shelf spine tint: accurate help copy, single surface resolver, fuller tests --- supacode/Domain/WindowChromeTint.swift | 30 +++----- .../Views/AppearanceSettingsView.swift | 2 +- .../Features/Shelf/Views/ShelfSpineView.swift | 10 +-- .../SettingsFilePersistenceTests.swift | 26 +++++++ supacodeTests/WindowChromeTintTests.swift | 75 ++++++++++--------- 5 files changed, 77 insertions(+), 66 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 98dbf727..8c0f68b0 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -70,31 +70,21 @@ enum WindowChromeTint { color == nil ? neutralPeakAlpha : saturatedPeakAlpha } - /// Base hue for a Shelf spine surface. When `followsRepositoryColor` is - /// true, a pinned repo color wins; otherwise every spine uses the user's - /// fallback style. - static func shelfSpineBase( + /// Surface hue and peak alpha for a Shelf spine. When + /// `followsRepositoryColor` is true, a pinned repo color wins (saturated + /// peak); otherwise every spine uses the user's fallback style — `.neutral` + /// stays gentler, `.systemTint` uses the saturated shelf/chrome peak. Base + /// and alpha share one branch so they can never disagree. + static func shelfSpineSurface( for color: RepositoryColorChoice?, fallback: ShelfSpineTintFallback, followsRepositoryColor: Bool - ) -> Color { + ) -> (base: Color, peakAlpha: Double) { if followsRepositoryColor, let color { - return color.color + return (color.color, saturatedPeakAlpha) } - return shelfSpineFallbackBase(fallback) - } - - /// Peak alpha for a Shelf spine surface. Neutral fallbacks stay gentler; - /// pinned repo colors and system tint use the saturated shelf/chrome peak. - static func shelfSpinePeakAlpha( - for color: RepositoryColorChoice?, - fallback: ShelfSpineTintFallback, - followsRepositoryColor: Bool - ) -> Double { - if followsRepositoryColor, color != nil { - return saturatedPeakAlpha - } - return fallback == .neutral ? neutralPeakAlpha : saturatedPeakAlpha + let peakAlpha = fallback == .neutral ? neutralPeakAlpha : saturatedPeakAlpha + return (shelfSpineFallbackBase(fallback), peakAlpha) } private static func shelfSpineFallbackBase(_ fallback: ShelfSpineTintFallback) -> Color { diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 3b1a23c7..cb5b3fb0 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -70,7 +70,7 @@ struct AppearanceSettingsView: View { Text(fallback.title).tag(fallback) } } - .help("Choose how Shelf spines are tinted when no repository color is available.") + .help("Spine style for repositories without a color, or for every spine when Follow Repo Color is off.") Toggle( "Follow Repo Color Setting", isOn: $store.shelfSpineTintFollowsRepositoryColor diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index ba115b8f..bc5da216 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -135,18 +135,12 @@ struct ShelfSpineView: View { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return WindowChromeTint.shelfSpineBase( + let surface = WindowChromeTint.shelfSpineSurface( for: appearance.color, fallback: tintFallback, followsRepositoryColor: followsRepositoryColor ) - .opacity( - WindowChromeTint.shelfSpinePeakAlpha( - for: appearance.color, - fallback: tintFallback, - followsRepositoryColor: followsRepositoryColor - ) * multiplier - ) + return surface.base.opacity(surface.peakAlpha * multiplier) } /// Tint used as the header icon color and the active-tab highlight. It diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index d4672e89..6858dbc6 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -55,6 +55,30 @@ struct SettingsFilePersistenceTests { #expect(reloaded.pinnedWorktreeIDs == ["/tmp/repo-a/wt-1"]) } + @Test(.dependencies) func saveAndReloadShelfSpineTintPreferences() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { + $0.global.shelfSpineTintFallback = .systemTint + $0.global.shelfSpineTintFollowsRepositoryColor = false + } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(reloaded.global.shelfSpineTintFallback == .systemTint) + #expect(reloaded.global.shelfSpineTintFollowsRepositoryColor == false) + } + @Test(.dependencies) func invalidJSONResetsToDefaults() throws { let storage = MutableTestStorage(initialData: Data("{".utf8)) @@ -113,6 +137,8 @@ struct SettingsFilePersistenceTests { #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) #expect(settings.global.restoreTerminalLayoutOnLaunch == false) #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) + #expect(settings.global.shelfSpineTintFallback == .neutral) + #expect(settings.global.shelfSpineTintFollowsRepositoryColor == true) #expect(settings.repositoryRoots.isEmpty) #expect(settings.pinnedWorktreeIDs.isEmpty) } diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index d861a4b5..25b336fe 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -46,56 +46,57 @@ struct WindowChromeTintTests { @Test func shelfSpineUsesNeutralFallbackByDefaultForUncoloredRepositories() { - #expect( - WindowChromeTint.shelfSpineBase( - for: nil, - fallback: .neutral, - followsRepositoryColor: true - ) == .primary - ) - #expect( - WindowChromeTint.shelfSpinePeakAlpha( - for: nil, - fallback: .neutral, - followsRepositoryColor: true - ) == WindowChromeTint.neutralPeakAlpha + let surface = WindowChromeTint.shelfSpineSurface( + for: nil, + fallback: .neutral, + followsRepositoryColor: true ) + #expect(surface.base == .primary) + #expect(surface.peakAlpha == WindowChromeTint.neutralPeakAlpha) } @Test func shelfSpineCanUseSystemTintFallbackForUncoloredRepositories() { - #expect( - WindowChromeTint.shelfSpineBase( - for: nil, - fallback: .systemTint, - followsRepositoryColor: true - ) == .accentColor + let surface = WindowChromeTint.shelfSpineSurface( + for: nil, + fallback: .systemTint, + followsRepositoryColor: true ) - #expect( - WindowChromeTint.shelfSpinePeakAlpha( - for: nil, - fallback: .systemTint, - followsRepositoryColor: true - ) == WindowChromeTint.saturatedPeakAlpha + #expect(surface.base == .accentColor) + #expect(surface.peakAlpha == WindowChromeTint.saturatedPeakAlpha) + } + + @Test + func shelfSpineFollowsRepositoryColorAtSaturatedAlpha() { + // A pinned color wins regardless of the fallback when following is on. + let surface = WindowChromeTint.shelfSpineSurface( + for: .green, + fallback: .neutral, + followsRepositoryColor: true ) + #expect(surface.base == .green) + #expect(surface.peakAlpha == WindowChromeTint.saturatedPeakAlpha) } @Test func shelfSpineCanIgnoreRepositoryColors() { - #expect( - WindowChromeTint.shelfSpineBase( - for: .green, - fallback: .neutral, - followsRepositoryColor: false - ) == .primary + // With following off, the fallback style drives both base and alpha even + // for a colored repo. + let neutral = WindowChromeTint.shelfSpineSurface( + for: .green, + fallback: .neutral, + followsRepositoryColor: false ) - #expect( - WindowChromeTint.shelfSpineBase( - for: .green, - fallback: .systemTint, - followsRepositoryColor: false - ) == .accentColor + #expect(neutral.base == .primary) + #expect(neutral.peakAlpha == WindowChromeTint.neutralPeakAlpha) + + let systemTint = WindowChromeTint.shelfSpineSurface( + for: .green, + fallback: .systemTint, + followsRepositoryColor: false ) + #expect(systemTint.base == .accentColor) + #expect(systemTint.peakAlpha == WindowChromeTint.saturatedPeakAlpha) } // MARK: - Toolbar background resolution -- 2.51.2 From f596d2009ad3ce5941cc96a3f6b05cb2e8ac880e Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 22:04:41 +0900 Subject: [PATCH 226/528] Refine PR #340: toolbar grouping, injected DockClient, decode cleanup, tests - Fix toolbar spacer so Run + Custom Commands stay one group and keep their leading separator when the Run button is hidden. - Route Dock badge/bounce through an injected DockClient (testable, matches the existing dependency pattern); render the badge as a bare dot instead of a glyph. - Replace the function_body_length lint disable in GlobalSettings.init(from:) by extracting a ToolbarAndDockSettings struct and a mergedWorktreeAction decode helper. - Add DockClient reducer tests and GlobalSettings persistence/migration tests. - Add a DEBUG command-palette item to light the Dock notification dot. --- supacode/Clients/Dock/DockClient.swift | 44 ++++++ .../Features/App/Reducer/AppFeature.swift | 34 ++--- .../CommandPalette/CommandPaletteItem.swift | 3 +- .../Reducer/CommandPaletteFeature.swift | 13 +- .../Views/CommandPaletteOverlayView.swift | 8 +- .../Views/WorktreeDetailView.swift | 22 ++- .../Settings/Models/GlobalSettings.swift | 75 +++++++--- supacodeTests/AppFeatureDockTests.swift | 138 ++++++++++++++++++ .../SettingsFilePersistenceTests.swift | 32 ++++ 9 files changed, 309 insertions(+), 60 deletions(-) create mode 100644 supacode/Clients/Dock/DockClient.swift create mode 100644 supacodeTests/AppFeatureDockTests.swift diff --git a/supacode/Clients/Dock/DockClient.swift b/supacode/Clients/Dock/DockClient.swift new file mode 100644 index 00000000..67bd10e8 --- /dev/null +++ b/supacode/Clients/Dock/DockClient.swift @@ -0,0 +1,44 @@ +import AppKit +import ComposableArchitecture + +/// Side effects targeting the app's Dock tile: the pending-notification badge +/// and the attention bounce. Wrapping these in a dependency keeps the reducer +/// testable and matches how the other AppKit side effects are injected. +struct DockClient { + /// Show (`true`) or clear (`false`) the notification badge on the Dock tile. + var setNotificationBadge: @MainActor @Sendable (_ isVisible: Bool) -> Void + /// Bounce the Dock icon according to the configured mode. `.off` is a no-op. + var bounce: @MainActor @Sendable (_ mode: DockBounceMode) -> Void +} + +extension DockClient: DependencyKey { + static let liveValue = DockClient( + setNotificationBadge: { isVisible in + // An empty (but non-nil) label renders the bare red badge dot; `nil` + // hides it. A glyph inside the pill would just look redundant. + NSApplication.shared.dockTile.badgeLabel = isVisible ? "" : nil + }, + bounce: { mode in + switch mode { + case .off: + break + case .once: + _ = NSApp.requestUserAttention(.informationalRequest) + case .continuous: + _ = NSApp.requestUserAttention(.criticalRequest) + } + } + ) + + static let testValue = DockClient( + setNotificationBadge: { _ in }, + bounce: { _ in } + ) +} + +extension DependencyValues { + var dockClient: DockClient { + get { self[DockClient.self] } + set { self[DockClient.self] = newValue } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index cfd2fb4c..5b58451f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -119,6 +119,7 @@ struct AppFeature { @Dependency(AppLifecycleClient.self) private var appLifecycleClient @Dependency(NotificationSoundClient.self) private var notificationSoundClient @Dependency(SystemNotificationClient.self) private var systemNotificationClient + @Dependency(DockClient.self) private var dockClient @Dependency(TerminalClient.self) private var terminalClient @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient @@ -236,9 +237,7 @@ struct AppFeature { await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) }, .run { _ in - await MainActor.run { - NSApplication.shared.dockTile.badgeLabel = nil - } + await dockClient.setNotificationBadge(false) }, .run { send in for await event in await terminalClient.events() { @@ -573,9 +572,7 @@ struct AppFeature { } }, .run { _ in - await MainActor.run { - NSApplication.shared.dockTile.badgeLabel = showDot ? "●" : nil - } + await dockClient.setNotificationBadge(showDot) } ) @@ -1173,6 +1170,9 @@ struct AppFeature { case .commandPalette(.delegate(.debugSimulateUpdateFound)): return .send(.updates(.debugSimulateUpdateFound)) + + case .commandPalette(.delegate(.debugLightDockNotificationDot)): + return .run { _ in await dockClient.setNotificationBadge(true) } #endif case .commandPalette: @@ -1200,23 +1200,11 @@ struct AppFeature { } ) } - switch state.settings.dockBounceMode { - case .off: - break - case .once: + let bounceMode = state.settings.dockBounceMode + if bounceMode != .off { effects.append( .run { _ in - await MainActor.run { - _ = NSApp.requestUserAttention(.informationalRequest) - } - } - ) - case .continuous: - effects.append( - .run { _ in - await MainActor.run { - _ = NSApp.requestUserAttention(.criticalRequest) - } + await dockClient.bounce(bounceMode) } ) } @@ -1226,9 +1214,7 @@ struct AppFeature { state.notificationIndicatorCount = count let showDot = state.settings.showNotificationDotOnDock && count > 0 return .run { _ in - await MainActor.run { - NSApplication.shared.dockTile.badgeLabel = showDot ? "●" : nil - } + await dockClient.setNotificationBadge(showDot) } case .terminalEvent(.runScriptStatusChanged(let worktreeID, let isRunning)): diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 8c397394..dc178ecc 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -79,6 +79,7 @@ struct CommandPaletteItem: Identifiable, Equatable { #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound + case debugLightDockNotificationDot #endif } @@ -150,7 +151,7 @@ struct CommandPaletteItem: Identifiable, Equatable { .runCustomCommand: return nil #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return nil #endif } diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6993ba2e..0086ddd1 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -68,6 +68,7 @@ struct CommandPaletteFeature { #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound + case debugLightDockNotificationDot #endif } @@ -815,6 +816,14 @@ private func makeClosePullRequestItem( category: .debug, defaultSuggestion: true ), + CommandPaletteItem( + id: "debug.dock.notification-dot", + title: "[Debug] Light Dock Notification Dot", + subtitle: "Forces the Dock notification badge on for visual testing", + kind: .debugLightDockNotificationDot, + category: .debug, + defaultSuggestion: true + ), ] } #endif @@ -981,6 +990,8 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette return .debugTestToast(toast) case .debugSimulateUpdateFound: return .debugSimulateUpdateFound + case .debugLightDockNotificationDot: + return .debugLightDockNotificationDot #endif case .checkForUpdates, .openSettings, @@ -1119,7 +1130,7 @@ private func pullRequestDelegateAction( .runCustomCommand: return nil #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return nil #endif } diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index bf288ae2..0e26fdab 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -534,7 +534,7 @@ private struct CommandPaletteRowView: View { case .deleteWorktree: return "Delete" #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return "Debug" #endif } @@ -615,6 +615,8 @@ private struct CommandPaletteRowView: View { return "ladybug" case .debugSimulateUpdateFound: return "ladybug" + case .debugLightDockNotificationDot: + return "ladybug" #endif } } @@ -636,7 +638,7 @@ private struct CommandPaletteRowView: View { case .worktreeSelect: return false #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return true #endif } @@ -777,7 +779,7 @@ private struct CommandPaletteRowView: View { case .runCustomCommand: base = "Run Custom Command: \(row.title)" #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: base = row.title #endif } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index d4cdb0ec..fbf0a508 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -699,10 +699,22 @@ struct WorktreeDetailView: View { @ToolbarContentBuilder private var commandToolbarItems: some ToolbarContent { - if toolbarState.showRunButtonInToolbar, - toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled - { + let showRunButton = + toolbarState.showRunButtonInToolbar + && (toolbarState.runScriptIsRunning || toolbarState.runScriptEnabled) + let entries = customCommandEntries + let inlineEntries = Array(entries.prefix(3)) + let overflowEntries = Array(entries.dropFirst(3)) + + // One fixed separator in front of the whole Run + Custom Command cluster + // keeps it distinct from the Open Editor / notification groups no matter + // which items are hidden. Run and the custom commands share one group (no + // spacer between them), matching the grouping before the toolbar toggles. + if showRunButton || !inlineEntries.isEmpty || !overflowEntries.isEmpty { ToolbarSpacer(.fixed) + } + + if showRunButton { ToolbarItem { RunScriptToolbarButton( isRunning: toolbarState.runScriptIsRunning, @@ -725,10 +737,6 @@ struct WorktreeDetailView: View { } } - let entries = customCommandEntries - let inlineEntries = Array(entries.prefix(3)) - let overflowEntries = Array(entries.dropFirst(3)) - if !inlineEntries.isEmpty { ToolbarItemGroup { ForEach(inlineEntries, id: \.command.id) { entry in diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index e4047529..c2988ccf 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -232,7 +232,6 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case automaticallyArchiveMergedWorktrees } - // swiftlint:disable:next function_body_length init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) appearanceMode = try container.decode(AppearanceMode.self, forKey: .appearanceMode) @@ -277,15 +276,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { deleteBranchOnDeleteWorktree = try container.decodeIfPresent(Bool.self, forKey: .deleteBranchOnDeleteWorktree) ?? Self.default.deleteBranchOnDeleteWorktree - if let decoded = try container.decodeIfPresent(MergedWorktreeAction.self, forKey: .mergedWorktreeAction) { - mergedWorktreeAction = decoded - } else if let legacyBool = try container.decodeIfPresent( - Bool.self, forKey: .automaticallyArchiveMergedWorktrees - ) { - mergedWorktreeAction = legacyBool ? .archive : nil - } else { - mergedWorktreeAction = Self.default.mergedWorktreeAction - } + mergedWorktreeAction = try Self.decodeMergedWorktreeAction(from: container) promptForWorktreeCreation = try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) ?? Self.default.promptForWorktreeCreation @@ -327,23 +318,59 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoShowActiveAgentsPanel = try container.decodeIfPresent(Bool.self, forKey: .autoShowActiveAgentsPanel) ?? Self.default.autoShowActiveAgentsPanel - windowTintMode = + (windowTintMode, windowTintCustomColor) = try Self.decodeWindowTint(from: container) + let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) + showRunButtonInToolbar = toolbarAndDock.showRunButtonInToolbar + showDefaultEditorInToolbar = toolbarAndDock.showDefaultEditorInToolbar + dockBounceMode = toolbarAndDock.dockBounceMode + showNotificationDotOnDock = toolbarAndDock.showNotificationDotOnDock + } + + private static func decodeWindowTint( + from container: KeyedDecodingContainer + ) throws -> (WindowTintMode, TintColor) { + let mode = try container.decodeIfPresent(WindowTintMode.self, forKey: .windowTintMode) ?? Self.default.windowTintMode - windowTintCustomColor = + let customColor = try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) ?? Self.default.windowTintCustomColor - showRunButtonInToolbar = - try container.decodeIfPresent(Bool.self, forKey: .showRunButtonInToolbar) - ?? Self.default.showRunButtonInToolbar - showDefaultEditorInToolbar = - try container.decodeIfPresent(Bool.self, forKey: .showDefaultEditorInToolbar) - ?? Self.default.showDefaultEditorInToolbar - dockBounceMode = - try container.decodeIfPresent(DockBounceMode.self, forKey: .dockBounceMode) - ?? Self.default.dockBounceMode - showNotificationDotOnDock = - try container.decodeIfPresent(Bool.self, forKey: .showNotificationDotOnDock) - ?? Self.default.showNotificationDotOnDock + return (mode, customColor) + } + + private static func decodeMergedWorktreeAction( + from container: KeyedDecodingContainer + ) throws -> MergedWorktreeAction? { + if let decoded = try container.decodeIfPresent(MergedWorktreeAction.self, forKey: .mergedWorktreeAction) { + return decoded + } + if let legacyBool = try container.decodeIfPresent(Bool.self, forKey: .automaticallyArchiveMergedWorktrees) { + return legacyBool ? .archive : nil + } + return Self.default.mergedWorktreeAction + } + + /// The toolbar-visibility and Dock-notification preferences, decoded as a + /// unit so `init(from:)` stays within the body-length limit. + private struct ToolbarAndDockSettings { + let showRunButtonInToolbar: Bool + let showDefaultEditorInToolbar: Bool + let dockBounceMode: DockBounceMode + let showNotificationDotOnDock: Bool + } + + private static func decodeToolbarAndDockSettings( + from container: KeyedDecodingContainer + ) throws -> ToolbarAndDockSettings { + try ToolbarAndDockSettings( + showRunButtonInToolbar: container.decodeIfPresent(Bool.self, forKey: .showRunButtonInToolbar) + ?? Self.default.showRunButtonInToolbar, + showDefaultEditorInToolbar: container.decodeIfPresent(Bool.self, forKey: .showDefaultEditorInToolbar) + ?? Self.default.showDefaultEditorInToolbar, + dockBounceMode: container.decodeIfPresent(DockBounceMode.self, forKey: .dockBounceMode) + ?? Self.default.dockBounceMode, + showNotificationDotOnDock: container.decodeIfPresent(Bool.self, forKey: .showNotificationDotOnDock) + ?? Self.default.showNotificationDotOnDock + ) } } diff --git a/supacodeTests/AppFeatureDockTests.swift b/supacodeTests/AppFeatureDockTests.swift new file mode 100644 index 00000000..bcca7e3d --- /dev/null +++ b/supacodeTests/AppFeatureDockTests.swift @@ -0,0 +1,138 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureDockTests { + private func notificationReceived() -> AppFeature.Action { + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded" + ) + ) + } + + @Test(.dependencies) func notificationReceivedBouncesOnceWhenModeIsOnce() async { + var globalSettings = GlobalSettings.default + globalSettings.dockBounceMode = .once + let bounces = LockIsolated<[DockBounceMode]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.bounce = { mode in bounces.withValue { $0.append(mode) } } + } + store.exhaustivity = .off + + await store.send(notificationReceived()) + await store.finish() + + #expect(bounces.value == [.once]) + } + + @Test(.dependencies) func notificationReceivedBouncesContinuouslyWhenModeIsContinuous() async { + var globalSettings = GlobalSettings.default + globalSettings.dockBounceMode = .continuous + let bounces = LockIsolated<[DockBounceMode]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.bounce = { mode in bounces.withValue { $0.append(mode) } } + } + store.exhaustivity = .off + + await store.send(notificationReceived()) + await store.finish() + + #expect(bounces.value == [.continuous]) + } + + @Test(.dependencies) func notificationReceivedDoesNotBounceWhenModeIsOff() async { + // `.off` is the default; no bounce effect should be scheduled at all. + let globalSettings = GlobalSettings.default + let bounces = LockIsolated<[DockBounceMode]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.bounce = { mode in bounces.withValue { $0.append(mode) } } + } + store.exhaustivity = .off + + await store.send(notificationReceived()) + await store.finish() + + #expect(bounces.value.isEmpty) + } + + @Test(.dependencies) func indicatorChangeShowsBadgeWhenEnabledAndCountPositive() async { + var globalSettings = GlobalSettings.default + globalSettings.showNotificationDotOnDock = true + let badges = LockIsolated<[Bool]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.notificationIndicatorChanged(count: 2))) { + $0.notificationIndicatorCount = 2 + } + await store.finish() + + #expect(badges.value == [true]) + } + + @Test(.dependencies) func indicatorChangeClearsBadgeWhenDisabled() async { + var globalSettings = GlobalSettings.default + globalSettings.showNotificationDotOnDock = false + let badges = LockIsolated<[Bool]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.notificationIndicatorChanged(count: 3))) { + $0.notificationIndicatorCount = 3 + } + await store.finish() + + #expect(badges.value == [false]) + } + + @Test(.dependencies) func indicatorChangeClearsBadgeWhenCountZero() async { + var globalSettings = GlobalSettings.default + globalSettings.showNotificationDotOnDock = true + let badges = LockIsolated<[Bool]>([]) + let store = TestStore( + initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) + ) { + AppFeature() + } withDependencies: { + $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.notificationIndicatorChanged(count: 0))) + await store.finish() + + #expect(badges.value == [false]) + } +} diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index d4672e89..900714fc 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -55,6 +55,34 @@ struct SettingsFilePersistenceTests { #expect(reloaded.pinnedWorktreeIDs == ["/tmp/repo-a/wt-1"]) } + @Test(.dependencies) func saveAndReloadToolbarAndDockSettings() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { + $0.global.showRunButtonInToolbar = false + $0.global.showDefaultEditorInToolbar = false + $0.global.dockBounceMode = .continuous + $0.global.showNotificationDotOnDock = true + } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(reloaded.global.showRunButtonInToolbar == false) + #expect(reloaded.global.showDefaultEditorInToolbar == false) + #expect(reloaded.global.dockBounceMode == .continuous) + #expect(reloaded.global.showNotificationDotOnDock == true) + } + @Test(.dependencies) func invalidJSONResetsToDefaults() throws { let storage = MutableTestStorage(initialData: Data("{".utf8)) @@ -113,6 +141,10 @@ struct SettingsFilePersistenceTests { #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) #expect(settings.global.restoreTerminalLayoutOnLaunch == false) #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) + #expect(settings.global.showRunButtonInToolbar == true) + #expect(settings.global.showDefaultEditorInToolbar == true) + #expect(settings.global.dockBounceMode == .off) + #expect(settings.global.showNotificationDotOnDock == false) #expect(settings.repositoryRoots.isEmpty) #expect(settings.pinnedWorktreeIDs.isEmpty) } -- 2.51.2 From 19cb4367e86232e1e7bda9b1d0c8d3285fd2312d Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 22:43:44 +0900 Subject: [PATCH 227/528] Show unread count on Dock badge instead of empty label An empty badgeLabel renders no badge on macOS, so the dot never showed. Switch DockClient.setNotificationBadge to take a count and display the number of worktrees with unseen notifications (clearing at zero). Also update CommandPaletteFeatureTests for the new debug command case so the test target compiles. --- supacode/Clients/Dock/DockClient.swift | 11 +++++------ supacode/Features/App/Reducer/AppFeature.swift | 12 ++++++------ supacodeTests/AppFeatureDockTests.swift | 18 +++++++++--------- supacodeTests/CommandPaletteFeatureTests.swift | 5 +++-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/supacode/Clients/Dock/DockClient.swift b/supacode/Clients/Dock/DockClient.swift index 67bd10e8..41c354d1 100644 --- a/supacode/Clients/Dock/DockClient.swift +++ b/supacode/Clients/Dock/DockClient.swift @@ -5,18 +5,17 @@ import ComposableArchitecture /// and the attention bounce. Wrapping these in a dependency keeps the reducer /// testable and matches how the other AppKit side effects are injected. struct DockClient { - /// Show (`true`) or clear (`false`) the notification badge on the Dock tile. - var setNotificationBadge: @MainActor @Sendable (_ isVisible: Bool) -> Void + /// Set the Dock tile's notification badge to `count` unread items, or clear + /// it when `count` is zero. + var setNotificationBadge: @MainActor @Sendable (_ count: Int) -> Void /// Bounce the Dock icon according to the configured mode. `.off` is a no-op. var bounce: @MainActor @Sendable (_ mode: DockBounceMode) -> Void } extension DockClient: DependencyKey { static let liveValue = DockClient( - setNotificationBadge: { isVisible in - // An empty (but non-nil) label renders the bare red badge dot; `nil` - // hides it. A glyph inside the pill would just look redundant. - NSApplication.shared.dockTile.badgeLabel = isVisible ? "" : nil + setNotificationBadge: { count in + NSApplication.shared.dockTile.badgeLabel = count > 0 ? String(count) : nil }, bounce: { mode in switch mode { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 5b58451f..1d219ba3 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -237,7 +237,7 @@ struct AppFeature { await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) }, .run { _ in - await dockClient.setNotificationBadge(false) + await dockClient.setNotificationBadge(0) }, .run { send in for await event in await terminalClient.events() { @@ -498,7 +498,7 @@ struct AppFeature { settings: state.settings, customCommands: state.selectedCustomCommands ) - let showDot = settings.showNotificationDotOnDock && state.notificationIndicatorCount > 0 + let badgeCount = settings.showNotificationDotOnDock ? state.notificationIndicatorCount : 0 return .merge( .send(.repositories(.githubIntegration(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled)))), .send( @@ -572,7 +572,7 @@ struct AppFeature { } }, .run { _ in - await dockClient.setNotificationBadge(showDot) + await dockClient.setNotificationBadge(badgeCount) } ) @@ -1172,7 +1172,7 @@ struct AppFeature { return .send(.updates(.debugSimulateUpdateFound)) case .commandPalette(.delegate(.debugLightDockNotificationDot)): - return .run { _ in await dockClient.setNotificationBadge(true) } + return .run { _ in await dockClient.setNotificationBadge(1) } #endif case .commandPalette: @@ -1212,9 +1212,9 @@ struct AppFeature { case .terminalEvent(.notificationIndicatorChanged(let count)): state.notificationIndicatorCount = count - let showDot = state.settings.showNotificationDotOnDock && count > 0 + let badgeCount = state.settings.showNotificationDotOnDock ? count : 0 return .run { _ in - await dockClient.setNotificationBadge(showDot) + await dockClient.setNotificationBadge(badgeCount) } case .terminalEvent(.runScriptStatusChanged(let worktreeID, let isRunning)): diff --git a/supacodeTests/AppFeatureDockTests.swift b/supacodeTests/AppFeatureDockTests.swift index bcca7e3d..49e7c493 100644 --- a/supacodeTests/AppFeatureDockTests.swift +++ b/supacodeTests/AppFeatureDockTests.swift @@ -78,13 +78,13 @@ struct AppFeatureDockTests { @Test(.dependencies) func indicatorChangeShowsBadgeWhenEnabledAndCountPositive() async { var globalSettings = GlobalSettings.default globalSettings.showNotificationDotOnDock = true - let badges = LockIsolated<[Bool]>([]) + let badges = LockIsolated<[Int]>([]) let store = TestStore( initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) ) { AppFeature() } withDependencies: { - $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + $0.dockClient.setNotificationBadge = { count in badges.withValue { $0.append(count) } } } store.exhaustivity = .off @@ -93,19 +93,19 @@ struct AppFeatureDockTests { } await store.finish() - #expect(badges.value == [true]) + #expect(badges.value == [2]) } @Test(.dependencies) func indicatorChangeClearsBadgeWhenDisabled() async { var globalSettings = GlobalSettings.default globalSettings.showNotificationDotOnDock = false - let badges = LockIsolated<[Bool]>([]) + let badges = LockIsolated<[Int]>([]) let store = TestStore( initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) ) { AppFeature() } withDependencies: { - $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + $0.dockClient.setNotificationBadge = { count in badges.withValue { $0.append(count) } } } store.exhaustivity = .off @@ -114,25 +114,25 @@ struct AppFeatureDockTests { } await store.finish() - #expect(badges.value == [false]) + #expect(badges.value == [0]) } @Test(.dependencies) func indicatorChangeClearsBadgeWhenCountZero() async { var globalSettings = GlobalSettings.default globalSettings.showNotificationDotOnDock = true - let badges = LockIsolated<[Bool]>([]) + let badges = LockIsolated<[Int]>([]) let store = TestStore( initialState: AppFeature.State(settings: SettingsFeature.State(settings: globalSettings)) ) { AppFeature() } withDependencies: { - $0.dockClient.setNotificationBadge = { isVisible in badges.withValue { $0.append(isVisible) } } + $0.dockClient.setNotificationBadge = { count in badges.withValue { $0.append(count) } } } store.exhaustivity = .off await store.send(.terminalEvent(.notificationIndicatorChanged(count: 0))) await store.finish() - #expect(badges.value == [false]) + #expect(badges.value == [0]) } } diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 86978a49..e7ec5302 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -29,6 +29,7 @@ struct CommandPaletteFeatureTests { "debug.toast.inProgress", "debug.toast.success", "debug.update.simulate-found", + "debug.dock.notification-dot", ]) #endif expectNoDifference(items.map(\.id), expectedIDs) @@ -1655,7 +1656,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: return .view #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return .debug #endif } @@ -1677,7 +1678,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .deleteWorktree, .runCustomCommand: return false #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound: + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: return true #endif } -- 2.51.2 From 9c5f5874f940dd6e549a785c1cf55a73c4384f50 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 22:51:11 +0900 Subject: [PATCH 228/528] Force Dock tile redraw after setting badge label Setting badgeLabel without calling display() can leave the tile unrepainted (bounce works because requestUserAttention doesn't touch tile drawing). Call dockTile.display() so the badge actually shows/clears. --- supacode/Clients/Dock/DockClient.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/supacode/Clients/Dock/DockClient.swift b/supacode/Clients/Dock/DockClient.swift index 41c354d1..4ef984ed 100644 --- a/supacode/Clients/Dock/DockClient.swift +++ b/supacode/Clients/Dock/DockClient.swift @@ -15,7 +15,11 @@ struct DockClient { extension DockClient: DependencyKey { static let liveValue = DockClient( setNotificationBadge: { count in - NSApplication.shared.dockTile.badgeLabel = count > 0 ? String(count) : nil + let dockTile = NSApplication.shared.dockTile + dockTile.badgeLabel = count > 0 ? String(count) : nil + // Setting `badgeLabel` alone doesn't always repaint the tile; force a + // redraw so the badge actually appears/clears. + dockTile.display() }, bounce: { mode in switch mode { -- 2.51.2 From 4b5ef47c83aa035fb1736c0e61909d7cefa553b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 23:18:32 +0900 Subject: [PATCH 229/528] Split Notifications settings and gate Dock badge on macOS state Move app-controlled options (bell, sound, move-to-top, dock bounce) into the Notifications section and system-dependent ones (system banners, dock badge) into a new System section. The Dock badge is rendered by macOS only when notifications are allowed and 'Badge app icon' is on (it does not depend on Prowl's banner toggle). Query that via SystemNotificationClient.dockBadgeAuthorization, refresh it when the pane appears, and disable + gray the badge toggle with an explanatory caption when unavailable. --- .../SystemNotificationClient.swift | 24 +++++++++++ .../Settings/Reducer/SettingsFeature.swift | 15 +++++++ .../Views/NotificationsSettingsView.swift | 40 +++++++++++++++---- supacodeTests/SettingsFeatureTests.swift | 13 ++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/supacode/Clients/Notifications/SystemNotificationClient.swift b/supacode/Clients/Notifications/SystemNotificationClient.swift index fa32e174..b3722adf 100644 --- a/supacode/Clients/Notifications/SystemNotificationClient.swift +++ b/supacode/Clients/Notifications/SystemNotificationClient.swift @@ -64,7 +64,20 @@ struct SystemNotificationClient { case notDetermined } + /// Whether macOS will actually render the app's Dock badge. The Dock badge + /// is gated by the system notification permission plus the per-app "Badge + /// app icon" switch — it does not depend on Prowl's own banner toggle. + enum DockBadgeAuthorization: Equatable { + /// Notifications are allowed and "Badge app icon" is on. + case available + /// macOS is not allowing notifications for Prowl (denied or not yet asked). + case notificationsDenied + /// Notifications are allowed, but "Badge app icon" is turned off. + case badgeDisabled + } + var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus + var dockBadgeAuthorization: @MainActor @Sendable () async -> DockBadgeAuthorization var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult var send: @MainActor @Sendable (_ title: String, _ body: String, _ worktreeID: Worktree.ID?, _ surfaceID: UUID?) async -> Void @@ -87,6 +100,16 @@ extension SystemNotificationClient: DependencyKey { return .denied } }, + dockBadgeAuthorization: { + let center = configuredNotificationCenter() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + return settings.badgeSetting == .enabled ? .available : .badgeDisabled + default: + return .notificationsDenied + } + }, requestAuthorization: { let center = configuredNotificationCenter() do { @@ -130,6 +153,7 @@ extension SystemNotificationClient: DependencyKey { static let testValue = SystemNotificationClient( authorizationStatus: { .notDetermined }, + dockBadgeAuthorization: { .available }, requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, send: { _, _, _, _ in }, openSettings: {} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 2138df14..93ecdb09 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -47,6 +47,10 @@ struct SettingsFeature { var showNotificationDotOnDock: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true + /// Whether macOS will render the Dock notification badge (notification + /// permission + the per-app "Badge app icon" switch). Refreshed when the + /// Notifications settings pane appears. + var dockBadgeAuthorization: SystemNotificationClient.DockBadgeAuthorization = .available var selection: SettingsSection? = .general var repositorySettings: RepositorySettingsFeature.State? @Presents var alert: AlertState? @@ -148,6 +152,8 @@ struct SettingsFeature { case uninstallCLIButtonTapped case cliInstallCompleted(Result) case refreshCLIInstallStatus + case refreshDockBadgeAuthorization + case dockBadgeAuthorizationResponse(SystemNotificationClient.DockBadgeAuthorization) case showNotificationPermissionAlert(errorMessage: String?) case repositorySettings(RepositorySettingsFeature.Action) case alert(PresentationAction) @@ -344,6 +350,15 @@ struct SettingsFeature { state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) return .none + case .refreshDockBadgeAuthorization: + return .run { send in + await send(.dockBadgeAuthorizationResponse(systemNotificationClient.dockBadgeAuthorization())) + } + + case .dockBadgeAuthorizationResponse(let authorization): + state.dockBadgeAuthorization = authorization + return .none + case .showNotificationPermissionAlert(let errorMessage): let message: String if let errorMessage, !errorMessage.isEmpty { diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index 639414c1..0a0d3955 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -19,11 +19,6 @@ struct NotificationsSettingsView: View { isOn: $store.notificationSoundEnabled ) .help("Play a sound when a notification is received") - Toggle( - "System notifications", - isOn: $store.systemNotificationsEnabled - ) - .help("Show macOS system notifications") Toggle( "Move notified worktree to top", isOn: $store.moveNotifiedWorktreeToTop @@ -38,11 +33,24 @@ struct NotificationsSettingsView: View { } } .help("Bounce the Prowl app icon in the Dock when a notification is received.") + } + Section("System") { + Toggle( + "System notifications", + isOn: $store.systemNotificationsEnabled + ) + .help("Show macOS system notification banners") Toggle( - "Show notification dot on dock icon", + "Show notification badge on dock icon", isOn: $store.showNotificationDotOnDock ) - .help("Show a badge on the Prowl dock icon while notifications are pending.") + .help("Show the unread worktree count on the Prowl dock icon.") + .disabled(!dockBadgeAvailable) + if let dockBadgeCaption { + Text(dockBadgeCaption) + .font(.callout) + .foregroundStyle(.secondary) + } } Section("Command Finished") { Toggle( @@ -76,5 +84,23 @@ struct NotificationsSettingsView: View { .formStyle(.grouped) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .task { + store.send(.refreshDockBadgeAuthorization) + } + } + + private var dockBadgeAvailable: Bool { + store.dockBadgeAuthorization == .available + } + + private var dockBadgeCaption: String? { + switch store.dockBadgeAuthorization { + case .available: + return nil + case .notificationsDenied: + return "Allow notifications for Prowl in System Settings to show the Dock badge." + case .badgeDisabled: + return "Turn on “Badge app icon” for Prowl in System Settings > Notifications." + } } } diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index a71ed446..4dfa3162 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -126,6 +126,19 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func refreshDockBadgeAuthorizationStoresSystemState() async { + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } withDependencies: { + $0.systemNotificationClient.dockBadgeAuthorization = { .badgeDisabled } + } + + await store.send(.refreshDockBadgeAuthorization) + await store.receive(\.dockBadgeAuthorizationResponse) { + $0.dockBadgeAuthorization = .badgeDisabled + } + } + @Test(.dependencies) func selectionDoesNotMutateRepositorySettings() async { let selection = SettingsSection.repository("repo-id") let store = TestStore(initialState: SettingsFeature.State()) { -- 2.51.2 From 6283906965e626aca669607f269b0b609b576caf Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 23:28:51 +0900 Subject: [PATCH 230/528] Refresh Dock badge availability when Prowl regains focus The Notifications pane view persists across tab switches, so .task only ran once per launch. Re-read the macOS notification/badge state on NSApplication.didBecomeActiveNotification so returning from System Settings updates the toggle live. --- .../Settings/Views/NotificationsSettingsView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index 0a0d3955..c7a560f2 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -1,3 +1,5 @@ +import AppKit +import Combine import ComposableArchitecture import SwiftUI @@ -87,6 +89,12 @@ struct NotificationsSettingsView: View { .task { store.send(.refreshDockBadgeAuthorization) } + // Changing the notification permission or "Badge app icon" switch happens + // in System Settings, so the moment Prowl regains focus is when to re-read + // the macOS state and update the toggle live (no restart needed). + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + store.send(.refreshDockBadgeAuthorization) + } } private var dockBadgeAvailable: Bool { -- 2.51.2 From 7fa8a3491d4b6b78a7b53ea40108c13be18ef2de Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 23:37:13 +0900 Subject: [PATCH 231/528] Make notification-permission alert user-friendly and actionable Replace the raw "Error: ..." technical text with a clear title and an actionable message telling the user to enable notifications in System Settings. --- .../Settings/Reducer/SettingsFeature.swift | 16 +++++----------- .../AppFeatureSystemNotificationTests.swift | 8 ++++---- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 93ecdb09..0020b278 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -359,17 +359,9 @@ struct SettingsFeature { state.dockBadgeAuthorization = authorization return .none - case .showNotificationPermissionAlert(let errorMessage): - let message: String - if let errorMessage, !errorMessage.isEmpty { - message = - "Prowl cannot send system notifications.\n\n" - + "Error: \(errorMessage)" - } else { - message = "Prowl cannot send system notifications while permission is denied." - } + case .showNotificationPermissionAlert: state.alert = AlertState { - TextState("Enable Notifications in System Settings") + TextState("Prowl cannot send system notifications") } actions: { ButtonState(action: .openSystemNotificationSettings) { TextState("Open System Settings") @@ -378,7 +370,9 @@ struct SettingsFeature { TextState("Cancel") } } message: { - TextState(message) + TextState( + "Notification permission is turned off. Open System Settings to allow Prowl to send notifications." + ) } return .none diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index a7a9482e..d767332d 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -43,7 +43,7 @@ struct AppFeatureSystemNotificationTests { $0.settings.systemNotificationsEnabled = false } let expectedAlert = AlertState { - TextState("Enable Notifications in System Settings") + TextState("Prowl cannot send system notifications") } actions: { ButtonState(action: .openSystemNotificationSettings) { TextState("Open System Settings") @@ -52,7 +52,7 @@ struct AppFeatureSystemNotificationTests { TextState("Cancel") } } message: { - TextState("Prowl cannot send system notifications.\n\nError: Mock request error") + TextState("Notification permission is turned off. Open System Settings to allow Prowl to send notifications.") } await store.receive(\.settings.showNotificationPermissionAlert) { $0.settings.alert = expectedAlert @@ -96,7 +96,7 @@ struct AppFeatureSystemNotificationTests { $0.settings.systemNotificationsEnabled = false } let expectedAlert = AlertState { - TextState("Enable Notifications in System Settings") + TextState("Prowl cannot send system notifications") } actions: { ButtonState(action: .openSystemNotificationSettings) { TextState("Open System Settings") @@ -105,7 +105,7 @@ struct AppFeatureSystemNotificationTests { TextState("Cancel") } } message: { - TextState("Prowl cannot send system notifications.\n\nError: Authorization status is denied.") + TextState("Notification permission is turned off. Open System Settings to allow Prowl to send notifications.") } await store.receive(\.settings.showNotificationPermissionAlert) { $0.settings.alert = expectedAlert -- 2.51.2 From 056965078fc726e7eaefeee104b84bfc3b5e9e10 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 23:45:33 +0900 Subject: [PATCH 232/528] Fix stuck notification indicator by pruning a closed surface's notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications are keyed by their originating surface and only cleared when that surface is focused or typed into. When a surface was torn down (closed tab/split, exited process) its notifications were left behind, so hasUnseenNotification stayed true forever — the bell and Dock badge stayed lit until the user hit Dismiss All. Consolidate the per-surface teardown bookkeeping into forgetSurface(_:) and drop that surface's notifications there, emitting the indicator change. Add a pure prunedNotifications(from:removingSurfaceID:) helper with unit coverage. --- .../Models/WorktreeTerminalState.swift | 56 +++++++++++-------- ...rktreeTerminalNotificationPruneTests.swift | 48 ++++++++++++++++ 2 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 supacodeTests/WorktreeTerminalNotificationPruneTests.swift diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index f030d193..1ef79326 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1964,16 +1964,39 @@ final class WorktreeTerminalState { return remainingMinutes > 0 ? "\(hours)h \(remainingMinutes)m" : "\(hours)h" } + /// Drops all per-surface bookkeeping for a surface that has been torn down, + /// including any notifications it produced. A notification is keyed by its + /// originating surface and is only cleared when that surface is focused or + /// typed into; once the surface is gone there is no way to mark it read, so + /// without dropping them here the worktree's unseen indicator (bell + Dock + /// badge) would stay lit until the user manually dismisses everything. + private func forgetSurface(_ surfaceID: UUID) { + surfaces.removeValue(forKey: surfaceID) + surfaceRunningStartedAtById.removeValue(forKey: surfaceID) + autoCloseSurfaceIds.remove(surfaceID) + pendingCustomCommands.removeValue(forKey: surfaceID) + cleanupCommandDetectorState(forSurfaceId: surfaceID) + cleanupAgentDetectionState(forSurfaceId: surfaceID) + let previousHasUnseen = hasUnseenNotification + notifications = Self.prunedNotifications(from: notifications, removingSurfaceID: surfaceID) + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + /// Removes every notification that originated from `surfaceID`, regardless of + /// read state. Pure so the teardown behavior can be unit-tested without a + /// live Ghostty surface. + static func prunedNotifications( + from notifications: [WorktreeTerminalNotification], + removingSurfaceID surfaceID: UUID + ) -> [WorktreeTerminalNotification] { + notifications.filter { $0.surfaceId != surfaceID } + } + private func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } for surface in tree.leaves() { surface.closeSurface() - surfaces.removeValue(forKey: surface.id) - surfaceRunningStartedAtById.removeValue(forKey: surface.id) - autoCloseSurfaceIds.remove(surface.id) - pendingCustomCommands.removeValue(forKey: surface.id) - cleanupCommandDetectorState(forSurfaceId: surface.id) - cleanupAgentDetectionState(forSurfaceId: surface.id) + forgetSurface(surface.id) } focusedSurfaceIdByTab.removeValue(forKey: tabId) tabIsRunningById.removeValue(forKey: tabId) @@ -2130,22 +2153,12 @@ final class WorktreeTerminalState { } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() - surfaces.removeValue(forKey: view.id) - surfaceRunningStartedAtById.removeValue(forKey: view.id) - autoCloseSurfaceIds.remove(view.id) - pendingCustomCommands.removeValue(forKey: view.id) - cleanupCommandDetectorState(forSurfaceId: view.id) - cleanupAgentDetectionState(forSurfaceId: view.id) + forgetSurface(view.id) return } guard let node = tree.find(id: view.id) else { view.closeSurface() - surfaces.removeValue(forKey: view.id) - surfaceRunningStartedAtById.removeValue(forKey: view.id) - autoCloseSurfaceIds.remove(view.id) - pendingCustomCommands.removeValue(forKey: view.id) - cleanupCommandDetectorState(forSurfaceId: view.id) - cleanupAgentDetectionState(forSurfaceId: view.id) + forgetSurface(view.id) return } let nextSurface = @@ -2154,12 +2167,7 @@ final class WorktreeTerminalState { : nil let newTree = tree.removing(node) view.closeSurface() - surfaces.removeValue(forKey: view.id) - surfaceRunningStartedAtById.removeValue(forKey: view.id) - autoCloseSurfaceIds.remove(view.id) - pendingCustomCommands.removeValue(forKey: view.id) - cleanupCommandDetectorState(forSurfaceId: view.id) - cleanupAgentDetectionState(forSurfaceId: view.id) + forgetSurface(view.id) if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) diff --git a/supacodeTests/WorktreeTerminalNotificationPruneTests.swift b/supacodeTests/WorktreeTerminalNotificationPruneTests.swift new file mode 100644 index 00000000..cd260411 --- /dev/null +++ b/supacodeTests/WorktreeTerminalNotificationPruneTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import supacode + +struct WorktreeTerminalNotificationPruneTests { + private func notification(surface: UUID, title: String, isRead: Bool) -> WorktreeTerminalNotification { + WorktreeTerminalNotification(surfaceId: surface, title: title, body: "", isRead: isRead) + } + + @Test func pruningRemovesEveryNotificationFromTheClosedSurface() { + let closed = UUID() + let other = UUID() + let notifications = [ + notification(surface: closed, title: "unread", isRead: false), + notification(surface: other, title: "kept", isRead: false), + // A read notification from the closed surface should go too — the + // surface no longer exists, so keeping it serves no purpose. + notification(surface: closed, title: "read", isRead: true), + ] + + let pruned = WorktreeTerminalState.prunedNotifications(from: notifications, removingSurfaceID: closed) + + #expect(pruned.map(\.title) == ["kept"]) + } + + @Test func pruningAClosedSurfaceClearsAStuckUnseenIndicator() { + // The bug: an unread notification from a closed surface kept + // `hasUnseenNotification`-style state lit forever. After pruning the only + // unread notification, nothing unread should remain. + let closed = UUID() + let notifications = [notification(surface: closed, title: "stuck", isRead: false)] + + let pruned = WorktreeTerminalState.prunedNotifications(from: notifications, removingSurfaceID: closed) + + #expect(pruned.isEmpty) + #expect(!pruned.contains { !$0.isRead }) + } + + @Test func pruningLeavesUnrelatedSurfacesUntouched() { + let other = UUID() + let notifications = [notification(surface: other, title: "kept", isRead: false)] + + let pruned = WorktreeTerminalState.prunedNotifications(from: notifications, removingSurfaceID: UUID()) + + #expect(pruned == notifications) + } +} -- 2.51.2 From 24e639f893a324b8544c245fa076ba1d40c5837a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 08:58:32 +0900 Subject: [PATCH 233/528] Refine Canvas action toolbar: gate run button, separate clusters, fix switch jump - Route run-button visibility through `showRunButtonInToolbar` in Canvas - Add a fixed separator between the notification and Run+Command clusters - Load focused-card settings atomically in one reduce pass via shared applyWorktreeSettings / applyWorktreeUserSettings helpers - Render the Run+Command cluster as a single toolbar item (HStack) so NSToolbar keeps a stable item set, avoiding the insert/remove animation that briefly overflowed the toolbar when switching between cards with different command counts --- .../Features/App/Reducer/AppFeature.swift | 89 +++++++----- .../Views/WorktreeDetailView.swift | 128 ++++++++++++------ .../AppFeatureCustomCommandTests.swift | 13 +- 3 files changed, 143 insertions(+), 87 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e80b156e..36b7b72f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -235,6 +235,51 @@ struct AppFeature { return resolved } + /// Applies a worktree's repository settings (open action, run script) into + /// state. Shared by the normal `worktreeSettingsLoaded` action and the Canvas + /// focus path so both stay in sync. + private func applyWorktreeSettings(_ settings: RepositorySettings, into state: inout State) { + @Shared(.settingsFile) var settingsFile + let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( + settingsFile.global.defaultEditorID + ) + state.openActionSelection = OpenWorktreeAction.fromSettingsID( + settings.openActionID, + defaultEditorID: normalizedDefaultEditorID + ) + state.selectedRunScript = settings.runScript + } + + /// Applies a worktree's user settings (custom commands, keybindings) into + /// state and returns the effect that re-registers custom shortcuts. Shared by + /// the normal `worktreeUserSettingsLoaded` action and the Canvas focus path. + private func applyWorktreeUserSettings( + _ settings: UserRepositorySettings, + into state: inout State + ) -> Effect { + state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) + let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) + let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in + let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut + } + return .run { _ in + let logger = SupaLogger("Shortcuts") + for conflict in userOverrideConflicts { + logger.warning( + "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " + + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " + + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" + ) + } + await customShortcutRegistryClient.setShortcuts(shortcuts) + } + } + var body: some Reducer { let core = Reduce { state, action in switch action { @@ -832,10 +877,14 @@ struct AppFeature { let rootURL = worktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings - return .concatenate( - .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), - .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) - ) + // Apply both settings in this single reduce pass instead of dispatching + // follow-up `.send`s. The Canvas focus ID (an `@Observable` on the + // terminal manager) updates synchronously on card tap, so the toolbar's + // Run + Custom Command items must update in the same transaction — + // otherwise the command list lands a frame later and the toolbar + // visibly reflows when switching between cards with different commands. + applyWorktreeSettings(repositorySettings, into: &state) + return applyWorktreeUserSettings(userRepositorySettings, into: &state) case .runScriptDraftChanged(let script): state.runScriptDraft = script @@ -959,42 +1008,14 @@ struct AppFeature { guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { return .none } - @Shared(.settingsFile) var settingsFile - let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( - settingsFile.global.defaultEditorID - ) - state.openActionSelection = OpenWorktreeAction.fromSettingsID( - settings.openActionID, - defaultEditorID: normalizedDefaultEditorID - ) - state.selectedRunScript = settings.runScript + applyWorktreeSettings(settings, into: &state) return .none case .worktreeUserSettingsLoaded(let settings, let worktreeID): guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { return .none } - state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) - state.resolvedKeybindings = resolvedKeybindings( - settings: state.settings, - customCommands: state.selectedCustomCommands - ) - let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) - let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in - let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut - } - return .run { _ in - let logger = SupaLogger("Shortcuts") - for conflict in userOverrideConflicts { - logger.warning( - "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " - + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " - + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" - ) - } - await customShortcutRegistryClient.setShortcuts(shortcuts) - } + return applyWorktreeUserSettings(settings, into: &state) case .systemNotificationsPermissionFailed(let errorMessage): return .concatenate( diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f41656a6..6df8d36a 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -28,6 +28,7 @@ struct WorktreeDetailView: View { let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool let availableUpdateVersion: String? + let showRunButtonInToolbar: Bool } @Bindable var store: StoreOf @@ -92,7 +93,8 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, - availableUpdateVersion: state.updates.availableVersion + availableUpdateVersion: state.updates.availableVersion, + showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar ) ) } else if hasActiveTerminalTarget, @@ -192,53 +194,89 @@ struct WorktreeDetailView: View { store.send(.updates(.checkForUpdates)) } } - if state.runScriptIsRunning || state.runScriptEnabled { - RunScriptToolbarButton( - isRunning: state.runScriptIsRunning, - isEnabled: state.runScriptEnabled, - runHelpText: AppShortcuts.helpText( - title: "Run Script", - commandID: AppShortcuts.CommandID.runScript, - in: store.resolvedKeybindings - ), - stopHelpText: AppShortcuts.helpText( - title: "Stop Script", - commandID: AppShortcuts.CommandID.stopScript, - in: store.resolvedKeybindings - ), - runShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.runScript), - stopShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.stopScript), - runAction: { store.send(.runScript) }, - stopAction: { store.send(.stopRunScript) } - ) - } - ForEach(Array(state.customCommands.enumerated()).prefix(3), id: \.element.id) { index, command in - UserCustomCommandToolbarButton( - title: command.resolvedTitle, - systemImage: command.resolvedSystemImage, - shortcut: store.resolvedKeybindings.display( - for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - ), - isEnabled: command.hasRunnableCommand, - action: { - store.send(.runCustomCommand(index)) + } + + let showRunButton = + state.showRunButtonInToolbar + && (state.runScriptIsRunning || state.runScriptEnabled) + let inlineCommands = Array(state.customCommands.enumerated().prefix(3)) + let overflowCommands = Array(state.customCommands.enumerated().dropFirst(3)) + // A fixed separator splits the Run + Custom Command cluster from the + // notification group, mirroring the Normal toolbar. + // + // INTENTIONAL DIVERGENCE FROM THE NORMAL TOOLBAR: the whole cluster is a + // single `ToolbarItem` (an HStack) here, whereas `commandToolbarItems` + // (Normal mode) lays the buttons out as separate items / a + // `ToolbarItemGroup`. The reason is how each mode updates NSToolbar (which + // SwiftUI's `.toolbar` bridges to): + // - Normal: switching worktree swaps the whole detail view, so NSToolbar + // is rebuilt wholesale — no per-item diff, no animation. + // - Canvas: `CanvasView` stays mounted across card switches; only the + // toolbar items change. With a multi-item structure NSToolbar performs + // an incremental insert/remove with its own animation (which SwiftUI + // transactions can't suppress), briefly overflowing the toolbar — the + // visible "jump" when switching between cards with different command + // counts. + // Collapsing the cluster into one item keeps NSToolbar's item set stable, + // so a command-count change is just an internal HStack relayout. Do NOT + // "unify" this back into a `ToolbarItemGroup` to match Normal — that + // reintroduces the jump. + if showRunButton || !state.customCommands.isEmpty { + ToolbarSpacer(.fixed) + ToolbarItem(placement: .primaryAction) { + // `spacing: 0` keeps the cluster as tight as the Normal toolbar's + // ToolbarItemGroup (whose buttons sit nearly flush on macOS 26); the + // buttons' own internal padding provides the visible gap. + HStack(spacing: 0) { + if showRunButton { + RunScriptToolbarButton( + isRunning: state.runScriptIsRunning, + isEnabled: state.runScriptEnabled, + runHelpText: AppShortcuts.helpText( + title: "Run Script", + commandID: AppShortcuts.CommandID.runScript, + in: store.resolvedKeybindings + ), + stopHelpText: AppShortcuts.helpText( + title: "Stop Script", + commandID: AppShortcuts.CommandID.stopScript, + in: store.resolvedKeybindings + ), + runShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.runScript), + stopShortcut: store.resolvedKeybindings.display(for: AppShortcuts.CommandID.stopScript), + runAction: { store.send(.runScript) }, + stopAction: { store.send(.stopRunScript) } + ) } - ) - } - if state.customCommands.count > 3 { - CustomCommandOverflowButton( - entries: Array(state.customCommands.enumerated().dropFirst(3)).map { - (index: $0.offset, command: $0.element) - }, - shortcutDisplay: { command in - store.resolvedKeybindings.display( - for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + ForEach(inlineCommands, id: \.element.id) { index, command in + UserCustomCommandToolbarButton( + title: command.resolvedTitle, + systemImage: command.resolvedSystemImage, + shortcut: store.resolvedKeybindings.display( + for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + ), + isEnabled: command.hasRunnableCommand, + action: { + store.send(.runCustomCommand(index)) + } ) - }, - onRunCustomCommand: { index in - store.send(.runCustomCommand(index)) } - ) + if !overflowCommands.isEmpty { + CustomCommandOverflowButton( + entries: overflowCommands.map { + (index: $0.offset, command: $0.element) + }, + shortcutDisplay: { command in + store.resolvedKeybindings.display( + for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + ) + }, + onRunCustomCommand: { index in + store.send(.runCustomCommand(index)) + } + ) + } + } } } } diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 0e181ff2..7944e04a 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -505,11 +505,10 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.canvasFocusedWorktreeChanged(worktree.id)) - await store.receive(\.worktreeSettingsLoaded) { + // Canvas focus applies both repository and user settings in a single reduce + // pass so the toolbar updates atomically with the focus change. + await store.send(.canvasFocusedWorktreeChanged(worktree.id)) { $0.openActionSelection = expectedDefaultOpenAction() - } - await store.receive(\.worktreeUserSettingsLoaded) { $0.selectedCustomCommands = settings.customCommands $0.resolvedKeybindings = KeybindingResolver.resolve( schema: .appResolverSchema(customCommands: settings.customCommands), @@ -519,6 +518,7 @@ struct AppFeatureCustomCommandTests { .overrides ) } + await store.finish() } @Test(.dependencies) func canvasFocusWithMultipleReposUsesPrimaryFocusedWorktree() async { @@ -570,12 +570,9 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.canvasFocusedWorktreeChanged(worktreeB.id)) - await store.receive(\.worktreeSettingsLoaded) { + await store.send(.canvasFocusedWorktreeChanged(worktreeB.id)) { $0.openActionSelection = expectedDefaultOpenAction() $0.selectedRunScript = "npm run repo-b" - } - await store.receive(\.worktreeUserSettingsLoaded) { $0.selectedCustomCommands = [commandB] $0.resolvedKeybindings = KeybindingResolver.resolve( schema: .appResolverSchema(customCommands: [commandB]), -- 2.51.2 From 90f35d13f5b7207f181c1ca379a830ef98d9f55c Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 09:43:47 +0900 Subject: [PATCH 234/528] Resolve Active Agents repo/branch from the agent's working directory The Active Agents panel labeled each row using the worktree that owns the agent's terminal tab. When a user `cd`s into a different repository before launching an agent, the row kept showing the original tab's repo/branch, which was confusing. Capture the surface's working directory when emitting an ActiveAgentEntry and resolve the displayed repository/branch from it via a three-tier lookup: 1. cwd inside a known repo/worktree -> use it, so the label tracks live branch renames through the existing metadata. 2. cwd known but outside every repo -> derive a name from its last path component (same logic as adding a repository). 3. cwd unknown -> fall back to the surface's owning worktree (legacy). `worktreeID` is left untouched so it stays the surface's real owner for navigation/focus (`focusSurface`/`selectWorktree`); only the display identity now follows the agent's actual directory. --- .../Models/ActiveAgentEntry.swift | 8 + .../Views/ActiveAgentsPanel.swift | 12 +- .../Repositories/Views/SidebarListView.swift | 89 +++++++++- .../Models/WorktreeTerminalState.swift | 8 + supacodeTests/ActiveAgentsFeatureTests.swift | 1 + .../AppFeatureSettingsChangedTests.swift | 1 + supacodeTests/RepositoriesFeatureTests.swift | 2 + .../RepositorySectionViewTests.swift | 160 ++++++++++++++++++ 8 files changed, 272 insertions(+), 9 deletions(-) diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index fa2d332e..bd6b7aa0 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -2,8 +2,16 @@ import Foundation struct ActiveAgentEntry: Identifiable, Equatable, Sendable { let id: UUID + /// The worktree that physically owns the agent's terminal surface (the tab's worktree). + /// Drives navigation/focus (`focusSurface`/`selectWorktree`), so it must stay the surface's + /// real owner even when the agent runs in a different directory. Display name/branch come from + /// `workingDirectory` instead — see `SidebarListView.activeAgentRowDisplay`. let worktreeID: Worktree.ID let worktreeName: String + /// The agent's current working directory at detection time, used to resolve the displayed + /// repository/branch. `nil` when the terminal hasn't reported a directory, in which case the + /// display falls back to `worktreeID`/`worktreeName`. + let workingDirectory: URL? let tabID: TerminalTabID let tabTitle: String let surfaceID: UUID diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index d816ed2a..d438fbe2 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -4,9 +4,9 @@ import SwiftUI struct ActiveAgentsPanel: View { @Bindable var store: StoreOf - let repositoryNamesByWorktreeID: [Worktree.ID: String] - let branchNamesByWorktreeID: [Worktree.ID: String] - let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] + /// Per-entry repository/branch labels resolved from each agent's working directory by the parent + /// (see `SidebarListView.activeAgentRowDisplays`); keeps this view presentational. + let rowDisplays: [ActiveAgentEntry.ID: ActiveAgentRowDisplay] let selectedSurfaceID: UUID? /// Merged "⌥⌃↑↓" hint shown while Cmd is held; `nil` hides it (bindings customized /// or Cmd not held). Resolved by the parent so the panel stays presentational. @@ -121,15 +121,15 @@ struct ActiveAgentsPanel: View { } private func repositoryName(for entry: ActiveAgentEntry) -> String { - repositoryNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName + rowDisplays[entry.id]?.repositoryName ?? entry.worktreeName } private func branchName(for entry: ActiveAgentEntry) -> String { - branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName + rowDisplays[entry.id]?.branchName ?? entry.worktreeName } private func repositoryColor(for entry: ActiveAgentEntry) -> RepositoryColorChoice? { - repositoryColorsByWorktreeID[entry.worktreeID] + rowDisplays[entry.id]?.color } private func isDimmed(_ entry: ActiveAgentEntry) -> Bool { diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 704e304e..c09e0e26 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -78,6 +78,11 @@ struct SidebarListView: View { customTitles: state.repositoryCustomTitles, repositoryAppearances: repositoryAppearances ) + let agentRowDisplays = Self.activeAgentRowDisplays( + entries: state.activeAgents.entries, + repositories: state.repositories, + metadata: agentWorktreeMetadata + ) let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 let activeAgentsPanelTopGap = 4.0 @@ -152,9 +157,7 @@ struct SidebarListView: View { .overlay(alignment: .bottom) { ActiveAgentsPanel( store: store.scope(state: \.activeAgents, action: \.activeAgents), - repositoryNamesByWorktreeID: agentWorktreeMetadata.repositoryNamesByWorktreeID, - branchNamesByWorktreeID: agentWorktreeMetadata.branchNamesByWorktreeID, - repositoryColorsByWorktreeID: agentWorktreeMetadata.repositoryColorsByWorktreeID, + rowDisplays: agentRowDisplays, selectedSurfaceID: selectedSurfaceID, navigationShortcutHint: activeAgentsShortcutHint, height: panelHeight, @@ -527,6 +530,80 @@ struct SidebarListView: View { repositoryColorsByWorktreeID: repositoryColorsByWorktreeID ) } + + /// Resolves the repository/branch label shown for each active agent from the directory the + /// agent actually runs in, rather than the tab's owning worktree. + static func activeAgentRowDisplays( + entries: IdentifiedArrayOf, + repositories: IdentifiedArrayOf, + metadata: ActiveAgentWorktreeMetadata + ) -> [ActiveAgentEntry.ID: ActiveAgentRowDisplay] { + var displays: [ActiveAgentEntry.ID: ActiveAgentRowDisplay] = [:] + for entry in entries { + displays[entry.id] = activeAgentRowDisplay( + for: entry, + repositories: repositories, + metadata: metadata + ) + } + return displays + } + + /// Three-tier resolution for the displayed name/branch of a single agent: + /// 1. `workingDirectory` falls inside a known repo/worktree → use it, so the label tracks live + /// branch renames through `metadata`. + /// 2. `workingDirectory` is known but outside every repo → derive a name from its last path + /// component (same logic as adding a repository). + /// 3. `workingDirectory` is unknown → fall back to the surface's owning worktree (legacy behavior). + static func activeAgentRowDisplay( + for entry: ActiveAgentEntry, + repositories: IdentifiedArrayOf, + metadata: ActiveAgentWorktreeMetadata + ) -> ActiveAgentRowDisplay { + if let workingDirectory = entry.workingDirectory { + if let key = resolveWorktreeID(forWorkingDirectory: workingDirectory, in: repositories) { + let fallbackName = workingDirectory.lastPathComponent + return ActiveAgentRowDisplay( + repositoryName: metadata.repositoryNamesByWorktreeID[key] ?? fallbackName, + branchName: metadata.branchNamesByWorktreeID[key] ?? fallbackName, + color: metadata.repositoryColorsByWorktreeID[key] + ) + } + let name = Repository.name(for: workingDirectory) + return ActiveAgentRowDisplay(repositoryName: name, branchName: name, color: nil) + } + return ActiveAgentRowDisplay( + repositoryName: metadata.repositoryNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, + branchName: metadata.branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, + color: metadata.repositoryColorsByWorktreeID[entry.worktreeID] + ) + } + + /// Finds the most specific repo/worktree whose directory contains `workingDirectory`. Plain + /// folders are keyed by their repository id (matching `activeAgentWorktreeMetadata`); git repos + /// are matched through their worktrees (the main worktree covers the repo root). When nested + /// directories both match (e.g. a worktree inside a repo), the deepest one wins. + static func resolveWorktreeID( + forWorkingDirectory workingDirectory: URL, + in repositories: IdentifiedArrayOf + ) -> Worktree.ID? { + var best: (id: Worktree.ID, depth: Int)? + func consider(id: Worktree.ID, directory: URL) { + guard PathPolicy.contains(workingDirectory, in: directory) else { return } + let depth = PathPolicy.normalizeURL(directory).pathComponents.count + if let current = best, current.depth >= depth { return } + best = (id, depth) + } + for repository in repositories { + if repository.capabilities.supportsRunnableFolderActions, !repository.capabilities.supportsWorktrees { + consider(id: repository.id, directory: repository.rootURL) + } + for worktree in repository.worktrees { + consider(id: worktree.id, directory: worktree.workingDirectory) + } + } + return best?.id + } } struct ActiveAgentWorktreeMetadata: Equatable { @@ -535,6 +612,12 @@ struct ActiveAgentWorktreeMetadata: Equatable { let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] } +struct ActiveAgentRowDisplay: Equatable { + let repositoryName: String + let branchName: String + let color: RepositoryColorChoice? +} + extension SidebarItem { fileprivate var isRepositoryOrderItem: Bool { repositoryOrderID != nil diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 1ef79326..5e83317a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1836,10 +1836,18 @@ final class WorktreeTerminalState { let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" + // Resolve the displayed repository/branch from where the agent actually runs, not the tab's + // owning worktree: a user may `cd` into a different repo before launching the agent. Falls + // back to the surface's launch directory when the shell hasn't reported a pwd. + let workingDirectory = inheritedSurfaceConfig( + fromSurfaceId: surfaceID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory return ActiveAgentEntry( id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, + workingDirectory: workingDirectory, tabID: tabId, tabTitle: tabTitle, surfaceID: surfaceID, diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 11e93b6a..f2766757 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -171,6 +171,7 @@ struct ActiveAgentsFeatureTests { id: id, worktreeID: "/repo/wt", worktreeName: "wt", + workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), tabTitle: "1", surfaceID: id, diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 6c7d782d..a1b60b3b 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -161,6 +161,7 @@ struct AppFeatureSettingsChangedTests { id: fixedUUID(0), worktreeID: "/repo/wt", worktreeName: "wt", + workingDirectory: nil, tabID: TerminalTabID(rawValue: fixedUUID(1)), tabTitle: "codex", surfaceID: fixedUUID(0), diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 54a7d4a7..c923b909 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -937,6 +937,7 @@ struct RepositoriesFeatureTests { id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, + workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), tabTitle: "agent", surfaceID: surfaceID, @@ -990,6 +991,7 @@ struct RepositoriesFeatureTests { id: surfaceID, worktreeID: repository.id, worktreeName: repository.name, + workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), tabTitle: "agent", surfaceID: surfaceID, diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index e729fce4..9057da76 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -132,6 +132,166 @@ struct RepositorySectionViewTests { #expect(metadata.repositoryColorsByWorktreeID[repository.id] == nil) } + // MARK: - Active agent row display (cwd-based repository/branch resolution) + + @Test func activeAgentRowDisplayUsesWorkingDirectoryRepositoryNotTabWorktree() { + // The agent's tab physically lives in `tabRepo`, but it was launched after `cd`-ing into a + // different checked-out repository. The row must reflect where the agent actually runs. + let tabWorktree = makeWorktree(repoRoot: "/tmp/tab-repo", path: "/tmp/tab-repo", branch: "main") + let tabRepo = makeRepository(id: "/tmp/tab-repo", name: "tab-repo", worktrees: [tabWorktree]) + let agentWorktree = makeWorktree( + repoRoot: "/tmp/other-repo", + path: "/tmp/other-repo", + branch: "feature/login" + ) + let agentRepo = makeRepository(id: "/tmp/other-repo", name: "other-repo", worktrees: [agentWorktree]) + let repositories: IdentifiedArrayOf = [tabRepo, agentRepo] + let metadata = SidebarListView.activeAgentWorktreeMetadata( + repositories: repositories, + customTitles: [:] + ) + + let entry = makeAgentEntry( + worktreeID: tabWorktree.id, + worktreeName: tabWorktree.name, + // A nested sub-directory still resolves to the enclosing worktree. + workingDirectory: URL(fileURLWithPath: "/tmp/other-repo/src") + ) + let display = SidebarListView.activeAgentRowDisplay( + for: entry, + repositories: repositories, + metadata: metadata + ) + + #expect(display.repositoryName == "other-repo") + #expect(display.branchName == "feature/login") + } + + @Test func activeAgentRowDisplayPrefersDeepestMatchingWorktree() { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(repoRoot: repoRoot, path: "/tmp/repo", branch: "main") + // A worktree nested inside the repo root; the deepest containing directory must win. + let nestedWorktree = makeWorktree( + repoRoot: repoRoot, + path: "/tmp/repo/worktrees/feature", + branch: "feature" + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + worktrees: [mainWorktree, nestedWorktree] + ) + let metadata = SidebarListView.activeAgentWorktreeMetadata(repositories: [repository], customTitles: [:]) + + let key = SidebarListView.resolveWorktreeID( + forWorkingDirectory: URL(fileURLWithPath: "/tmp/repo/worktrees/feature/lib"), + in: [repository] + ) + + #expect(key == nestedWorktree.id) + let entry = makeAgentEntry( + worktreeID: mainWorktree.id, + worktreeName: mainWorktree.name, + workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktrees/feature/lib") + ) + let display = SidebarListView.activeAgentRowDisplay(for: entry, repositories: [repository], metadata: metadata) + #expect(display.branchName == "feature") + } + + @Test func activeAgentRowDisplayResolvesPlainFolderByRootURL() { + let repository = makeRepository(id: "/tmp/notes", name: "notes", kind: .plain, worktrees: []) + let metadata = SidebarListView.activeAgentWorktreeMetadata(repositories: [repository], customTitles: [:]) + + let entry = makeAgentEntry( + worktreeID: repository.id, + worktreeName: repository.name, + workingDirectory: URL(fileURLWithPath: "/tmp/notes/inbox") + ) + let display = SidebarListView.activeAgentRowDisplay(for: entry, repositories: [repository], metadata: metadata) + + #expect(display.repositoryName == "notes") + #expect(display.branchName == "notes") + } + + @Test func activeAgentRowDisplayFallsBackToPathComponentForUnknownDirectory() { + let tabWorktree = makeWorktree(repoRoot: "/tmp/tab-repo", path: "/tmp/tab-repo", branch: "main") + let tabRepo = makeRepository(id: "/tmp/tab-repo", name: "tab-repo", worktrees: [tabWorktree]) + let metadata = SidebarListView.activeAgentWorktreeMetadata(repositories: [tabRepo], customTitles: [:]) + + let entry = makeAgentEntry( + worktreeID: tabWorktree.id, + worktreeName: tabWorktree.name, + workingDirectory: URL(fileURLWithPath: "/tmp/scratch/playground") + ) + let display = SidebarListView.activeAgentRowDisplay(for: entry, repositories: [tabRepo], metadata: metadata) + + #expect(display.repositoryName == "playground") + #expect(display.branchName == "playground") + #expect(display.color == nil) + } + + @Test func activeAgentRowDisplayFallsBackToOwningWorktreeWhenDirectoryUnknown() { + let tabWorktree = makeWorktree(repoRoot: "/tmp/tab-repo", path: "/tmp/tab-repo", branch: "main") + let tabRepo = makeRepository(id: "/tmp/tab-repo", name: "tab-repo", worktrees: [tabWorktree]) + let metadata = SidebarListView.activeAgentWorktreeMetadata(repositories: [tabRepo], customTitles: [:]) + + let entry = makeAgentEntry( + worktreeID: tabWorktree.id, + worktreeName: tabWorktree.name, + workingDirectory: nil + ) + let display = SidebarListView.activeAgentRowDisplay(for: entry, repositories: [tabRepo], metadata: metadata) + + #expect(display.repositoryName == "tab-repo") + #expect(display.branchName == "main") + } + + private func makeWorktree(repoRoot: String, path: String, branch: String) -> Worktree { + Worktree( + id: path, + name: branch, + detail: branch, + workingDirectory: URL(fileURLWithPath: path), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository( + id: String, + name: String, + kind: Repository.Kind = .git, + worktrees: IdentifiedArrayOf + ) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: name, + kind: kind, + worktrees: worktrees + ) + } + + private func makeAgentEntry( + worktreeID: Worktree.ID, + worktreeName: String, + workingDirectory: URL? + ) -> ActiveAgentEntry { + ActiveAgentEntry( + id: UUID(), + worktreeID: worktreeID, + worktreeName: worktreeName, + workingDirectory: workingDirectory, + tabID: TerminalTabID(rawValue: UUID()), + tabTitle: "agent", + surfaceID: UUID(), + paneIndex: 1, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + } + @Test func openTabCountForGitRepositorySumsAllWorktrees() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo") -- 2.51.2 From 0b1edd17d5b70b99250ecac2797f7b67ebcfdd36 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 22:06:02 +0900 Subject: [PATCH 235/528] Reduce line diff polling --- .../WorktreeInfoWatcherClient.swift | 2 + .../Features/App/Reducer/AppFeature.swift | 39 ++- .../WorktreeInfoWatcherManager.swift | 274 +++++++++++++++--- .../AppFeatureTerminalSetupScriptTests.swift | 36 +++ .../WorktreeInfoWatcherManagerTests.swift | 127 +++++++- 5 files changed, 442 insertions(+), 36 deletions(-) diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index 6a8e11bd..3d608a15 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -7,7 +7,9 @@ struct WorktreeInfoWatcherClient { enum Command: Equatable { case setWorktrees([Worktree]) + case setOpenedWorktreeIDs(Set) case setSelectedWorktreeID(Worktree.ID?) + case refreshLineChanges case setPullRequestTrackingEnabled(Bool) case stop } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 36b7b72f..be77efd5 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -34,6 +34,13 @@ private func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> return worktrees } +private func openedWorktreeIDsForInfoWatcher( + from repositories: RepositoriesFeature.State +) -> Set { + let watchedIDs = Set(repositories.worktreesForInfoWatcher().map(\.id)) + return repositories.openedWorktreeIDs.intersection(watchedIDs) +} + @Reducer struct AppFeature { @ObservableState @@ -320,6 +327,9 @@ struct AppFeature { case .active: return .merge( .send(.repositories(.refreshWorktrees)), + .run { _ in + await worktreeInfoWatcher.send(.refreshLineChanges) + }, .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: .seconds(30)) @@ -444,6 +454,7 @@ struct AppFeature { customCommands: state.selectedCustomCommands ) let worktrees = state.repositories.worktreesForInfoWatcher() + let openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) let shouldRestoreLayout = state.launchRestoreMode == .restoreLayout && state.repositories.snapshotPersistencePhase == .active @@ -472,6 +483,9 @@ struct AppFeature { .run { _ in await worktreeInfoWatcher.send(.setWorktrees(worktrees)) }, + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(openedWorktreeIDs)) + }, ] if shouldRestoreLayout { effects.append( @@ -491,6 +505,9 @@ struct AppFeature { .run { _ in await worktreeInfoWatcher.send(.setWorktrees(worktrees)) }, + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(openedWorktreeIDs)) + }, ] if shouldRestoreLayout { effects.append( @@ -1371,13 +1388,31 @@ struct AppFeature { // marks its worktree as Shelf-visible. Layout restore in // particular only calls `selectWorktree` for the one active // worktree; other restored worktrees only surface here. - return .send(.repositories(.markWorktreeOpened(worktreeID))) + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + if state.repositories.worktree(for: worktreeID) != nil { + openedWorktreeIDs.insert(worktreeID) + } + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeOpened(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) case .terminalEvent(.tabClosed(let worktreeID, let remainingTabs)): // Closing the last tab retires the book from the Shelf. Other // closes are routine and need no Reducer-side bookkeeping. guard remainingTabs == 0 else { return .none } - return .send(.repositories(.markWorktreeClosed(worktreeID))) + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + openedWorktreeIDs.remove(worktreeID) + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeClosed(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) case .terminalEvent(.focusChanged(_, let surfaceID)): // Keep the Active Agents panel's keyboard-navigation anchor in sync with diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 58ff8718..79d9cc03 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,11 +1,22 @@ +import CoreServices import Darwin import Dispatch import Foundation +@MainActor +protocol WorktreeFileEventMonitoring: AnyObject { + func cancel() +} + @MainActor final class WorktreeInfoWatcherManager { typealias WorktreePhaseOffset = @Sendable (Worktree.ID, Duration) -> Duration typealias RepositoryPhaseOffset = @Sendable (URL, Duration) -> Duration + typealias WorktreeFileEventMonitorFactory = + @MainActor @Sendable ( + _ worktree: Worktree, + _ onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? private struct HeadWatcher { let headURL: URL @@ -28,7 +39,7 @@ final class WorktreeInfoWatcherManager { let initialDelay: Duration let immediate: Bool let forceReschedule: Bool - let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event + let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event? } private struct RefreshTiming: Equatable { @@ -37,19 +48,25 @@ final class WorktreeInfoWatcherManager { } private let filesChangedDebounceInterval: Duration + private let lineChangesEventDebounceInterval: Duration + private let lineChangesSafetyRefreshInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration private let refreshTiming: RefreshTiming private let lineChangePhaseOffset: WorktreePhaseOffset private let pullRequestPhaseOffset: RepositoryPhaseOffset + private let worktreeFileEventMonitorFactory: WorktreeFileEventMonitorFactory private let sleep: @Sendable (Duration) async throws -> Void private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] + private var worktreeFileEventMonitors: [Worktree.ID: WorktreeFileEventMonitoring] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var pullRequestTasks: [URL: RefreshTask] = [:] - private var lineChangeTasks: [Worktree.ID: RefreshTask] = [:] + private var lineChangeSafetyTasks: [Worktree.ID: RefreshTask] = [:] + private var lineChangeRefreshTasks: [Worktree.ID: Task] = [:] private var deferredLineChangeIDs: Set = [] + private var openedWorktreeIDs: Set = [] private var hasCompletedInitialWorktreeLoad = false private var selectedWorktreeID: Worktree.ID? private var pullRequestTrackingEnabled = true @@ -60,16 +77,23 @@ final class WorktreeInfoWatcherManager { focusedInterval: Duration = .seconds(30), unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), + lineChangesEventDebounceInterval: Duration = .seconds(30), + lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), lineChangePhaseOffset: @escaping WorktreePhaseOffset = WorktreeInfoWatcherManager.defaultLineChangePhaseOffset, pullRequestPhaseOffset: @escaping RepositoryPhaseOffset = WorktreeInfoWatcherManager.defaultPullRequestPhaseOffset, + worktreeFileEventMonitorFactory: @escaping WorktreeFileEventMonitorFactory = + WorktreeInfoWatcherManager.defaultWorktreeFileEventMonitorFactory, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval + self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval + self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown self.lineChangePhaseOffset = lineChangePhaseOffset self.pullRequestPhaseOffset = pullRequestPhaseOffset + self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory self.sleep = { duration in try await clock.sleep(for: duration) } @@ -79,8 +103,12 @@ final class WorktreeInfoWatcherManager { switch command { case .setWorktrees(let worktrees): setWorktrees(worktrees) + case .setOpenedWorktreeIDs(let worktreeIDs): + setOpenedWorktreeIDs(worktreeIDs) case .setSelectedWorktreeID(let worktreeID): setSelectedWorktreeID(worktreeID) + case .refreshLineChanges: + scheduleLineChangesRefreshForAllWorktrees() case .setPullRequestTrackingEnabled(let isEnabled): setPullRequestTrackingEnabled(isEnabled) case .stop: @@ -106,6 +134,7 @@ final class WorktreeInfoWatcherManager { } if !removedIDs.isEmpty { deferredLineChangeIDs.subtract(removedIDs) + openedWorktreeIDs.subtract(removedIDs) } let newIDs = desiredIDs.subtracting(currentIDs) if !newIDs.isEmpty && !isInitialWorktreeLoad { @@ -114,10 +143,12 @@ final class WorktreeInfoWatcherManager { self.worktrees = worktreesByID for worktree in worktrees { configureWatcher(for: worktree) - updateLineChangeSchedule( - worktreeID: worktree.id, - immediate: isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) - ) + if isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) { + emitLineChangesChanged(worktreeID: worktree.id) + } else if newIDs.contains(worktree.id) { + scheduleLineChangesRefresh(worktreeID: worktree.id, delay: deferredLineChangesRefreshDelay(for: worktree)) + } + syncLineChangesActivity(for: worktree.id) } if isInitialWorktreeLoad { hasCompletedInitialWorktreeLoad = true @@ -138,6 +169,18 @@ final class WorktreeInfoWatcherManager { } } + private func setOpenedWorktreeIDs(_ worktreeIDs: Set) { + let validIDs = worktreeIDs.intersection(worktrees.keys) + guard validIDs != openedWorktreeIDs else { + return + } + let affectedIDs = openedWorktreeIDs.symmetricDifference(validIDs) + openedWorktreeIDs = validIDs + for worktreeID in affectedIDs { + syncLineChangesActivity(for: worktreeID) + } + } + private func setSelectedWorktreeID(_ worktreeID: Worktree.ID?) { guard selectedWorktreeID != worktreeID else { return @@ -147,10 +190,11 @@ final class WorktreeInfoWatcherManager { selectedWorktreeID = worktreeID let nextRepository = worktreeID.flatMap { worktrees[$0]?.repositoryRootURL } if let previousWorktreeID { - updateLineChangeSchedule(worktreeID: previousWorktreeID, immediate: false) + syncLineChangesActivity(for: previousWorktreeID) } if let worktreeID { - updateLineChangeSchedule(worktreeID: worktreeID, immediate: true) + emitLineChangesChanged(worktreeID: worktreeID) + syncLineChangesActivity(for: worktreeID) } if let previousRepository, previousRepository == nextRepository { updatePullRequestSchedule( @@ -248,13 +292,6 @@ final class WorktreeInfoWatcherManager { await MainActor.run { guard let self else { return } self.emit(.filesChanged(worktreeID: worktreeID)) - if !self.deferredLineChangeIDs.contains(worktreeID) { - self.updateLineChangeSchedule( - worktreeID: worktreeID, - immediate: false, - forceReschedule: true - ) - } } } filesDebounceTasks[worktreeID] = task @@ -291,10 +328,12 @@ final class WorktreeInfoWatcherManager { private func stopWatcher(for worktreeID: Worktree.ID) { stopHeadWatcher(for: worktreeID) + stopWorktreeFileEventMonitor(for: worktreeID) branchDebounceTasks.removeValue(forKey: worktreeID)?.cancel() filesDebounceTasks.removeValue(forKey: worktreeID)?.cancel() restartTasks.removeValue(forKey: worktreeID)?.cancel() - lineChangeTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeRefreshTasks.removeValue(forKey: worktreeID)?.cancel() } private func stopAll() { @@ -313,16 +352,25 @@ final class WorktreeInfoWatcherManager { for task in pullRequestTasks.values { task.task.cancel() } - for task in lineChangeTasks.values { + for task in lineChangeSafetyTasks.values { task.task.cancel() } + for task in lineChangeRefreshTasks.values { + task.cancel() + } + for monitor in worktreeFileEventMonitors.values { + monitor.cancel() + } headWatchers.removeAll() + worktreeFileEventMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() restartTasks.removeAll() pullRequestTasks.removeAll() - lineChangeTasks.removeAll() + lineChangeSafetyTasks.removeAll() + lineChangeRefreshTasks.removeAll() deferredLineChangeIDs.removeAll() + openedWorktreeIDs.removeAll() hasCompletedInitialWorktreeLoad = false cancelAllPullRequestSelectionCooldownTasks() worktrees.removeAll() @@ -410,28 +458,107 @@ final class WorktreeInfoWatcherManager { emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) } - private func updateLineChangeSchedule( + private func scheduleLineChangesRefreshForAllWorktrees() { + for worktree in worktrees.values { + scheduleLineChangesRefresh(worktreeID: worktree.id, delay: lineChangesRefreshDelay(for: worktree)) + } + } + + private func scheduleLineChangesRefresh( worktreeID: Worktree.ID, - immediate: Bool, - forceReschedule: Bool = false + delay: Duration ) { guard worktrees[worktreeID] != nil else { return } - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused - let shouldEmit = immediate && !deferredLineChangeIDs.contains(worktreeID) + lineChangeRefreshTasks[worktreeID]?.cancel() + let sleep = self.sleep + let task = Task { [weak self, sleep] in + do { + try await sleep(delay) + } catch { + return + } + await MainActor.run { + self?.lineChangeRefreshTasks.removeValue(forKey: worktreeID) + self?.emitLineChangesChanged(worktreeID: worktreeID) + } + } + lineChangeRefreshTasks[worktreeID] = task + } + + private func scheduleLineChangesDebouncedRefresh(worktreeID: Worktree.ID) { + scheduleLineChangesRefresh(worktreeID: worktreeID, delay: lineChangesEventDebounceInterval) + } + + private func updateLineChangesSafetySchedule(worktreeID: Worktree.ID) { + guard isLineChangesActive(worktreeID), worktrees[worktreeID] != nil else { + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + return + } let request = RepeatingTaskRequest( worktreeID: worktreeID, - interval: interval, - initialDelay: interval + lineChangePhaseOffset(worktreeID, interval), - immediate: shouldEmit, - forceReschedule: forceReschedule, + interval: lineChangesSafetyRefreshInterval, + initialDelay: lineChangesSafetyRefreshInterval, + immediate: false, + forceReschedule: false, makeEvent: { [weak self] worktreeID in - self?.deferredLineChangeIDs.remove(worktreeID) - return .filesChanged(worktreeID: worktreeID) + self?.makeLineChangesChangedEvent(worktreeID: worktreeID) } ) - updateRepeatingTask(request, tasks: &lineChangeTasks) + updateRepeatingTask(request, tasks: &lineChangeSafetyTasks) + } + + private func lineChangesRefreshDelay(for worktree: Worktree) -> Duration { + let interval = worktree.id == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + return lineChangePhaseOffset(worktree.id, interval) + } + + private func deferredLineChangesRefreshDelay(for worktree: Worktree) -> Duration { + let interval = worktree.id == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + return interval + lineChangePhaseOffset(worktree.id, interval) + } + + private func emitLineChangesChanged(worktreeID: Worktree.ID) { + guard let event = makeLineChangesChangedEvent(worktreeID: worktreeID) else { + return + } + emit(event) + } + + private func makeLineChangesChangedEvent(worktreeID: Worktree.ID) -> WorktreeInfoWatcherClient.Event? { + guard worktrees[worktreeID] != nil else { + return nil + } + deferredLineChangeIDs.remove(worktreeID) + return .filesChanged(worktreeID: worktreeID) + } + + private func syncLineChangesActivity(for worktreeID: Worktree.ID) { + guard let worktree = worktrees[worktreeID], isLineChangesActive(worktreeID) else { + stopWorktreeFileEventMonitor(for: worktreeID) + lineChangeSafetyTasks.removeValue(forKey: worktreeID)?.task.cancel() + return + } + startWorktreeFileEventMonitorIfNeeded(for: worktree) + updateLineChangesSafetySchedule(worktreeID: worktreeID) + } + + private func isLineChangesActive(_ worktreeID: Worktree.ID) -> Bool { + selectedWorktreeID == worktreeID || openedWorktreeIDs.contains(worktreeID) + } + + private func startWorktreeFileEventMonitorIfNeeded(for worktree: Worktree) { + guard worktreeFileEventMonitors[worktree.id] == nil else { + return + } + worktreeFileEventMonitors[worktree.id] = worktreeFileEventMonitorFactory(worktree) { [weak self] in + self?.scheduleLineChangesDebouncedRefresh(worktreeID: worktree.id) + } + } + + private func stopWorktreeFileEventMonitor(for worktreeID: Worktree.ID) { + worktreeFileEventMonitors.removeValue(forKey: worktreeID)?.cancel() } private func updateRepeatingTask( @@ -441,13 +568,17 @@ final class WorktreeInfoWatcherManager { let worktreeID = request.worktreeID if let existing = tasks[worktreeID], existing.interval == request.interval, !request.forceReschedule { if request.immediate { - emit(request.makeEvent(worktreeID)) + if let event = request.makeEvent(worktreeID) { + emit(event) + } } return } tasks[worktreeID]?.task.cancel() if request.immediate { - emit(request.makeEvent(worktreeID)) + if let event = request.makeEvent(worktreeID) { + emit(event) + } } let sleep = self.sleep let task = Task { [weak self, sleep] in @@ -458,7 +589,10 @@ final class WorktreeInfoWatcherManager { } while !Task.isCancelled { await MainActor.run { - self?.emit(request.makeEvent(worktreeID)) + guard let event = request.makeEvent(worktreeID) else { + return + } + self?.emit(event) } do { try await sleep(request.interval) @@ -484,6 +618,13 @@ final class WorktreeInfoWatcherManager { stablePhaseOffset(seed: repositoryRootURL.path(percentEncoded: false), interval: interval) } + private static func defaultWorktreeFileEventMonitorFactory( + worktree: Worktree, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? { + FSEventsWorktreeFileEventMonitor(rootURL: worktree.workingDirectory, onEvent: onEvent) + } + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { let intervalMilliseconds = durationMilliseconds(interval) guard intervalMilliseconds > 0 else { @@ -559,3 +700,70 @@ final class WorktreeInfoWatcherManager { return true } } + +private final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private var stream: FSEventStreamRef? + + init?( + rootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) { + self.onEvent = onEvent + let path = rootURL.path(percentEncoded: false) + var context = FSEventStreamContext( + version: 0, + info: nil, + retain: nil, + release: nil, + copyDescription: nil + ) + context.info = Unmanaged.passUnretained(self).toOpaque() + let callback: FSEventStreamCallback = { _, callbackInfo, _, _, _, _ in + guard let callbackInfo else { return } + let monitor = Unmanaged + .fromOpaque(callbackInfo) + .takeUnretainedValue() + Task { @MainActor in + monitor.onEvent() + } + } + stream = FSEventStreamCreate( + nil, + callback, + &context, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, + FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer + | kFSEventStreamCreateFlagWatchRoot + ) + ) + guard let stream else { + return nil + } + FSEventStreamScheduleWithRunLoop( + stream, + CFRunLoopGetMain(), + CFRunLoopMode.defaultMode.rawValue + ) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + return nil + } + } + + func cancel() { + guard let stream else { + return + } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } +} diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index d0ae2290..4f259215 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -71,6 +71,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: true, selected: true ) + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -78,6 +79,10 @@ struct AppFeatureTerminalSetupScriptTests { ) ) { AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } } await store.send(.terminalEvent(.tabCreated(worktreeID: worktree.id))) @@ -86,6 +91,37 @@ struct AppFeatureTerminalSetupScriptTests { } #expect(store.state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id)) await store.finish() + #expect(watcherCommands.value == [.setOpenedWorktreeIDs([worktree.id])]) + } + + @Test(.dependencies) func tabClosedSyncsOpenedWorktreesToInfoWatcher() async { + let worktree = makeWorktree() + var repositoriesState = makeRepositoriesState( + worktree: worktree, + pendingSetupScript: false, + selected: true + ) + repositoriesState.openedWorktreeIDs = [worktree.id] + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } + } + + await store.send(.terminalEvent(.tabClosed(worktreeID: worktree.id, remainingTabs: 0))) + await store.receive(\.repositories.markWorktreeClosed) { + $0.repositories.openedWorktreeIDs = [] + } + await store.finish() + #expect(watcherCommands.value == [.setOpenedWorktreeIDs([])]) } @Test(.dependencies) func setupScriptConsumedEventClearsPending() async { diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 6cdc309c..962b686b 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -25,7 +25,7 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempWorktree.tempRoot) } - @Test func defersLineChangesForWorktreesAddedAfterInitialLoad() async throws { + @Test func worktreesAddedAfterInitialLoadRefreshLineChangesOnce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let firstWorktree = try #require(tempRepository.worktrees.first) @@ -56,6 +56,10 @@ struct WorktreeInfoWatcherManagerTests { await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + await clock.advance(by: .milliseconds(80)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) @@ -113,6 +117,91 @@ struct WorktreeInfoWatcherManagerTests { await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1) + await clock.advance(by: .milliseconds(120)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func activeWorktreeRefreshesLineChangesAfterFileEventDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let activeWorktree = try #require(tempRepository.worktrees.first) + let inactiveWorktree = try #require(tempRepository.worktrees.dropFirst().first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + lineChangesEventDebounceInterval: .milliseconds(80), + lineChangesSafetyRefreshInterval: .seconds(3_600), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([activeWorktree, inactiveWorktree])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1) + #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1) + #expect(monitorStore.monitor(for: activeWorktree.id) == nil) + #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil) + + manager.handleCommand(.setOpenedWorktreeIDs([activeWorktree.id])) + let monitor = try #require(monitorStore.monitor(for: activeWorktree.id)) + #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil) + + monitor.emit() + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 2) + #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func activeWorktreeUsesSlowSafetyLineChangesRefresh() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let worktree = try #require(tempRepository.worktrees.first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + lineChangesSafetyRefreshInterval: .milliseconds(80), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([worktree])) + manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2) + manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) @@ -355,3 +444,39 @@ private func drainAsyncEvents(_ iterations: Int = 20) async { await Task.yield() } } + +@MainActor +private final class TestWorktreeFileEventMonitorStore { + private var monitors: [Worktree.ID: TestWorktreeFileEventMonitor] = [:] + + func makeMonitor( + worktree: Worktree, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeFileEventMonitoring? { + let monitor = TestWorktreeFileEventMonitor(onEvent: onEvent) + monitors[worktree.id] = monitor + return monitor + } + + func monitor(for worktreeID: Worktree.ID) -> TestWorktreeFileEventMonitor? { + monitors[worktreeID] + } +} + +@MainActor +private final class TestWorktreeFileEventMonitor: WorktreeFileEventMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private(set) var isCanceled = false + + init(onEvent: @escaping @MainActor @Sendable () -> Void) { + self.onEvent = onEvent + } + + func emit() { + onEvent() + } + + func cancel() { + isCanceled = true + } +} -- 2.51.2 From 6722d3c421000d43baf22d638cb10f7f7df5288b Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 23:52:33 +0900 Subject: [PATCH 236/528] Add cross-repo batched pull request GraphQL API Introduce batchPullRequestsAcrossRepositories on GithubCLIClient that accepts a host plus a list of (owner, repo, branches) requests and issues a single multi-repository GraphQL query, aliasing each repo and its branches to fold N per-repo requests into one gh subprocess. The response decoder routes top-level errors[] entries back to the owning repository so callers can surface partial failures (e.g. a single repo with revoked permission) without dropping data for the other repos in the batch. Chunks of 15 repos with a concurrency cap of 3 protect against the undocumented GraphQL resource limits introduced in September 2025. --- .../Github/CrossRepoPullRequestResponse.swift | 110 ++++ supacode/Clients/Github/GithubCLIClient.swift | 380 +++++++++++++ supacodeTests/GithubCLIClientTests.swift | 510 ++++++++++++++++++ 3 files changed, 1000 insertions(+) create mode 100644 supacode/Clients/Github/CrossRepoPullRequestResponse.swift diff --git a/supacode/Clients/Github/CrossRepoPullRequestResponse.swift b/supacode/Clients/Github/CrossRepoPullRequestResponse.swift new file mode 100644 index 00000000..1644ccfc --- /dev/null +++ b/supacode/Clients/Github/CrossRepoPullRequestResponse.swift @@ -0,0 +1,110 @@ +import Foundation + +nonisolated struct CrossRepoPullRequestResponse: Decodable { + let repositories: [String: CrossRepoPullRequestPayload] + let errors: [CrossRepoPullRequestResponseError] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: TopLevelKey.self) + self.errors = + (try? container.decode([CrossRepoPullRequestResponseError].self, forKey: .errors)) ?? [] + if container.contains(.data), + let dataContainer = try? container.nestedContainer( + keyedBy: CrossRepoDynamicKey.self, + forKey: .data + ) + { + var dict: [String: CrossRepoPullRequestPayload] = [:] + for key in dataContainer.allKeys { + if (try? dataContainer.decodeNil(forKey: key)) == true { + continue + } + if let payload = try? dataContainer.decode( + CrossRepoPullRequestPayload.self, forKey: key + ) { + dict[key.stringValue] = payload + } + } + self.repositories = dict + } else { + self.repositories = [:] + } + } + + func failedAliases() -> Set { + var aliases: Set = [] + for error in errors { + guard let first = error.path.first else { + continue + } + aliases.insert(first) + } + return aliases + } + + private enum TopLevelKey: String, CodingKey { + case data + case errors + } +} + +nonisolated struct CrossRepoPullRequestPayload: Decodable { + let pullRequestsByAlias: [String: GithubGraphQLPullRequestResponse.PullRequestConnection] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CrossRepoDynamicKey.self) + var dict: [String: GithubGraphQLPullRequestResponse.PullRequestConnection] = [:] + for key in container.allKeys { + dict[key.stringValue] = try container.decode( + GithubGraphQLPullRequestResponse.PullRequestConnection.self, + forKey: key + ) + } + self.pullRequestsByAlias = dict + } +} + +nonisolated struct CrossRepoPullRequestResponseError: Decodable { + let message: String? + let path: [String] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + self.message = try container.decodeIfPresent(String.self, forKey: .message) + var components: [String] = [] + if container.contains(.path), + var unkeyed = try? container.nestedUnkeyedContainer(forKey: .path) + { + while !unkeyed.isAtEnd { + if let value = try? unkeyed.decode(String.self) { + components.append(value) + } else if let value = try? unkeyed.decode(Int.self) { + components.append("\(value)") + } else { + _ = try? unkeyed.decodeNil() + } + } + } + self.path = components + } + + private enum Keys: String, CodingKey { + case message + case path + } +} + +nonisolated struct CrossRepoDynamicKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 11223f71..0ec09683 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -40,11 +40,41 @@ extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { } } +nonisolated struct RepoKey: Hashable, Sendable { + let owner: String + let repo: String +} + +nonisolated struct CrossRepoPullRequestRequest: Sendable, Hashable { + let owner: String + let repo: String + let branches: [String] + + var key: RepoKey { + RepoKey(owner: owner, repo: repo) + } +} + +nonisolated struct CrossRepoPullRequestResult: Sendable { + let successByRepo: [RepoKey: [String: GithubPullRequest]] + let failedRepos: [RepoKey: GithubCLIError] + + init( + successByRepo: [RepoKey: [String: GithubPullRequest]] = [:], + failedRepos: [RepoKey: GithubCLIError] = [:] + ) { + self.successByRepo = successByRepo + self.failedRepos = failedRepos + } +} + struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] + var batchPullRequestsAcrossRepositories: + @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> CrossRepoPullRequestResult var mergePullRequest: @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy) async throws -> Void var closePullRequest: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void var markPullRequestReady: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void @@ -65,6 +95,7 @@ extension GithubCLIClient: DependencyKey { resolveRemoteInfo: resolveRemoteInfoFetcher(shell: shell, resolver: resolver), latestRun: latestRunFetcher(shell: shell, resolver: resolver), batchPullRequests: batchPullRequestsFetcher(shell: shell, resolver: resolver), + batchPullRequestsAcrossRepositories: batchPullRequestsAcrossRepositoriesFetcher(shell: shell, resolver: resolver), mergePullRequest: mergePullRequestFetcher(shell: shell, resolver: resolver), closePullRequest: closePullRequestFetcher(shell: shell, resolver: resolver), markPullRequestReady: markPullRequestReadyFetcher(shell: shell, resolver: resolver), @@ -81,6 +112,7 @@ extension GithubCLIClient: DependencyKey { resolveRemoteInfo: { _ in nil }, latestRun: { _, _ in nil }, batchPullRequests: { _, _, _, _ in [:] }, + batchPullRequestsAcrossRepositories: { _, _ in CrossRepoPullRequestResult() }, mergePullRequest: { _, _, _, _ in }, closePullRequest: { _, _, _ in }, markPullRequestReady: { _, _, _ in }, @@ -296,6 +328,354 @@ nonisolated private func batchPullRequestsFetcher( } } +nonisolated private let crossRepoBatchAliasLimit = 15 +nonisolated private let crossRepoBatchMaxConcurrentRequests = 3 +nonisolated private let crossRepoBatchLogger = SupaLogger("BPR") + +nonisolated private struct CrossRepoChunkOutcome: Sendable { + let successByRepo: [RepoKey: [String: GithubPullRequest]] + let failedRepos: [RepoKey: GithubCLIError] +} + +nonisolated private func batchPullRequestsAcrossRepositoriesFetcher( + shell: ShellClient, + resolver: GithubCLIExecutableResolver +) -> @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> CrossRepoPullRequestResult { + { host, requests in + let cleaned = sanitizeCrossRepoRequests(requests) + guard !cleaned.isEmpty else { + return CrossRepoPullRequestResult() + } + let chunks = makeCrossRepoChunks(cleaned, chunkSize: crossRepoBatchAliasLimit) + // [BPR] remove after manual verification + crossRepoBatchLogger.debug( + "batch start host=\(host) repos=\(cleaned.count) chunks=\(chunks.count)" + ) + let outcomes = try await loadCrossRepoChunks( + shell: shell, + resolver: resolver, + host: host, + chunks: chunks + ) + return mergeCrossRepoChunkResults(outcomes) + } +} + +nonisolated private func sanitizeCrossRepoRequests( + _ requests: [CrossRepoPullRequestRequest] +) -> [CrossRepoPullRequestRequest] { + var sanitized: [CrossRepoPullRequestRequest] = [] + for request in requests { + var seen = Set() + let branches = request.branches.filter { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && seen.insert(value).inserted + } + guard !branches.isEmpty else { + continue + } + sanitized.append( + CrossRepoPullRequestRequest(owner: request.owner, repo: request.repo, branches: branches) + ) + } + return sanitized +} + +nonisolated private func makeCrossRepoChunks( + _ requests: [CrossRepoPullRequestRequest], + chunkSize: Int +) -> [[CrossRepoPullRequestRequest]] { + guard !requests.isEmpty else { + return [] + } + var chunks: [[CrossRepoPullRequestRequest]] = [] + var index = 0 + while index < requests.count { + let end = min(index + chunkSize, requests.count) + chunks.append(Array(requests[index.. [CrossRepoChunkOutcome] { + try await withThrowingTaskGroup(of: (Int, CrossRepoChunkOutcome).self) { group in + var nextChunkIndex = 0 + let initialCount = min(crossRepoBatchMaxConcurrentRequests, chunks.count) + while nextChunkIndex < initialCount { + let chunkIndex = nextChunkIndex + let chunk = chunks[chunkIndex] + group.addTask { + let outcome = try await fetchCrossRepoChunk( + shell: shell, + resolver: resolver, + host: host, + chunk: chunk, + chunkIndex: chunkIndex + ) + return (chunkIndex, outcome) + } + nextChunkIndex += 1 + } + var resultsByIndex: [Int: CrossRepoChunkOutcome] = [:] + while let (chunkIndex, outcome) = try await group.next() { + resultsByIndex[chunkIndex] = outcome + if nextChunkIndex < chunks.count { + let candidateIndex = nextChunkIndex + let candidateChunk = chunks[candidateIndex] + group.addTask { + let outcome = try await fetchCrossRepoChunk( + shell: shell, + resolver: resolver, + host: host, + chunk: candidateChunk, + chunkIndex: candidateIndex + ) + return (candidateIndex, outcome) + } + nextChunkIndex += 1 + } + } + return (0.. CrossRepoPullRequestResult { + var success: [RepoKey: [String: GithubPullRequest]] = [:] + var failed: [RepoKey: GithubCLIError] = [:] + for outcome in outcomes { + for (key, prs) in outcome.successByRepo { + success[key] = prs + } + for (key, error) in outcome.failedRepos { + failed[key] = error + } + } + return CrossRepoPullRequestResult(successByRepo: success, failedRepos: failed) +} + +nonisolated private func fetchCrossRepoChunk( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + host: String, + chunk: [CrossRepoPullRequestRequest], + chunkIndex: Int +) async throws -> CrossRepoChunkOutcome { + let plan = makeCrossRepoBatchQuery(requests: chunk) + // [BPR] remove after manual verification + crossRepoBatchLogger.debug( + "chunk[\(chunkIndex)] dispatch repos=\(plan.repoAliases.count) host=\(host)" + ) + let output = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "api", + "graphql", + "--hostname", + host, + "-f", + "query=\(plan.query)", + ], + repoRoot: nil + ) + guard !output.isEmpty else { + var failed: [RepoKey: GithubCLIError] = [:] + for key in plan.repoAliases.values { + failed[key] = .commandFailed("Empty response from gh api graphql") + } + return CrossRepoChunkOutcome(successByRepo: [:], failedRepos: failed) + } + let data = Data(output.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let response = try decoder.decode(CrossRepoPullRequestResponse.self, from: data) + + let failedAliases = response.failedAliases() + var success: [RepoKey: [String: GithubPullRequest]] = [:] + var failed: [RepoKey: GithubCLIError] = [:] + for (alias, key) in plan.repoAliases { + if failedAliases.contains(alias) { + // [BPR] remove after manual verification + crossRepoBatchLogger.debug("chunk[\(chunkIndex)] partial-error alias=\(alias) repo=\(key.owner)/\(key.repo)") + failed[key] = .commandFailed("Partial GraphQL error for \(key.owner)/\(key.repo)") + continue + } + guard let payload = response.repositories[alias] else { + failed[key] = .commandFailed("Missing response payload for \(key.owner)/\(key.repo)") + continue + } + let branchAliasMap = plan.branchAliasesByRepo[alias] ?? [:] + let prs = rankCrossRepoPullRequests( + pullRequestsByAlias: payload.pullRequestsByAlias, + aliasMap: branchAliasMap, + owner: key.owner, + repo: key.repo + ) + success[key] = prs + } + // [BPR] remove after manual verification + crossRepoBatchLogger.debug( + "chunk[\(chunkIndex)] done success=\(success.count) failed=\(failed.count)" + ) + return CrossRepoChunkOutcome(successByRepo: success, failedRepos: failed) +} + +nonisolated private struct CrossRepoBatchQueryPlan: Sendable { + let query: String + let repoAliases: [String: RepoKey] + let branchAliasesByRepo: [String: [String: String]] +} + +nonisolated private func makeCrossRepoBatchQuery( + requests: [CrossRepoPullRequestRequest] +) -> CrossRepoBatchQueryPlan { + var repoAliases: [String: RepoKey] = [:] + var branchAliasesByRepo: [String: [String: String]] = [:] + var repoBlocks: [String] = [] + let orderBy = "orderBy: {field: UPDATED_AT, direction: DESC}" + for (repoIndex, request) in requests.enumerated() { + let repoAlias = "r\(repoIndex)" + repoAliases[repoAlias] = RepoKey(owner: request.owner, repo: request.repo) + var branchAliasMap: [String: String] = [:] + var selections: [String] = [] + for (branchIndex, branch) in request.branches.enumerated() { + let branchAlias = "\(repoAlias)_b\(branchIndex)" + branchAliasMap[branchAlias] = branch + let escapedBranch = escapeGraphQLString(branch) + let pullRequestsArgs = + "first: 5, states: [OPEN, MERGED], headRefName: \"\(escapedBranch)\", \(orderBy)" + let selection = """ + \(branchAlias): pullRequests(\(pullRequestsArgs)) { + nodes { + number + title + state + additions + deletions + isDraft + reviewDecision + mergeable + mergeStateStatus + url + updatedAt + headRefName + baseRefName + commits { + totalCount + } + author { + login + } + headRepository { + name + owner { login } + } + statusCheckRollup { + contexts(first: 100) { + nodes { + ... on CheckRun { + name + status + conclusion + startedAt + completedAt + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + createdAt + } + } + } + } + } + } + """ + selections.append(selection) + } + branchAliasesByRepo[repoAlias] = branchAliasMap + let escapedOwner = escapeGraphQLString(request.owner) + let escapedRepo = escapeGraphQLString(request.repo) + let block = """ + \(repoAlias): repository(owner: \"\(escapedOwner)\", name: \"\(escapedRepo)\") { + \(selections.joined(separator: "\n")) + } + """ + repoBlocks.append(block) + } + let query = """ + query { + \(repoBlocks.joined(separator: "\n")) + } + """ + return CrossRepoBatchQueryPlan( + query: query, + repoAliases: repoAliases, + branchAliasesByRepo: branchAliasesByRepo + ) +} + +nonisolated private func rankCrossRepoPullRequests( + pullRequestsByAlias: [String: GithubGraphQLPullRequestResponse.PullRequestConnection], + aliasMap: [String: String], + owner: String, + repo: String +) -> [String: GithubPullRequest] { + let normalizedOwner = owner.lowercased() + let normalizedRepo = repo.lowercased() + var results: [String: GithubPullRequest] = [:] + for (alias, connection) in pullRequestsByAlias { + guard let branch = aliasMap[alias] else { + continue + } + let upstreamCandidates = connection.nodes.filter { + $0.matches(owner: normalizedOwner, repo: normalizedRepo) + } + let candidates: [GithubGraphQLPullRequestResponse.PullRequestNode] + if !upstreamCandidates.isEmpty { + candidates = upstreamCandidates + } else { + let forkCandidates = connection.nodes.filter { + $0.headRepository != nil && $0.doesNotTargetSameBranch(branch) + } + candidates = + if !forkCandidates.isEmpty { + forkCandidates + } else { + connection.nodes.filter { + $0.headRepository == nil && $0.doesNotTargetSameBranch(branch) + } + } + } + if let node = candidates.max(by: { left, right in + let leftRank = left.stateRank + let rightRank = right.stateRank + if leftRank != rightRank { + return leftRank < rightRank + } + let leftDate = left.updatedAt ?? .distantPast + let rightDate = right.updatedAt ?? .distantPast + if leftDate != rightDate { + return leftDate < rightDate + } + return left.number < right.number + }) { + results[branch] = node.pullRequest + } + } + return results +} + nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index 98c004b9..846b3e56 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -268,6 +268,327 @@ struct GithubCLIClientTests { #expect(snapshot.loginCallCount == 2) } + @Test func batchAcrossRepositoriesReturnsEmptyResultWhenNoRequests() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { _ in + ShellOutput(stdout: #"{"data":{}}"#, stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", []) + + #expect(result.successByRepo.isEmpty) + #expect(result.failedRepos.isEmpty) + let snapshot = await probe.snapshot() + #expect(snapshot.ghCallCount == 0) + #expect(snapshot.whichCallCount == 0) + } + + @Test func batchAcrossRepositoriesSkipsReposWithoutBranches() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: []), + CrossRepoPullRequestRequest(owner: "khoi", repo: "beta", branches: ["", " "]), + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + #expect(result.successByRepo.isEmpty) + #expect(result.failedRepos.isEmpty) + let snapshot = await probe.snapshot() + #expect(snapshot.ghCallCount == 0) + } + + @Test func batchAcrossRepositoriesSendsSingleGraphQLCallForMultipleRepos() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1", "feat-2"]), + CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-3"]), + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + #expect(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")] != nil) + #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) + #expect(result.failedRepos.isEmpty) + let snapshot = await probe.snapshot() + #expect(snapshot.ghCallCount == 1) + let invocations = await observedArguments.snapshot() + #expect(invocations.count == 1) + } + + @Test func batchAcrossRepositoriesUsesProvidedHostnameInGhArguments() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "octo", repo: "ghe-repo", branches: ["main"]) + ] + + _ = try await client.batchPullRequestsAcrossRepositories("github.enterprise.test", requests) + + let invocations = await observedArguments.snapshot() + #expect(invocations.count == 1) + let firstCall = try #require(invocations.first) + #expect(firstCall.contains("api")) + #expect(firstCall.contains("graphql")) + let hostnameIndex = try #require(firstCall.firstIndex(of: "--hostname")) + #expect(firstCall[firstCall.index(after: hostnameIndex)] == "github.enterprise.test") + } + + @Test func batchAcrossRepositoriesEmbedsOwnerAndRepoLiteralsInQuery() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]), + CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), + ] + + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let invocations = await observedArguments.snapshot() + let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) + let query = String(queryArgument.dropFirst("query=".count)) + #expect(query.contains(#"owner: "khoi""#)) + #expect(query.contains(#"name: "alpha""#)) + #expect(query.contains(#"owner: "supabit""#)) + #expect(query.contains(#"name: "beta""#)) + let structure = parseCrossRepoQuery(query) + #expect(structure.repos.count == 2) + #expect(structure.repos.allSatisfy { !$0.branchAliases.isEmpty }) + } + + @Test func batchAcrossRepositoriesDeduplicatesBranchesPerRepo() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest( + owner: "khoi", + repo: "alpha", + branches: ["feat-1", "feat-1", "feat-2", "", "feat-2"] + ) + ] + + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let invocations = await observedArguments.snapshot() + let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) + let structure = parseCrossRepoQuery(String(queryArgument.dropFirst("query=".count))) + let alpha = try #require(structure.repos.first { $0.repo == "alpha" }) + #expect(alpha.branchAliases.count == 2) + } + + @Test func batchAcrossRepositoriesSplitsAtAliasLimit() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = (0..<18).map { index in + CrossRepoPullRequestRequest(owner: "khoi", repo: "repo-\(index)", branches: ["main"]) + } + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let snapshot = await probe.snapshot() + #expect(snapshot.ghCallCount == 2) + let invocations = await observedArguments.snapshot() + #expect(invocations.count == 2) + let totalRepoBlocks = invocations.reduce(0) { partial, args in + guard let queryArg = args.first(where: { $0.hasPrefix("query=") }) else { + return partial + } + let query = String(queryArg.dropFirst("query=".count)) + return partial + parseCrossRepoQuery(query).repos.count + } + #expect(totalRepoBlocks == 18) + #expect(result.successByRepo.count == 18) + #expect(result.failedRepos.isEmpty) + } + + @Test func batchAcrossRepositoriesCapsConcurrencyAtThree() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + try await ContinuousClock().sleep(for: .milliseconds(80)) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = (0..<60).map { index in + CrossRepoPullRequestRequest(owner: "khoi", repo: "repo-\(index)", branches: ["main"]) + } + + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let snapshot = await probe.snapshot() + #expect(snapshot.ghCallCount == 4) + #expect(snapshot.maxInFlight == 3) + } + + @Test func batchAcrossRepositoriesRoutesPartialErrorsToFailedRepos() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + let stdout = crossRepoGraphQLResponse(for: arguments, failedRepoAliases: ["r1"]) + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]), + CrossRepoPullRequestRequest(owner: "ghost", repo: "missing", branches: ["main"]), + CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + #expect(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")] != nil) + #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) + #expect(result.failedRepos[RepoKey(owner: "ghost", repo: "missing")] != nil) + } + + @Test func batchAcrossRepositoriesRoutesFieldErrorPathToOwnRepo() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + let stdout = crossRepoGraphQLResponse( + for: arguments, + fieldErrorPaths: [["r0", "r0_b1"]] + ) + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1", "feat-2"]), + CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-3"]), + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + #expect(result.failedRepos[RepoKey(owner: "khoi", repo: "alpha")] != nil) + #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) + } + + @Test func batchAcrossRepositoriesReturnsAllFailedWhenAllReposErrored() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + let stdout = crossRepoGraphQLResponse( + for: arguments, + failedRepoAliases: ["r0", "r1"] + ) + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]), + CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + #expect(result.successByRepo.isEmpty) + #expect(result.failedRepos.count == 2) + } + + @Test func batchAcrossRepositoriesThrowsOnTotalShellFailure() async { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { _ in + throw ShellClientError( + command: "gh api graphql", + stdout: "", + stderr: "boom", + exitCode: 1 + ) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]) + ] + + do { + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + Issue.record("Expected batchPullRequestsAcrossRepositories to throw") + } catch let error as GithubCLIError { + switch error { + case .commandFailed: + break + case .outdated, .unavailable: + Issue.record("Unexpected GithubCLIError: \(error.localizedDescription)") + } + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } + + @Test func batchAcrossRepositoriesEscapesBranchSpecialCharacters() async throws { + let probe = GithubBatchShellProbe() + let observedArguments = ObservedArguments() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + await observedArguments.append(arguments) + return ShellOutput(stdout: crossRepoGraphQLResponse(for: arguments), stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest( + owner: "khoi", + repo: "alpha", + branches: [#"weird"branch"#, "tab\there", "back\\slash"] + ) + ] + + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let invocations = await observedArguments.snapshot() + let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) + let query = String(queryArgument.dropFirst("query=".count)) + #expect(query.contains(#"\""#)) + #expect(query.contains(#"\t"#)) + #expect(query.contains(#"\\"#)) + } + + @Test func batchAcrossRepositoriesSurfacesDecodedPullRequestData() async throws { + let probe = GithubBatchShellProbe() + let shell = makeBatchAcrossShellMock(probe: probe) { arguments in + let stdout = crossRepoGraphQLResponseWithSinglePR( + for: arguments, + prNumber: 42 + ) + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) + } + let client = GithubCLIClient.live(shell: shell) + let requests = [ + CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]) + ] + + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + + let alphaPRs = try #require(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")]) + let pullRequest = try #require(alphaPRs["feat-1"]) + #expect(pullRequest.number == 42) + } + @Test func executableResolutionIsSingleFlightAndReused() async { let probe = GithubBatchShellProbe() let shell = ShellClient( @@ -330,3 +651,192 @@ nonisolated private func queryAliases(from query: String) -> [String] { } return aliases } + +nonisolated struct CrossRepoQueryStructure { + let repos: [Entry] + + struct Entry { + let alias: String + let owner: String + let repo: String + let branchAliases: [String] + } +} + +nonisolated func parseCrossRepoQuery(_ query: String) -> CrossRepoQueryStructure { + guard + let repoRegex = try? NSRegularExpression( + pattern: #"(r\d+):\s*repository\(owner:\s*"([^"]+)",\s*name:\s*"([^"]+)"\)"# + ) + else { + return CrossRepoQueryStructure(repos: []) + } + let nsQuery = query as NSString + let queryRange = NSRange(location: 0, length: nsQuery.length) + var entries: [CrossRepoQueryStructure.Entry] = [] + let repoMatches = repoRegex.matches(in: query, range: queryRange) + for (matchIndex, match) in repoMatches.enumerated() { + let alias = nsQuery.substring(with: match.range(at: 1)) + let owner = nsQuery.substring(with: match.range(at: 2)) + let repo = nsQuery.substring(with: match.range(at: 3)) + let blockStart = match.range.location + match.range.length + let blockEnd: Int + if matchIndex + 1 < repoMatches.count { + blockEnd = repoMatches[matchIndex + 1].range.location + } else { + blockEnd = nsQuery.length + } + guard blockEnd > blockStart else { + continue + } + let blockRange = NSRange(location: blockStart, length: blockEnd - blockStart) + let block = nsQuery.substring(with: blockRange) + let branchAliases = matchedAliases(in: block, pattern: "\(alias)_b\\d+") + entries.append( + CrossRepoQueryStructure.Entry(alias: alias, owner: owner, repo: repo, branchAliases: branchAliases) + ) + } + return CrossRepoQueryStructure(repos: entries) +} + +nonisolated private func matchedAliases(in text: String, pattern: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + let range = NSRange(text.startIndex..() + var aliases: [String] = [] + for match in regex.matches(in: text, range: range) { + guard let matchRange = Range(match.range, in: text) else { + continue + } + let alias = String(text[matchRange]) + if seen.insert(alias).inserted { + aliases.append(alias) + } + } + return aliases +} + +nonisolated func crossRepoGraphQLResponse( + for arguments: [String], + failedRepoAliases: Set = [], + fieldErrorPaths: [[String]] = [] +) -> String { + guard let queryArgument = arguments.first(where: { $0.hasPrefix("query=") }) else { + return #"{"data":null}"# + } + let query = String(queryArgument.dropFirst("query=".count)) + let structure = parseCrossRepoQuery(query) + var dataEntries: [String] = [] + for entry in structure.repos { + if failedRepoAliases.contains(entry.alias) { + dataEntries.append(#""\#(entry.alias)":null"#) + continue + } + let aliasEntries = entry.branchAliases.map { #""\#($0)":{"nodes":[]}"# }.joined(separator: ",") + dataEntries.append(#""\#(entry.alias)":{\#(aliasEntries)}"#) + } + let dataJSON = "{\(dataEntries.joined(separator: ","))}" + var errorEntries: [String] = [] + for alias in failedRepoAliases { + errorEntries.append( + #"{"path":["\#(alias)"],"message":"Could not resolve to a Repository","type":"NOT_FOUND"}"# + ) + } + for path in fieldErrorPaths { + let serializedPath = path.map { #""\#($0)""# }.joined(separator: ",") + errorEntries.append(#"{"path":[\#(serializedPath)],"message":"Field error"}"#) + } + if errorEntries.isEmpty { + return #"{"data":\#(dataJSON)}"# + } + let errorsJSON = "[\(errorEntries.joined(separator: ","))]" + return #"{"data":\#(dataJSON),"errors":\#(errorsJSON)}"# +} + +actor ObservedArguments { + private var calls: [[String]] = [] + + func append(_ arguments: [String]) { + calls.append(arguments) + } + + func snapshot() -> [[String]] { + calls + } +} + +nonisolated func crossRepoGraphQLResponseWithSinglePR( + for arguments: [String], + prNumber: Int +) -> String { + guard let queryArgument = arguments.first(where: { $0.hasPrefix("query=") }) else { + return #"{"data":{}}"# + } + let structure = parseCrossRepoQuery(String(queryArgument.dropFirst("query=".count))) + var repositoryPayload: [String: Any] = [:] + for (entryIndex, entry) in structure.repos.enumerated() { + var aliasPayload: [String: Any] = [:] + for (branchIndex, alias) in entry.branchAliases.enumerated() { + let node: [String: Any] = [ + "number": prNumber + entryIndex * 100 + branchIndex, + "title": "PR", + "state": "OPEN", + "additions": 1, + "deletions": 1, + "isDraft": false, + "reviewDecision": NSNull(), + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "url": "https://example.com/pr", + "updatedAt": NSNull(), + "headRefName": NSNull(), + "baseRefName": "main", + "commits": ["totalCount": 1], + "author": ["login": "khoi"], + "headRepository": NSNull(), + "statusCheckRollup": NSNull(), + ] + aliasPayload[alias] = ["nodes": [node]] + } + repositoryPayload[entry.alias] = aliasPayload + } + let body: [String: Any] = ["data": repositoryPayload] + guard let data = try? JSONSerialization.data(withJSONObject: body), + let json = String(bytes: data, encoding: .utf8) + else { + return #"{"data":{}}"# + } + return json +} + +nonisolated func makeBatchAcrossShellMock( + probe: GithubBatchShellProbe, + responseBuilder: @escaping @Sendable (_ arguments: [String]) async throws -> ShellOutput +) -> ShellClient { + ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + await probe.recordWhichCall() + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, _, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.recordLoginCall() + _ = await probe.beginGhCall() + do { + let output = try await responseBuilder(arguments) + await probe.endGhCall() + return output + } catch { + await probe.endGhCall() + throw error + } + } + ) +} -- 2.51.2 From 6c64f3b6b27c347b61e5803de5b3f21991a541a5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 23:52:42 +0900 Subject: [PATCH 237/528] Add PullRequestRefreshCoordinator for batched PR refresh The coordinator sits between reducer effects and GithubCLIClient. It buckets enqueued refresh requests by host, debounces a short window (250ms) to coalesce concurrent enqueues into a single batched GraphQL call, and serializes per-host work with an in-flight lock so a new enqueue arriving mid-flight is queued and flushed immediately after the prior call completes. Failure handling has two layers: per-repo partial errors surfaced by the batched API fall back to the legacy per-repo batchPullRequests path for the affected repo only, and a 6-second soft timeout on the whole batch falls back the entire host to per-repo so a single slow repo cannot stall the others. Coordinator outcomes are delivered through a result handler closure, keeping the coordinator free of TCA dependencies and easy to unit test with a TestClock. --- .../PullRequestRefreshCoordinator.swift | 297 +++++++++ .../PullRequestRefreshCoordinatorTests.swift | 579 ++++++++++++++++++ 2 files changed, 876 insertions(+) create mode 100644 supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift create mode 100644 supacodeTests/PullRequestRefreshCoordinatorTests.swift diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift new file mode 100644 index 00000000..f3360387 --- /dev/null +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -0,0 +1,297 @@ +import ComposableArchitecture +import Foundation + +@MainActor +final class PullRequestRefreshCoordinator { + nonisolated struct Request: Equatable, Sendable { + let repositoryID: Repository.ID + let repositoryRootURL: URL + let host: String + let owner: String + let repo: String + let branches: [String] + let worktreeIDs: [Worktree.ID] + } + + nonisolated enum Outcome: Sendable, Equatable { + case refreshed( + repositoryID: Repository.ID, + repositoryRootURL: URL, + worktreeIDs: [Worktree.ID], + prsByBranch: [String: GithubPullRequest] + ) + case failed( + repositoryID: Repository.ID, + worktreeIDs: [Worktree.ID], + message: String + ) + } + + private let githubCLI: GithubCLIClient + private let clock: any Clock + private let debounceWindow: Duration + private let softTimeout: Duration + private let aliasLimit: Int + private let resultHandler: @MainActor (Outcome) -> Void + + private var pendingByHost: [String: [Repository.ID: Request]] = [:] + private var flushTaskByHost: [String: Task] = [:] + private var inflightHosts: Set = [] + private var queuedByHost: [String: [Repository.ID: Request]] = [:] + + private let logger = SupaLogger("BPR") + + init( + githubCLI: GithubCLIClient, + clock: any Clock, + debounceWindow: Duration = .milliseconds(250), + softTimeout: Duration = .seconds(6), + aliasLimit: Int = 15, + resultHandler: @MainActor @escaping (Outcome) -> Void + ) { + self.githubCLI = githubCLI + self.clock = clock + self.debounceWindow = debounceWindow + self.softTimeout = softTimeout + self.aliasLimit = aliasLimit + self.resultHandler = resultHandler + } + + func enqueue(_ request: Request) { + // Sanitize branches at the gate: silently drop a request that contributes nothing. + let cleanedBranches = request.branches.filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + guard !cleanedBranches.isEmpty else { + return + } + let normalized = Request( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + host: request.host, + owner: request.owner, + repo: request.repo, + branches: cleanedBranches, + worktreeIDs: request.worktreeIDs + ) + + if inflightHosts.contains(normalized.host) { + // [BPR] remove after manual verification + logger.debug("queue host=\(normalized.host) repo=\(normalized.owner)/\(normalized.repo) (inflight)") + mergeRequest(normalized, into: &queuedByHost) + return + } + + mergeRequest(normalized, into: &pendingByHost) + // [BPR] remove after manual verification + logger.debug("enqueue host=\(normalized.host) repo=\(normalized.owner)/\(normalized.repo)") + rescheduleDebounce(forHost: normalized.host) + } + + func cancelHost(_ host: String) { + flushTaskByHost.removeValue(forKey: host)?.cancel() + pendingByHost.removeValue(forKey: host) + queuedByHost.removeValue(forKey: host) + } + + func reset() { + for (_, task) in flushTaskByHost { + task.cancel() + } + flushTaskByHost.removeAll() + pendingByHost.removeAll() + queuedByHost.removeAll() + inflightHosts.removeAll() + } + + private func mergeRequest( + _ request: Request, + into bucket: inout [String: [Repository.ID: Request]] + ) { + var hostBucket = bucket[request.host] ?? [:] + if let existing = hostBucket[request.repositoryID] { + var seen = Set(existing.branches) + var combined = existing.branches + for branch in request.branches where seen.insert(branch).inserted { + combined.append(branch) + } + var workseen = Set(existing.worktreeIDs) + var workCombined = existing.worktreeIDs + for worktreeID in request.worktreeIDs where workseen.insert(worktreeID).inserted { + workCombined.append(worktreeID) + } + hostBucket[request.repositoryID] = Request( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + host: request.host, + owner: request.owner, + repo: request.repo, + branches: combined, + worktreeIDs: workCombined + ) + } else { + hostBucket[request.repositoryID] = request + } + bucket[request.host] = hostBucket + } + + private func rescheduleDebounce(forHost host: String) { + flushTaskByHost.removeValue(forKey: host)?.cancel() + let task = Task { [weak self, debounceWindow, clock] in + do { + try await clock.sleep(for: debounceWindow) + } catch { + return + } + await self?.flush(host: host) + } + flushTaskByHost[host] = task + } + + private func flush(host: String) async { + flushTaskByHost.removeValue(forKey: host) + guard let bucket = pendingByHost.removeValue(forKey: host), !bucket.isEmpty else { + return + } + inflightHosts.insert(host) + let requests = Array(bucket.values) + // [BPR] remove after manual verification + logger.debug("flush host=\(host) repos=\(requests.count)") + await processBatch(host: host, requests: requests) + inflightHosts.remove(host) + if let queued = queuedByHost.removeValue(forKey: host), !queued.isEmpty { + pendingByHost[host, default: [:]].merge(queued) { _, new in new } + // [BPR] remove after manual verification + logger.debug("drain-queued host=\(host) repos=\(queued.count)") + await flush(host: host) + } + } + + private func processBatch(host: String, requests: [Request]) async { + let crossRepoRequests = requests.map { + CrossRepoPullRequestRequest(owner: $0.owner, repo: $0.repo, branches: $0.branches) + } + do { + let result = try await runBatchWithTimeout(host: host, requests: crossRepoRequests) + let requestsByKey = Dictionary( + uniqueKeysWithValues: requests.map { (RepoKey(owner: $0.owner, repo: $0.repo), $0) } + ) + for (key, prsByBranch) in result.successByRepo { + guard let request = requestsByKey[key] else { + continue + } + resultHandler( + .refreshed( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + worktreeIDs: request.worktreeIDs, + prsByBranch: prsByBranch + ) + ) + } + for (key, _) in result.failedRepos { + guard let request = requestsByKey[key] else { + continue + } + // [BPR] remove after manual verification + logger.debug("fallback partial repo=\(key.owner)/\(key.repo)") + await fallbackPerRepo(request) + } + } catch { + // [BPR] remove after manual verification + logger.debug("fallback batch host=\(host) error=\(String(describing: error))") + for request in requests { + await fallbackPerRepo(request) + } + } + } + + private func runBatchWithTimeout( + host: String, + requests: [CrossRepoPullRequestRequest] + ) async throws -> CrossRepoPullRequestResult { + try await withThrowingTaskGroup(of: BatchTimeoutOutcome.self) { group in + let githubCLI = self.githubCLI + let softTimeout = self.softTimeout + let clock = self.clock + group.addTask { + let value = try await githubCLI.batchPullRequestsAcrossRepositories(host, requests) + return .completed(value) + } + group.addTask { + try await clock.sleep(for: softTimeout) + return .timedOut + } + defer { group.cancelAll() } + while let outcome = try await group.next() { + switch outcome { + case .completed(let value): + return value + case .timedOut: + throw PullRequestRefreshCoordinatorError.softTimeout + } + } + throw PullRequestRefreshCoordinatorError.softTimeout + } + } + + private func fallbackPerRepo(_ request: Request) async { + do { + let prs = try await githubCLI.batchPullRequests( + request.host, + request.owner, + request.repo, + request.branches + ) + resultHandler( + .refreshed( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + worktreeIDs: request.worktreeIDs, + prsByBranch: prs + ) + ) + } catch { + resultHandler( + .failed( + repositoryID: request.repositoryID, + worktreeIDs: request.worktreeIDs, + message: String(describing: error) + ) + ) + } + } + + private enum BatchTimeoutOutcome: Sendable { + case completed(CrossRepoPullRequestResult) + case timedOut + } +} + +enum PullRequestRefreshCoordinatorError: Error, Equatable { + case softTimeout +} + +nonisolated struct PullRequestRefreshCoordinatorClient: Sendable { + var enqueue: @Sendable (PullRequestRefreshCoordinator.Request) -> Void + var cancelHost: @Sendable (String) -> Void + var reset: @Sendable () -> Void + + nonisolated static let unimplemented = PullRequestRefreshCoordinatorClient( + enqueue: { _ in }, + cancelHost: { _ in }, + reset: {} + ) +} + +extension PullRequestRefreshCoordinatorClient: DependencyKey { + nonisolated static let liveValue = unimplemented + nonisolated static let testValue = unimplemented +} + +extension DependencyValues { + var pullRequestRefreshCoordinator: PullRequestRefreshCoordinatorClient { + get { self[PullRequestRefreshCoordinatorClient.self] } + set { self[PullRequestRefreshCoordinatorClient.self] = newValue } + } +} diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift new file mode 100644 index 00000000..2cb7d0f7 --- /dev/null +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -0,0 +1,579 @@ +import Clocks +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct PullRequestRefreshCoordinatorTests { + @Test func enqueueCoalescesMultipleReposIntoSingleBatchAfterDebounce() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha")) + coordinator.enqueue(request(repo: "beta")) + coordinator.enqueue(request(repo: "gamma")) + + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let batchedCalls = await probe.batchedCalls() + #expect(batchedCalls.count == 1) + #expect(batchedCalls.first?.requests.count == 3) + let refreshed = await outcomes.refreshedRepositories() + #expect(Set(refreshed) == Set(["alpha", "beta", "gamma"])) + } + + @Test func enqueueDoesNotFlushBeforeDebounceWindow() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha")) + await clock.advance(by: .milliseconds(100)) + await Task.yield() + + #expect(await probe.batchedCalls().isEmpty) + } + + @Test func multipleHostsTriggerIndependentBatches() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha", host: "github.com")) + coordinator.enqueue(request(repo: "beta", host: "github.com")) + coordinator.enqueue(request(repo: "gamma", host: "ghe.example")) + + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 2) + let hosts = Set(calls.map(\.host)) + #expect(hosts == ["github.com", "ghe.example"]) + } + + @Test func partialErrorTriggersPerRepoFallback() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var success: [RepoKey: [String: GithubPullRequest]] = [:] + var failed: [RepoKey: GithubCLIError] = [:] + for request in requests { + let key = RepoKey(owner: request.owner, repo: request.repo) + if request.repo == "beta" { + failed[key] = .commandFailed("not found") + } else { + success[key] = [:] + } + } + return CrossRepoPullRequestResult(successByRepo: success, failedRepos: failed) + }, + legacy: { _, _, repo, _ in + ["legacy-branch": makeFixturePullRequest(repo: repo)] + } + ) + + coordinator.enqueue(request(repo: "alpha")) + coordinator.enqueue(request(repo: "beta")) + + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let legacyCalls = await probe.legacyCalls() + #expect(legacyCalls.count == 1) + #expect(legacyCalls.first?.repo == "beta") + let refreshed = await outcomes.refreshedRepositories() + #expect(Set(refreshed) == ["alpha", "beta"]) + } + + @Test func batchedThrowFallsBackAllReposToLegacy() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, _ in + throw GithubCLIError.commandFailed("network down") + }, + legacy: { _, _, _, _ in + [:] + } + ) + + coordinator.enqueue(request(repo: "alpha")) + coordinator.enqueue(request(repo: "beta")) + + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let legacyCalls = await probe.legacyCalls() + #expect(Set(legacyCalls.map(\.repo)) == ["alpha", "beta"]) + } + + @Test func legacyFailureSurfacesAsFailedOutcome() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, _ in + throw GithubCLIError.commandFailed("offline") + }, + legacy: { _, _, _, _ in + throw GithubCLIError.commandFailed("legacy down too") + } + ) + + coordinator.enqueue(request(repo: "alpha")) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let failed = await outcomes.failedRepositories() + #expect(failed == ["alpha"]) + } + + @Test func inflightHostBuffersNewEnqueueAndFlushesAfterCompletion() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let release = AsyncStreamFlag() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + await release.wait() + return successResult(for: requests) + } + ) + + coordinator.enqueue(request(repo: "alpha")) + await clock.advance(by: .milliseconds(250)) + await waitUntil { await probe.batchedCalls().count == 1 } + + coordinator.enqueue(request(repo: "beta")) + await Task.yield() + #expect(await probe.batchedCalls().count == 1) + + await release.signal() + await waitUntil { await probe.batchedCalls().count == 2 } + let calls = await probe.batchedCalls() + #expect(calls.last?.requests.map(\.repo) == ["beta"]) + } + + @Test func enqueueIgnoresEmptyBranches() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha", branches: [])) + coordinator.enqueue(request(repo: "beta", branches: ["", " "])) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + + #expect(await probe.batchedCalls().isEmpty) + } + + @Test func enqueueMergesBranchListsForSameRepositoryWithinWindow() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha", branches: ["feat-1"])) + coordinator.enqueue(request(repo: "alpha", branches: ["feat-2", "feat-1"])) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 1) + let alphaRequest = try #require(calls.first?.requests.first { $0.repo == "alpha" }) + #expect(Set(alphaRequest.branches) == ["feat-1", "feat-2"]) + } + + @Test func softTimeoutFallsBackToLegacy() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let neverFinish = AsyncStreamFlag() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + debounce: .milliseconds(250), + softTimeout: .seconds(6), + batched: { _, _ in + await neverFinish.wait() + return CrossRepoPullRequestResult() + }, + legacy: { _, _, _, _ in [:] } + ) + + coordinator.enqueue(request(repo: "alpha")) + await clock.advance(by: .milliseconds(250)) + await waitUntil { await probe.batchedCalls().count >= 1 } + await clock.advance(by: .seconds(6)) + await waitUntil { await probe.legacyCalls().count >= 1 } + + let legacyCalls = await probe.legacyCalls() + #expect(legacyCalls.map(\.repo) == ["alpha"]) + await neverFinish.signal() + } + + @Test func resetCancelsPendingDebouncesAndInflight() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha")) + coordinator.reset() + await clock.advance(by: .milliseconds(250)) + await Task.yield() + + #expect(await probe.batchedCalls().isEmpty) + } + + @Test func cancelHostStopsPendingFlush() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha", host: "host-a")) + coordinator.enqueue(request(repo: "beta", host: "host-b")) + coordinator.cancelHost("host-a") + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.map(\.host) == ["host-b"]) + } + + @Test func batchedSuccessEmitsRefreshedWithBranchPRs() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var dict: [RepoKey: [String: GithubPullRequest]] = [:] + for request in requests { + let pullRequest = makeFixturePullRequest(repo: request.repo) + dict[RepoKey(owner: request.owner, repo: request.repo)] = ["feat-1": pullRequest] + } + return CrossRepoPullRequestResult(successByRepo: dict) + } + ) + + coordinator.enqueue(request(repo: "alpha", branches: ["feat-1"])) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let snapshots = await outcomes.snapshot() + let refresh = try #require( + snapshots.compactMap { snapshot -> (String, [String: GithubPullRequest])? in + if case .refreshed(let id, _, _, let prs) = snapshot { + return (id, prs) + } + return nil + } + .first + ) + #expect(refresh.0 == "alpha") + #expect(refresh.1["feat-1"]?.title == "PR-alpha") + } + + @Test func enqueueAfterFlushStartsNewDebounceWindow() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue(request(repo: "alpha")) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + #expect(await probe.batchedCalls().count == 1) + + coordinator.enqueue(request(repo: "beta")) + await clock.advance(by: .milliseconds(100)) + await Task.yield() + #expect(await probe.batchedCalls().count == 1) + await clock.advance(by: .milliseconds(150)) + await Task.yield() + await Task.yield() + #expect(await probe.batchedCalls().count == 2) + } + + @Test func legacyFallbackArgumentsMirrorOriginalRequest() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var failed: [RepoKey: GithubCLIError] = [:] + for request in requests { + failed[RepoKey(owner: request.owner, repo: request.repo)] = .commandFailed("nope") + } + return CrossRepoPullRequestResult(failedRepos: failed) + }, + legacy: { _, _, _, _ in [:] } + ) + + coordinator.enqueue( + request(repo: "alpha", host: "ghe.example", branches: ["feat-x", "feat-y"]) + ) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.legacyCalls() + let call = try #require(calls.first) + #expect(call.host == "ghe.example") + #expect(call.owner == "khoi") + #expect(call.repo == "alpha") + #expect(Set(call.branches) == ["feat-x", "feat-y"]) + } +} + +// MARK: - Helpers + +@MainActor +private func makeCoordinator( + probe: CoordinatorProbe, + clock: TestClock, + outcomes: OutcomeCollector, + debounce: Duration = .milliseconds(250), + softTimeout: Duration = .seconds(6), + aliasLimit: Int = 15, + batched: + @escaping @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> + CrossRepoPullRequestResult, + legacy: + @escaping @Sendable (String, String, String, [String]) async throws -> + [String: GithubPullRequest] = { _, _, _, _ in [:] } +) -> PullRequestRefreshCoordinator { + var client = GithubCLIClient.testValue + client.batchPullRequestsAcrossRepositories = { host, requests in + await probe.recordBatched(host: host, requests: requests) + return try await batched(host, requests) + } + client.batchPullRequests = { host, owner, repo, branches in + await probe.recordLegacy(host: host, owner: owner, repo: repo, branches: branches) + return try await legacy(host, owner, repo, branches) + } + return PullRequestRefreshCoordinator( + githubCLI: client, + clock: clock, + debounceWindow: debounce, + softTimeout: softTimeout, + aliasLimit: aliasLimit + ) { outcome in + Task { await outcomes.record(outcome) } + } +} + +@MainActor +private func waitUntil( + _ condition: @MainActor @escaping () async -> Bool, + maxIterations: Int = 500 +) async { + for _ in 0.. PullRequestRefreshCoordinator.Request { + PullRequestRefreshCoordinator.Request( + repositoryID: repo, + repositoryRootURL: URL(fileURLWithPath: "/tmp/\(repo)"), + host: host, + owner: "khoi", + repo: repo, + branches: branches, + worktreeIDs: ["\(repo)-wt"] + ) +} + +nonisolated private func successResult( + for requests: [CrossRepoPullRequestRequest] +) -> CrossRepoPullRequestResult { + var dict: [RepoKey: [String: GithubPullRequest]] = [:] + for request in requests { + dict[RepoKey(owner: request.owner, repo: request.repo)] = [:] + } + return CrossRepoPullRequestResult(successByRepo: dict) +} + +nonisolated func makeFixturePullRequest(repo: String) -> GithubPullRequest { + GithubPullRequest( + number: 1, + title: "PR-\(repo)", + state: "OPEN", + additions: 0, + deletions: 0, + isDraft: false, + reviewDecision: nil, + mergeable: nil, + mergeStateStatus: nil, + updatedAt: nil, + url: "https://example.com/\(repo)/pull/1", + headRefName: nil, + baseRefName: "main", + commitsCount: 1, + authorLogin: "khoi", + statusCheckRollup: nil + ) +} + +actor CoordinatorProbe { + struct BatchedCall: Sendable { + let host: String + let requests: [CrossRepoPullRequestRequest] + } + + struct LegacyCall: Sendable { + let host: String + let owner: String + let repo: String + let branches: [String] + } + + private var batched: [BatchedCall] = [] + private var legacy: [LegacyCall] = [] + + func recordBatched(host: String, requests: [CrossRepoPullRequestRequest]) { + batched.append(BatchedCall(host: host, requests: requests)) + } + + func recordLegacy(host: String, owner: String, repo: String, branches: [String]) { + legacy.append(LegacyCall(host: host, owner: owner, repo: repo, branches: branches)) + } + + func batchedCalls() -> [BatchedCall] { + batched + } + + func legacyCalls() -> [LegacyCall] { + legacy + } +} + +actor OutcomeCollector { + private var outcomes: [PullRequestRefreshCoordinator.Outcome] = [] + + func record(_ outcome: PullRequestRefreshCoordinator.Outcome) { + outcomes.append(outcome) + } + + func snapshot() -> [PullRequestRefreshCoordinator.Outcome] { + outcomes + } + + func refreshedRepositories() -> [String] { + outcomes.compactMap { + if case .refreshed(let id, _, _, _) = $0 { + return id + } + return nil + } + } + + func failedRepositories() -> [String] { + outcomes.compactMap { + if case .failed(let id, _, _) = $0 { + return id + } + return nil + } + } +} + +actor AsyncStreamFlag { + private var resumed = false + private var continuation: CheckedContinuation? + + func wait() async { + if resumed { + return + } + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } onCancel: { + Task { await self.cancel() } + } + } + + func signal() { + resumed = true + continuation?.resume() + continuation = nil + } + + private func cancel() { + continuation?.resume() + continuation = nil + } +} -- 2.51.2 From ff1884f86544d1660754e8f205af280027fb3087 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 23:52:51 +0900 Subject: [PATCH 238/528] Route PR refresh through coordinator behind feature flag When the githubBatchedPullRequestRefreshEnabled @Shared app-storage flag is on, the .available branch of repositoryPullRequestRefreshRequested hands off to the coordinator instead of the per-repo refreshRepositoryPullRequests effect. The in-flight / queued state machine is unchanged: outcomes arriving back via pullRequestRefreshBatchOutcome are translated into the existing repositoryPullRequestsLoaded and repositoryPullRequestRefreshCompleted actions, so the write-back path and downstream auto-archive/auto-delete behaviour remain untouched. The reducer caches resolved GithubRemoteInfo per repository in remoteInfoByRepositoryID. First refresh resolves and caches; subsequent refreshes skip the gh repo view subprocess entirely. Flag defaults to off so all existing behaviour is preserved until verified in production. --- ...epositoriesFeature+GithubIntegration.swift | 109 +++++++ .../Reducer/RepositoriesFeature.swift | 5 + ...atchedPullRequestRefreshReducerTests.swift | 279 ++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 supacodeTests/BatchedPullRequestRefreshReducerTests.swift diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 3ac455fe..0d91174f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -4,6 +4,7 @@ import Foundation private let unresolvedGithubRepositoryMessage = "Prowl could not determine which GitHub repository owns this pull request. Check the repository remote and try again." +nonisolated private let githubIntegrationLogger = SupaLogger("BPR") extension RepositoriesFeature { static func resolveGithubRemoteInfo( @@ -74,6 +75,15 @@ extension RepositoriesFeature { return .none } state.inFlightPullRequestRefreshRepositoryIDs.insert(repositoryID) + if state.batchedPullRequestRefreshEnabled { + return enqueueBatchedPullRequestRefresh( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + worktrees: worktrees, + branches: branches, + cachedRemoteInfo: state.remoteInfoByRepositoryID[repositoryID] + ) + } return refreshRepositoryPullRequests( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, @@ -624,6 +634,105 @@ extension RepositoriesFeature { case .setMergedWorktreeAction(let action): state.mergedWorktreeAction = action return .none + + case .cacheRemoteInfo(let repositoryID, let remoteInfo): + state.remoteInfoByRepositoryID[repositoryID] = remoteInfo + return .none + + case .pullRequestRefreshBatchOutcome(let outcome): + return reduceBatchOutcome(state: &state, outcome: outcome) + } + } + + private func reduceBatchOutcome( + state: inout State, + outcome: PullRequestRefreshCoordinator.Outcome + ) -> Effect { + switch outcome { + case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): + // [BPR] remove after manual verification + githubIntegrationLogger.debug( + "BPR outcome refreshed repo=\(repositoryID) worktrees=\(worktreeIDs.count) branches=\(prsByBranch.count)" + ) + guard let repository = state.repositories[id: repositoryID] else { + state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) + return .none + } + var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] + for worktreeID in worktreeIDs { + if let worktree = repository.worktrees[id: worktreeID] { + prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] + } + } + return .merge( + .send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: prsByWorktreeID + ) + ) + ), + .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + ) + case .failed(let repositoryID, _, let message): + // [BPR] remove after manual verification + githubIntegrationLogger.debug("BPR outcome failed repo=\(repositoryID) message=\(message)") + return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + } + } + + func enqueueBatchedPullRequestRefresh( + repositoryID: Repository.ID, + repositoryRootURL: URL, + worktrees: [Worktree], + branches: [String], + cachedRemoteInfo: GithubRemoteInfo? + ) -> Effect { + let worktreeIDs = worktrees.map(\.id) + let coordinatorClient = pullRequestRefreshCoordinator + let githubCLI = self.githubCLI + let gitClient = self.gitClient + // [BPR] remove after manual verification + githubIntegrationLogger.debug( + "BPR enqueue repo=\(repositoryID) branches=\(branches.count) cached=\(cachedRemoteInfo != nil)" + ) + return .run { send in + let resolvedRemoteInfo: GithubRemoteInfo? + if let cachedRemoteInfo { + resolvedRemoteInfo = cachedRemoteInfo + } else { + let info = await RepositoriesFeature.resolveGithubRemoteInfo( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + if let info { + await send( + .githubIntegration(.cacheRemoteInfo(repositoryID: repositoryID, remoteInfo: info)) + ) + } + resolvedRemoteInfo = info + } + guard let info = resolvedRemoteInfo else { + // [BPR] remove after manual verification + githubIntegrationLogger.debug( + "BPR enqueue abort repo=\(repositoryID) reason=remoteInfo-unresolved" + ) + await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + return + } + coordinatorClient.enqueue( + PullRequestRefreshCoordinator.Request( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + host: info.host, + owner: info.owner, + repo: info.repo, + branches: branches, + worktreeIDs: worktreeIDs + ) + ) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 74c268c5..6efe35cb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -169,6 +169,8 @@ struct RepositoriesFeature { case setGithubIntegrationEnabled(Bool) case setMergedWorktreeAction(MergedWorktreeAction?) case pullRequestAction(Worktree.ID, PullRequestAction) + case cacheRemoteInfo(repositoryID: Repository.ID, remoteInfo: GithubRemoteInfo) + case pullRequestRefreshBatchOutcome(PullRequestRefreshCoordinator.Outcome) } @CasePathable @@ -239,6 +241,8 @@ struct RepositoriesFeature { var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var inFlightPullRequestRefreshRepositoryIDs: Set = [] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] + var remoteInfoByRepositoryID: [Repository.ID: GithubRemoteInfo] = [:] + @Shared(.appStorage("githubBatchedPullRequestRefreshEnabled")) var batchedPullRequestRefreshEnabled = false var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 @@ -402,6 +406,7 @@ struct RepositoriesFeature { @Dependency(AnalyticsClient.self) var analyticsClient @Dependency(GitClientDependency.self) var gitClient @Dependency(GithubCLIClient.self) var githubCLI + @Dependency(PullRequestRefreshCoordinatorClient.self) var pullRequestRefreshCoordinator @Dependency(GithubIntegrationClient.self) var githubIntegration @Dependency(OpenURLClient.self) var openURLClient @Dependency(RepositoryPersistenceClient.self) var repositoryPersistence diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift new file mode 100644 index 00000000..0ad2be1d --- /dev/null +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -0,0 +1,279 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import IdentifiedCollections +import Sharing +import Testing + +@testable import supacode + +@MainActor +struct BatchedPullRequestRefreshReducerTests { + @Test func batchedFlagOffStillRunsLegacyBatchPullRequests() async { + let context = makeContext() + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.remoteInfo = { _ in context.remoteInfo } + $0.githubCLI.resolveRemoteInfo = { _ in context.remoteInfo } + $0.githubCLI.batchPullRequests = { _, _, _, _ in [:] } + $0.pullRequestRefreshCoordinator = .unimplemented + } + // Flag default false → exercises legacy code path; assert no coordinator call by relying on + // the unimplemented default coordinator which does nothing. + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + } + + @Test func batchedFlagOnDispatchesViaCoordinatorWhenRemoteInfoCached() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + var initialState = context.state + initialState.$batchedPullRequestRefreshEnabled.withLock { $0 = true } + initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("Should not resolve when cache hit") + return nil + } + $0.githubCLI.batchPullRequests = { _, _, _, _ in + Issue.record("Legacy batchPullRequests should not run on coordinator path") + return [:] + } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.finish() + + let snapshot = enqueued.value + #expect(snapshot.count == 1) + let request = try? #require(snapshot.first) + #expect(request?.host == "github.com") + #expect(request?.owner == "khoi") + #expect(request?.repo == "alpha") + #expect(request?.branches == ["main", "feature"]) + } + + @Test func batchedFlagOnResolvesAndCachesRemoteInfoOnFirstRun() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + var initialState = context.state + initialState.$batchedPullRequestRefreshEnabled.withLock { $0 = true } + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in context.remoteInfo } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not be used when gh resolve succeeds") + return nil + } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.cacheRemoteInfo) { + $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + } + await store.finish() + + #expect(enqueued.value.count == 1) + } + + @Test func coordinatorOutcomeRefreshedTranslatesToLoadedAndCompleted() async { + let context = makeContext() + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + let pullRequest = makePullRequestFixture() + let outcome = PullRequestRefreshCoordinator.Outcome.refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": pullRequest] + ) + + await store.send(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = pullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + } + + @Test func coordinatorOutcomeFailedClearsInFlight() async { + let context = makeContext() + var initialState = context.state + initialState.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + let outcome = PullRequestRefreshCoordinator.Outcome.failed( + repositoryID: context.repository.id, + worktreeIDs: context.worktreeIDs, + message: "boom" + ) + + await store.send(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + } + + @Test func cacheRemoteInfoStoresMappingInState() async { + let context = makeContext() + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = .unimplemented + } + + await store.send( + .githubIntegration( + .cacheRemoteInfo(repositoryID: context.repository.id, remoteInfo: context.remoteInfo) + ) + ) { + $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + } + await store.finish() + } +} + +// MARK: - Fixtures + +@MainActor +private func makeContext() -> RefreshTestContext { + let repoRoot = "/tmp/coord-repo" + let mainWorktree = Worktree( + id: repoRoot, + name: "main", + detail: "detail", + workingDirectory: URL(fileURLWithPath: repoRoot), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + let featureWorktree = Worktree( + id: "\(repoRoot)/feature", + name: "feature", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/feature"), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + let repository = Repository( + id: repoRoot, + rootURL: URL(fileURLWithPath: repoRoot), + name: "alpha", + worktrees: IdentifiedArrayOf(uniqueElements: [mainWorktree, featureWorktree]) + ) + var state = RepositoriesFeature.State() + state.repositories = [repository] + state.repositoryRoots = [repository.rootURL] + state.githubIntegrationAvailability = .available + return RefreshTestContext( + repoRoot: repoRoot, + repository: repository, + mainWorktree: mainWorktree, + featureWorktree: featureWorktree, + remoteInfo: GithubRemoteInfo(host: "github.com", owner: "khoi", repo: "alpha"), + state: state + ) +} + +@MainActor +private struct RefreshTestContext { + let repoRoot: String + let repository: Repository + let mainWorktree: Worktree + let featureWorktree: Worktree + let remoteInfo: GithubRemoteInfo + let state: RepositoriesFeature.State + + var repoRootURL: URL { URL(fileURLWithPath: repoRoot) } + var worktreeIDs: [Worktree.ID] { [mainWorktree.id, featureWorktree.id] } +} + +nonisolated private func makePullRequestFixture() -> GithubPullRequest { + GithubPullRequest( + number: 7, + title: "Coord PR", + state: "OPEN", + additions: 0, + deletions: 0, + isDraft: false, + reviewDecision: nil, + mergeable: nil, + mergeStateStatus: nil, + updatedAt: nil, + url: "https://example.com/coord-pr/7", + headRefName: "feature", + baseRefName: "main", + commitsCount: 1, + authorLogin: "khoi", + statusCheckRollup: nil + ) +} -- 2.51.2 From c294d1927f26ccff5ae89716fd801eb8cb113e64 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 28 May 2026 23:52:59 +0900 Subject: [PATCH 239/528] Instantiate PR refresh coordinator at app boot Create the PullRequestRefreshCoordinator alongside the other shared managers, hold it as @State, and bind its result handler to the app store through a weak SupacodeAppStoreBox so the closure can be constructed before the store itself exists. The coordinator client closures hop to the MainActor with Task { @MainActor in ... } since they may be invoked from any reducer effect context. --- supacode/App/supacodeApp.swift | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 9b4f1189..d6f5aa51 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -14,6 +14,11 @@ import Sentry import Sharing import SwiftUI +@MainActor +private final class SupacodeAppStoreBox { + weak var store: StoreOf? +} + private enum GhosttyCLI { static func argv(resolvedKeybindings: ResolvedKeybindingMap) -> [UnsafeMutablePointer?] { var args: [UnsafeMutablePointer?] = [] @@ -95,6 +100,7 @@ struct SupacodeApp: App { @State private var ghosttyShortcuts: GhosttyShortcutManager @State private var terminalManager: WorktreeTerminalManager @State private var worktreeInfoWatcher: WorktreeInfoWatcherManager + @State private var pullRequestRefreshCoordinator: PullRequestRefreshCoordinator @State private var commandKeyObserver: CommandKeyObserver @State private var cliSocketServer: CLISocketServer @State private var store: StoreOf @@ -186,6 +192,9 @@ struct SupacodeApp: App { _terminalManager = State(initialValue: terminalManager) let worktreeInfoWatcher = WorktreeInfoWatcherManager() _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) + let storeBox = SupacodeAppStoreBox() + let coordinator = Self.makePullRequestRefreshCoordinator(storeBox: storeBox) + _pullRequestRefreshCoordinator = State(initialValue: coordinator) let keyObserver = CommandKeyObserver() _commandKeyObserver = State(initialValue: keyObserver) var initialAppState = AppFeature.State(settings: SettingsFeature.State(settings: initialSettings)) @@ -207,8 +216,12 @@ struct SupacodeApp: App { worktreeInfoWatcher.eventStream() } ) + values.pullRequestRefreshCoordinator = Self.makePullRequestRefreshCoordinatorClient( + coordinator: coordinator + ) } _store = State(initialValue: appStore) + storeBox.store = appStore let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) _cliSocketServer = State(initialValue: cliServer) @@ -235,6 +248,42 @@ struct SupacodeApp: App { #endif } + @MainActor + private static func makePullRequestRefreshCoordinator( + storeBox: SupacodeAppStoreBox + ) -> PullRequestRefreshCoordinator { + PullRequestRefreshCoordinator( + githubCLI: .liveValue, + clock: ContinuousClock() + ) { outcome in + storeBox.store?.send( + .repositories(.githubIntegration(.pullRequestRefreshBatchOutcome(outcome))) + ) + } + } + + private static func makePullRequestRefreshCoordinatorClient( + coordinator: PullRequestRefreshCoordinator + ) -> PullRequestRefreshCoordinatorClient { + PullRequestRefreshCoordinatorClient( + enqueue: { request in + Task { @MainActor in + coordinator.enqueue(request) + } + }, + cancelHost: { host in + Task { @MainActor in + coordinator.cancelHost(host) + } + }, + reset: { + Task { @MainActor in + coordinator.reset() + } + } + ) + } + private static func makeMemoryWatchdog( appStore: StoreOf, terminalManager: WorktreeTerminalManager -- 2.51.2 From 1aca333f12e584c383bca0bbddba689ba4519092 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 00:01:03 +0900 Subject: [PATCH 240/528] Default PR refresh phase offset to zero With PullRequestRefreshCoordinator coalescing concurrent enqueues into a single batched GraphQL call, staggering per-repo emit ticks now fights the optimization: repos that fire outside the 250ms debounce window get bucketed into a follow-up batch instead of joining the main one. Drop the hash-based offset to zero. The injectable parameter stays so WorktreeInfoWatcherManagerTests can still feed deterministic staggered ticks where ordering matters. --- .../BusinessLogic/WorktreeInfoWatcherManager.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 79d9cc03..d10b07c4 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -615,7 +615,11 @@ final class WorktreeInfoWatcherManager { repositoryRootURL: URL, interval: Duration ) -> Duration { - stablePhaseOffset(seed: repositoryRootURL.path(percentEncoded: false), interval: interval) + // PR refresh is now coalesced by PullRequestRefreshCoordinator, so emitting + // every repo on the same tick maximises the chance of folding into a single + // batched GraphQL query. The injectable parameter is kept so tests can still + // stagger emits when they need to assert ordering. + .zero } private static func defaultWorktreeFileEventMonitorFactory( -- 2.51.2 From a57b6fed00fdc5d6dc39c533e4d0246ac5fb399a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 00:01:37 +0900 Subject: [PATCH 241/528] Make coordinator the canonical PR refresh path Three cleanups that collectively retire the legacy per-repo refresh path now that the coordinator has been validated: 1. Reverse resolveGithubRemoteInfo to try git first, gh as fallback. gitClient.remoteInfo already parses the local remote URL via `git remote get-url` (~10ms, no subprocess startup vs ~200ms for gh repo view). The batched GraphQL query is itself authoritative about repository existence, so the gh check was redundant. Cold start drops from N gh subprocesses to roughly zero. 2. Delete the githubBatchedPullRequestRefreshEnabled @Shared flag and the legacy refreshRepositoryPullRequests effect. .repositoryPullRequest RefreshRequested now unconditionally enqueues into the coordinator; write-back goes through pullRequestRefreshBatchOutcome -> existing repositoryPullRequestsLoaded / repositoryPullRequestRefreshCompleted actions, so the in-flight / queued state machine is unchanged. 3. Drop the RepositoriesFeatureTests case asserting the legacy synchronous load chain (.repositoryPullRequestsLoaded fired from within the same effect run). BatchedPullRequestRefreshReducerTests already exercises both the cache-hit and cache-miss coordinator paths and outcome translation, providing equivalent coverage. githubCLI.batchPullRequests stays as the per-repo fallback path that PullRequestRefreshCoordinator uses for partial errors and soft timeouts. --- ...epositoriesFeature+GithubIntegration.swift | 23 ++++---- .../Reducer/RepositoriesFeature.swift | 47 --------------- ...atchedPullRequestRefreshReducerTests.swift | 46 ++------------- supacodeTests/RepositoriesFeatureTests.swift | 58 +------------------ 4 files changed, 19 insertions(+), 155 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 0d91174f..7ed55f97 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -12,10 +12,15 @@ extension RepositoriesFeature { githubCLI: GithubCLIClient, gitClient: GitClientDependency ) async -> GithubRemoteInfo? { - if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { + // Parsing the local git remote URL is ~20x faster than spawning `gh repo view` + // (no gh subprocess, no network round trip). The batched GraphQL query that + // follows is itself authoritative about whether the repo exists on GitHub, so + // gh is only useful as a fallback for non-standard remotes that the regex in + // GitClient cannot parse. + if let remoteInfo = await gitClient.remoteInfo(repositoryRootURL) { return remoteInfo } - return await gitClient.remoteInfo(repositoryRootURL) + return await githubCLI.resolveRemoteInfo(repositoryRootURL) } } @@ -75,20 +80,12 @@ extension RepositoriesFeature { return .none } state.inFlightPullRequestRefreshRepositoryIDs.insert(repositoryID) - if state.batchedPullRequestRefreshEnabled { - return enqueueBatchedPullRequestRefresh( - repositoryID: repositoryID, - repositoryRootURL: repositoryRootURL, - worktrees: worktrees, - branches: branches, - cachedRemoteInfo: state.remoteInfoByRepositoryID[repositoryID] - ) - } - return refreshRepositoryPullRequests( + return enqueueBatchedPullRequestRefresh( repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktrees: worktrees, - branches: branches + branches: branches, + cachedRemoteInfo: state.remoteInfoByRepositoryID[repositoryID] ) case .unknown: queuePullRequestRefresh( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 6efe35cb..7ccc2929 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -242,7 +242,6 @@ struct RepositoriesFeature { var inFlightPullRequestRefreshRepositoryIDs: Set = [] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var remoteInfoByRepositoryID: [Repository.ID: GithubRemoteInfo] = [:] - @Shared(.appStorage("githubBatchedPullRequestRefreshEnabled")) var batchedPullRequestRefreshEnabled = false var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 @@ -1259,52 +1258,6 @@ struct RepositoriesFeature { } } - func refreshRepositoryPullRequests( - repositoryID: Repository.ID, - repositoryRootURL: URL, - worktrees: [Worktree], - branches: [String] - ) -> Effect { - let gitClient = gitClient - let githubCLI = githubCLI - return .run { send in - guard - let remoteInfo = await Self.resolveGithubRemoteInfo( - repositoryRootURL: repositoryRootURL, - githubCLI: githubCLI, - gitClient: gitClient - ) - else { - await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) - return - } - do { - let prsByBranch = try await githubCLI.batchPullRequests( - remoteInfo.host, - remoteInfo.owner, - remoteInfo.repo, - branches - ) - var pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] - for worktree in worktrees { - pullRequestsByWorktreeID[worktree.id] = prsByBranch[worktree.name] - } - await send( - .githubIntegration( - .repositoryPullRequestsLoaded( - repositoryID: repositoryID, - pullRequestsByWorktreeID: pullRequestsByWorktreeID - ) - ) - ) - } catch { - await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) - return - } - await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) - } - } - func loadPersistedRepositoryEntries( fallbackRoots: [URL] = [] ) async -> [PersistedRepositoryEntry] { diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 0ad2be1d..0daa3f27 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -2,49 +2,16 @@ import ComposableArchitecture import DependenciesTestSupport import Foundation import IdentifiedCollections -import Sharing import Testing @testable import supacode @MainActor struct BatchedPullRequestRefreshReducerTests { - @Test func batchedFlagOffStillRunsLegacyBatchPullRequests() async { - let context = makeContext() - let store = TestStore(initialState: context.state) { - RepositoriesFeature() - } withDependencies: { - $0.gitClient.remoteInfo = { _ in context.remoteInfo } - $0.githubCLI.resolveRemoteInfo = { _ in context.remoteInfo } - $0.githubCLI.batchPullRequests = { _, _, _, _ in [:] } - $0.pullRequestRefreshCoordinator = .unimplemented - } - // Flag default false → exercises legacy code path; assert no coordinator call by relying on - // the unimplemented default coordinator which does nothing. - - await store.send( - .worktreeInfoEvent( - .repositoryPullRequestRefresh( - repositoryRootURL: context.repoRootURL, - worktreeIDs: context.worktreeIDs - ) - ) - ) - await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] - } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) - await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { - $0.inFlightPullRequestRefreshRepositoryIDs = [] - } - await store.finish() - } - - @Test func batchedFlagOnDispatchesViaCoordinatorWhenRemoteInfoCached() async { + @Test func refreshDispatchesViaCoordinatorWhenRemoteInfoCached() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) var initialState = context.state - initialState.$batchedPullRequestRefreshEnabled.withLock { $0 = true } initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo let store = TestStore(initialState: initialState) { @@ -89,18 +56,17 @@ struct BatchedPullRequestRefreshReducerTests { #expect(request?.branches == ["main", "feature"]) } - @Test func batchedFlagOnResolvesAndCachesRemoteInfoOnFirstRun() async { + @Test func refreshResolvesAndCachesRemoteInfoOnFirstRun() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) - var initialState = context.state - initialState.$batchedPullRequestRefreshEnabled.withLock { $0 = true } + let initialState = context.state let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.githubCLI.resolveRemoteInfo = { _ in context.remoteInfo } - $0.gitClient.remoteInfo = { _ in - Issue.record("git remoteInfo should not be used when gh resolve succeeds") + $0.gitClient.remoteInfo = { _ in context.remoteInfo } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when git remote resolves") return nil } $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index c923b909..ccac9876 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3546,12 +3546,12 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } - $0.githubCLI.resolveRemoteInfo = { root in + $0.gitClient.remoteInfo = { root in #expect(root == URL(fileURLWithPath: repoRoot)) return upstreamRemoteInfo } - $0.gitClient.remoteInfo = { _ in - Issue.record("git remoteInfo should not be used when gh repo view succeeds") + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when git remote resolves") return nil } $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _ in @@ -3846,58 +3846,6 @@ struct RepositoriesFeatureTests { await store.finish() } - @Test func worktreeInfoEventRepositoryPullRequestRefreshPrefersResolvedRemoteInfo() async { - let repoRoot = "/tmp/repo" - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) - let featureWorktree = makeWorktree( - id: "\(repoRoot)/feature", - name: "feature", - repoRoot: repoRoot - ) - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") - let requestedRemoteInfos = LockIsolated<[GithubRemoteInfo]>([]) - var initialState = makeState(repositories: [repository]) - initialState.githubIntegrationAvailability = .available - let store = TestStore(initialState: initialState) { - RepositoriesFeature() - } withDependencies: { - $0.githubCLI.resolveRemoteInfo = { root in - #expect(root == URL(fileURLWithPath: repoRoot)) - return upstreamRemoteInfo - } - $0.gitClient.remoteInfo = { _ in - Issue.record("git remoteInfo should not be used when gh repo view succeeds") - return nil - } - $0.githubCLI.batchPullRequests = { host, owner, repo, branches in - #expect(branches == ["main", "feature"]) - requestedRemoteInfos.withValue { - $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) - } - return [:] - } - } - - await store.send( - .worktreeInfoEvent( - .repositoryPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) - ) - ) - await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] - } - await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) - await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { - $0.inFlightPullRequestRefreshRepositoryIDs = [] - } - #expect(requestedRemoteInfos.value == [upstreamRemoteInfo]) - await store.finish() - } - @Test func worktreeInfoEventRepositoryPullRequestRefreshQueuesWhileAvailabilityUnknown() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) -- 2.51.2 From 668162edac005eb078d3c584971ffff1d3cfe478 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 00:23:44 +0900 Subject: [PATCH 242/528] Fan out per-repo fallback concurrently and lengthen soft timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two recovery-path fixes observed in production logs: 1. The `for request in requests { await fallbackPerRepo(request) }` loop was serial, so a soft-timeout on a 14-repo batch ran 14 sequential per-repo legacy calls (~7s total) — the cure was slower than the disease. Replace with a TaskGroup so all fallback calls run concurrently, capped by the gh subprocess scheduler instead of our await chain. The same change applies to the partial-error fallback path. 2. The default 6s soft timeout fires intermittently on regular networks because GitHub's own GraphQL timeout is 10s, so a client-side 6s cap pre-empts the server before it has a chance to respond. Bump to 12s: still bounded so a wedged batch cannot block indefinitely, but lets the server resolve the query in nearly all normal cases before we give up. --- .../PullRequestRefreshCoordinator.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index f3360387..d9f4d2d7 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -45,7 +45,7 @@ final class PullRequestRefreshCoordinator { githubCLI: GithubCLIClient, clock: any Clock, debounceWindow: Duration = .milliseconds(250), - softTimeout: Duration = .seconds(6), + softTimeout: Duration = .seconds(12), aliasLimit: Int = 15, resultHandler: @MainActor @escaping (Outcome) -> Void ) { @@ -189,19 +189,27 @@ final class PullRequestRefreshCoordinator { ) ) } - for (key, _) in result.failedRepos { - guard let request = requestsByKey[key] else { - continue - } + let failedRequests = result.failedRepos.keys.compactMap { requestsByKey[$0] } + if !failedRequests.isEmpty { // [BPR] remove after manual verification - logger.debug("fallback partial repo=\(key.owner)/\(key.repo)") - await fallbackPerRepo(request) + logger.debug("fallback partial host=\(host) count=\(failedRequests.count)") + await fanOutFallback(failedRequests) } } catch { // [BPR] remove after manual verification logger.debug("fallback batch host=\(host) error=\(String(describing: error))") + await fanOutFallback(requests) + } + } + + private func fanOutFallback(_ requests: [Request]) async { + // Run per-repo fallback requests concurrently; serial awaits here would multiply + // a slow recovery path by the number of repos in the batch. + await withTaskGroup(of: Void.self) { group in for request in requests { - await fallbackPerRepo(request) + group.addTask { [weak self] in + await self?.fallbackPerRepo(request) + } } } } -- 2.51.2 From 383068b09cb98bee249cee71fe3049828c5d117f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 09:41:39 +0900 Subject: [PATCH 243/528] Remove BPR debug logging used for manual verification The [BPR]-tagged SupaLogger calls were added during initial rollout so the batched PR refresh path could be observed end-to-end against the real GitHub API. Manual verification of cold start, cache hit, batch flush, queued/inflight handling, and timeout/fallback recovery has been completed; drop the logging and the now-unused logger fields and chunkIndex parameter. --- supacode/Clients/Github/GithubCLIClient.swift | 24 +++---------------- .../PullRequestRefreshCoordinator.swift | 14 ----------- ...epositoriesFeature+GithubIntegration.swift | 17 +------------ 3 files changed, 4 insertions(+), 51 deletions(-) diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 0ec09683..08167c1b 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -330,7 +330,6 @@ nonisolated private func batchPullRequestsFetcher( nonisolated private let crossRepoBatchAliasLimit = 15 nonisolated private let crossRepoBatchMaxConcurrentRequests = 3 -nonisolated private let crossRepoBatchLogger = SupaLogger("BPR") nonisolated private struct CrossRepoChunkOutcome: Sendable { let successByRepo: [RepoKey: [String: GithubPullRequest]] @@ -347,10 +346,6 @@ nonisolated private func batchPullRequestsAcrossRepositoriesFetcher( return CrossRepoPullRequestResult() } let chunks = makeCrossRepoChunks(cleaned, chunkSize: crossRepoBatchAliasLimit) - // [BPR] remove after manual verification - crossRepoBatchLogger.debug( - "batch start host=\(host) repos=\(cleaned.count) chunks=\(chunks.count)" - ) let outcomes = try await loadCrossRepoChunks( shell: shell, resolver: resolver, @@ -415,8 +410,7 @@ nonisolated private func loadCrossRepoChunks( shell: shell, resolver: resolver, host: host, - chunk: chunk, - chunkIndex: chunkIndex + chunk: chunk ) return (chunkIndex, outcome) } @@ -433,8 +427,7 @@ nonisolated private func loadCrossRepoChunks( shell: shell, resolver: resolver, host: host, - chunk: candidateChunk, - chunkIndex: candidateIndex + chunk: candidateChunk ) return (candidateIndex, outcome) } @@ -465,14 +458,9 @@ nonisolated private func fetchCrossRepoChunk( shell: ShellClient, resolver: GithubCLIExecutableResolver, host: String, - chunk: [CrossRepoPullRequestRequest], - chunkIndex: Int + chunk: [CrossRepoPullRequestRequest] ) async throws -> CrossRepoChunkOutcome { let plan = makeCrossRepoBatchQuery(requests: chunk) - // [BPR] remove after manual verification - crossRepoBatchLogger.debug( - "chunk[\(chunkIndex)] dispatch repos=\(plan.repoAliases.count) host=\(host)" - ) let output = try await runGh( shell: shell, resolver: resolver, @@ -503,8 +491,6 @@ nonisolated private func fetchCrossRepoChunk( var failed: [RepoKey: GithubCLIError] = [:] for (alias, key) in plan.repoAliases { if failedAliases.contains(alias) { - // [BPR] remove after manual verification - crossRepoBatchLogger.debug("chunk[\(chunkIndex)] partial-error alias=\(alias) repo=\(key.owner)/\(key.repo)") failed[key] = .commandFailed("Partial GraphQL error for \(key.owner)/\(key.repo)") continue } @@ -521,10 +507,6 @@ nonisolated private func fetchCrossRepoChunk( ) success[key] = prs } - // [BPR] remove after manual verification - crossRepoBatchLogger.debug( - "chunk[\(chunkIndex)] done success=\(success.count) failed=\(failed.count)" - ) return CrossRepoChunkOutcome(successByRepo: success, failedRepos: failed) } diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index d9f4d2d7..ec89d6f9 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -39,8 +39,6 @@ final class PullRequestRefreshCoordinator { private var inflightHosts: Set = [] private var queuedByHost: [String: [Repository.ID: Request]] = [:] - private let logger = SupaLogger("BPR") - init( githubCLI: GithubCLIClient, clock: any Clock, @@ -76,15 +74,11 @@ final class PullRequestRefreshCoordinator { ) if inflightHosts.contains(normalized.host) { - // [BPR] remove after manual verification - logger.debug("queue host=\(normalized.host) repo=\(normalized.owner)/\(normalized.repo) (inflight)") mergeRequest(normalized, into: &queuedByHost) return } mergeRequest(normalized, into: &pendingByHost) - // [BPR] remove after manual verification - logger.debug("enqueue host=\(normalized.host) repo=\(normalized.owner)/\(normalized.repo)") rescheduleDebounce(forHost: normalized.host) } @@ -155,14 +149,10 @@ final class PullRequestRefreshCoordinator { } inflightHosts.insert(host) let requests = Array(bucket.values) - // [BPR] remove after manual verification - logger.debug("flush host=\(host) repos=\(requests.count)") await processBatch(host: host, requests: requests) inflightHosts.remove(host) if let queued = queuedByHost.removeValue(forKey: host), !queued.isEmpty { pendingByHost[host, default: [:]].merge(queued) { _, new in new } - // [BPR] remove after manual verification - logger.debug("drain-queued host=\(host) repos=\(queued.count)") await flush(host: host) } } @@ -191,13 +181,9 @@ final class PullRequestRefreshCoordinator { } let failedRequests = result.failedRepos.keys.compactMap { requestsByKey[$0] } if !failedRequests.isEmpty { - // [BPR] remove after manual verification - logger.debug("fallback partial host=\(host) count=\(failedRequests.count)") await fanOutFallback(failedRequests) } } catch { - // [BPR] remove after manual verification - logger.debug("fallback batch host=\(host) error=\(String(describing: error))") await fanOutFallback(requests) } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 7ed55f97..d91cc7af 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -4,7 +4,6 @@ import Foundation private let unresolvedGithubRepositoryMessage = "Prowl could not determine which GitHub repository owns this pull request. Check the repository remote and try again." -nonisolated private let githubIntegrationLogger = SupaLogger("BPR") extension RepositoriesFeature { static func resolveGithubRemoteInfo( @@ -647,10 +646,6 @@ extension RepositoriesFeature { ) -> Effect { switch outcome { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): - // [BPR] remove after manual verification - githubIntegrationLogger.debug( - "BPR outcome refreshed repo=\(repositoryID) worktrees=\(worktreeIDs.count) branches=\(prsByBranch.count)" - ) guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) return .none @@ -672,9 +667,7 @@ extension RepositoriesFeature { ), .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) - case .failed(let repositoryID, _, let message): - // [BPR] remove after manual verification - githubIntegrationLogger.debug("BPR outcome failed repo=\(repositoryID) message=\(message)") + case .failed(let repositoryID, _, _): return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) } } @@ -690,10 +683,6 @@ extension RepositoriesFeature { let coordinatorClient = pullRequestRefreshCoordinator let githubCLI = self.githubCLI let gitClient = self.gitClient - // [BPR] remove after manual verification - githubIntegrationLogger.debug( - "BPR enqueue repo=\(repositoryID) branches=\(branches.count) cached=\(cachedRemoteInfo != nil)" - ) return .run { send in let resolvedRemoteInfo: GithubRemoteInfo? if let cachedRemoteInfo { @@ -712,10 +701,6 @@ extension RepositoriesFeature { resolvedRemoteInfo = info } guard let info = resolvedRemoteInfo else { - // [BPR] remove after manual verification - githubIntegrationLogger.debug( - "BPR enqueue abort repo=\(repositoryID) reason=remoteInfo-unresolved" - ) await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } -- 2.51.2 From 3f7d6dbf63814112d4b4a9d40b5b60fa20f8ff50 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 09:47:57 +0900 Subject: [PATCH 244/528] Use dispatch queue for FSEvents --- .../BusinessLogic/WorktreeInfoWatcherManager.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 79d9cc03..35f9e48a 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -744,11 +744,7 @@ private final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitorin guard let stream else { return nil } - FSEventStreamScheduleWithRunLoop( - stream, - CFRunLoopGetMain(), - CFRunLoopMode.defaultMode.rawValue - ) + FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) guard FSEventStreamStart(stream) else { FSEventStreamInvalidate(stream) FSEventStreamRelease(stream) -- 2.51.2 From fc96e0986989fdf0d6f38686706a91f95ea92abe Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 10:33:13 +0900 Subject: [PATCH 245/528] Trim batched PR branches and surface partial GraphQL error detail Two Copilot-review follow-ups on the cross-repo batched PR path: - `sanitizeCrossRepoRequests` previously trimmed branches only to decide emptiness, then deduplicated and stored the original (potentially whitespace-padded) value. That meant "feat" and "feat " were treated as distinct branches and the trailing space was forwarded into the GraphQL `headRefName` argument. Now trim first, dedupe on the trimmed value, and store the trimmed value. - When `errors[]` entries marked a repo alias as failed, the surfaced `commandFailed` message was always the generic "Partial GraphQL error for owner/repo", discarding the GraphQL `message` and `path`. Replace `failedAliases() -> Set` with `errorMessagesByAlias() -> [String: String]` which preserves the underlying message (and field path for sub-repo errors) so that the per-repo fallback path logs and any future surfaced error UX have enough detail to be diagnosable. --- .../Github/CrossRepoPullRequestResponse.swift | 24 +++++++++++++++---- supacode/Clients/Github/GithubCLIClient.swift | 13 ++++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/supacode/Clients/Github/CrossRepoPullRequestResponse.swift b/supacode/Clients/Github/CrossRepoPullRequestResponse.swift index 1644ccfc..377ad3fd 100644 --- a/supacode/Clients/Github/CrossRepoPullRequestResponse.swift +++ b/supacode/Clients/Github/CrossRepoPullRequestResponse.swift @@ -31,15 +31,29 @@ nonisolated struct CrossRepoPullRequestResponse: Decodable { } } - func failedAliases() -> Set { - var aliases: Set = [] + func errorMessagesByAlias() -> [String: String] { + var messages: [String: String] = [:] for error in errors { - guard let first = error.path.first else { + guard let alias = error.path.first else { continue } - aliases.insert(first) + let description = describeError(error) + if let existing = messages[alias], !existing.isEmpty { + messages[alias] = "\(existing); \(description)" + } else { + messages[alias] = description + } + } + return messages + } + + private func describeError(_ error: CrossRepoPullRequestResponseError) -> String { + let message = error.message ?? "GraphQL error" + if error.path.count > 1 { + let field = error.path.dropFirst().joined(separator: ".") + return "\(message) (at \(field))" } - return aliases + return message } private enum TopLevelKey: String, CodingKey { diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 08167c1b..1f765d8f 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -362,9 +362,12 @@ nonisolated private func sanitizeCrossRepoRequests( var sanitized: [CrossRepoPullRequestRequest] = [] for request in requests { var seen = Set() - let branches = request.branches.filter { value in + let branches = request.branches.compactMap { value -> String? in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmed.isEmpty && seen.insert(value).inserted + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { + return nil + } + return trimmed } guard !branches.isEmpty else { continue @@ -486,12 +489,12 @@ nonisolated private func fetchCrossRepoChunk( decoder.dateDecodingStrategy = .iso8601 let response = try decoder.decode(CrossRepoPullRequestResponse.self, from: data) - let failedAliases = response.failedAliases() + let errorMessagesByAlias = response.errorMessagesByAlias() var success: [RepoKey: [String: GithubPullRequest]] = [:] var failed: [RepoKey: GithubCLIError] = [:] for (alias, key) in plan.repoAliases { - if failedAliases.contains(alias) { - failed[key] = .commandFailed("Partial GraphQL error for \(key.owner)/\(key.repo)") + if let detail = errorMessagesByAlias[alias] { + failed[key] = .commandFailed("GraphQL error for \(key.owner)/\(key.repo): \(detail)") continue } guard let payload = response.repositories[alias] else { -- 2.51.2 From 8b11e9fec21cfe8c7f5952b11673cea8617e8ad6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 10:33:20 +0900 Subject: [PATCH 246/528] Trim coordinator branches and drop unused aliasLimit parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot-review follow-ups on PullRequestRefreshCoordinator: - `enqueue` trimmed branches only to decide emptiness, then stored the original strings — same hazard as the client-side fix in the preceding commit (distinct treatment of "feat" and "feat ", padded GraphQL queries). Trim and store the trimmed value so request merging dedupes consistently. - `aliasLimit` was stored on the coordinator but never read; the actual GraphQL alias cap lives in `GithubCLIClient`'s crossRepoBatchAliasLimit constant. The parameter was misleading because tests could pass any value and observe no effect. Drop the init parameter, the stored property, and the test-helper override. --- .../BusinessLogic/PullRequestRefreshCoordinator.swift | 11 +++++------ .../PullRequestRefreshCoordinatorTests.swift | 4 +--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index ec89d6f9..0dca1139 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -31,7 +31,6 @@ final class PullRequestRefreshCoordinator { private let clock: any Clock private let debounceWindow: Duration private let softTimeout: Duration - private let aliasLimit: Int private let resultHandler: @MainActor (Outcome) -> Void private var pendingByHost: [String: [Repository.ID: Request]] = [:] @@ -44,21 +43,21 @@ final class PullRequestRefreshCoordinator { clock: any Clock, debounceWindow: Duration = .milliseconds(250), softTimeout: Duration = .seconds(12), - aliasLimit: Int = 15, resultHandler: @MainActor @escaping (Outcome) -> Void ) { self.githubCLI = githubCLI self.clock = clock self.debounceWindow = debounceWindow self.softTimeout = softTimeout - self.aliasLimit = aliasLimit self.resultHandler = resultHandler } func enqueue(_ request: Request) { - // Sanitize branches at the gate: silently drop a request that contributes nothing. - let cleanedBranches = request.branches.filter { - !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // Trim and drop whitespace-only entries so "feat" and "feat " do not get treated as + // distinct branches downstream and don't leak padding into the GraphQL headRefName. + let cleanedBranches = request.branches.compactMap { branch -> String? in + let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } guard !cleanedBranches.isEmpty else { return diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 2cb7d0f7..5dac9692 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -398,7 +398,6 @@ private func makeCoordinator( outcomes: OutcomeCollector, debounce: Duration = .milliseconds(250), softTimeout: Duration = .seconds(6), - aliasLimit: Int = 15, batched: @escaping @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> CrossRepoPullRequestResult, @@ -419,8 +418,7 @@ private func makeCoordinator( githubCLI: client, clock: clock, debounceWindow: debounce, - softTimeout: softTimeout, - aliasLimit: aliasLimit + softTimeout: softTimeout ) { outcome in Task { await outcomes.record(outcome) } } -- 2.51.2 From 2d3d68196de3241a199ee267438967e9252abbf3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 29 May 2026 19:38:33 +0900 Subject: [PATCH 247/528] Propagate Task cancellation into ShellClient processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellClient.runProcessStream used to spawn its worker on Task.detached and block on Process.waitUntilExit(), which means structured-concurrency timeouts that wrap a ShellClient call could not actually interrupt the running command — the catch path only ran after `gh` (or whatever shell program) terminated of its own accord. PullRequestRefreshCoordinator's soft timeout was the immediate motivation, but every shell-backed client inherits the same hole. Three changes wire cancellation through: 1. Replace `process.waitUntilExit()` with `waitForExit(of:)`, an async wait built on `process.terminationHandler` plus a CheckedContinuation. A `LockIsolated` guards against double-resume in the race where the process exits between `terminationHandler` assignment and the `isRunning` sanity check. 2. Wrap the wait in `withTaskCancellationHandler { … } onCancel: { process.terminate() }` so a cancelled worker Task immediately sends SIGTERM rather than waiting for natural exit. 3. Hold the running Process in a LockIsolated box and call `process.terminate()` plus `workerTask.cancel()` from the stream's `onTermination` handler, so when the stream consumer goes away (cancel, throw, explicit finish) the underlying process is torn down too. Switch the worker / pipe-reader Tasks from `.detached` to inheriting Tasks while we're here — they were already isolated by virtue of being created inside the stream closure, but the explicit form removes a small footgun. Tests cover the consumer-cancel and Task-cancel paths against a real `/bin/sleep 30`; the consumer used to block for the full 30 seconds, now it returns in well under a second. A regression test for a fast-exiting (`/bin/sh -c true`) command guards the terminationHandler/isRunning race. Closes #368. --- supacode/Clients/Shell/ShellClient.swift | 65 +++++++++++------ supacodeTests/ShellClientStreamingTests.swift | 69 +++++++++++++++++++ 2 files changed, 114 insertions(+), 20 deletions(-) diff --git a/supacode/Clients/Shell/ShellClient.swift b/supacode/Clients/Shell/ShellClient.swift index 94f0aeed..aca3adb3 100644 --- a/supacode/Clients/Shell/ShellClient.swift +++ b/supacode/Clients/Shell/ShellClient.swift @@ -168,7 +168,12 @@ nonisolated private func runProcessStream( currentDirectoryURL: URL? ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task.detached { + // Stored so onTermination can SIGTERM the process when the consumer of the + // stream goes away (Task cancellation, for-await throw, explicit finish). + // Without this hook structured-concurrency timeouts that wrap a ShellClient + // call would have to wait for the process to exit on its own. + let processBox = LockIsolated(nil) + let workerTask = Task { let outputAccumulator = ShellOutputAccumulator() let process = Process() process.executableURL = executableURL @@ -184,33 +189,24 @@ nonisolated private func runProcessStream( let command = ([executableURL.path(percentEncoded: false)] + arguments).joined(separator: " ") do { try process.run() - let stdoutTask = Task.detached { + processBox.withValue { $0 = process } + let stdoutTask = Task { for await line in lineStream(from: outputHandle) { await outputAccumulator.append(line, source: .stdout) - continuation.yield( - .line( - ShellStreamLine( - source: .stdout, - text: line - ) - ) - ) + continuation.yield(.line(ShellStreamLine(source: .stdout, text: line))) } } - let stderrTask = Task.detached { + let stderrTask = Task { for await line in lineStream(from: errorHandle) { await outputAccumulator.append(line, source: .stderr) - continuation.yield( - .line( - ShellStreamLine( - source: .stderr, - text: line - ) - ) - ) + continuation.yield(.line(ShellStreamLine(source: .stderr, text: line))) } } - process.waitUntilExit() + await withTaskCancellationHandler { + await waitForExit(of: process) + } onCancel: { + process.terminate() + } await stdoutTask.value await stderrTask.value let output = await outputAccumulator.output(exitCode: process.terminationStatus) @@ -231,6 +227,35 @@ nonisolated private func runProcessStream( continuation.finish(throwing: error) } } + continuation.onTermination = { _ in + processBox.withValue { $0?.terminate() } + workerTask.cancel() + } + } +} + +/// Waits asynchronously for `process` to exit using `terminationHandler` +/// instead of `process.waitUntilExit()` so the caller's Task cancellation +/// can be honoured. The handler is paired with a synchronous `isRunning` +/// check to cover the race where the process exits before the handler is +/// installed. +nonisolated private func waitForExit(of process: Process) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let resumed = LockIsolated(false) + let resumeOnce: @Sendable () -> Void = { + let shouldResume = resumed.withValue { (value: inout Bool) -> Bool in + guard !value else { return false } + value = true + return true + } + if shouldResume { + continuation.resume() + } + } + process.terminationHandler = { _ in resumeOnce() } + if !process.isRunning { + resumeOnce() + } } } diff --git a/supacodeTests/ShellClientStreamingTests.swift b/supacodeTests/ShellClientStreamingTests.swift index 0217b5a9..f0d95b35 100644 --- a/supacodeTests/ShellClientStreamingTests.swift +++ b/supacodeTests/ShellClientStreamingTests.swift @@ -127,6 +127,75 @@ struct ShellClientStreamingTests { #expect(streamedLines.contains(where: { $0.source == .stderr && $0.text == "err" })) } + @Test func cancellingRunStreamConsumerTerminatesProcessQuickly() async throws { + let shell = ShellClient.liveValue + let commandURL = URL(fileURLWithPath: "/bin/sleep") + let stream = shell.runStream(commandURL, ["30"], nil) + + let consumer = Task { + var lines: [ShellStreamLine] = [] + do { + for try await event in stream { + if case .line(let line) = event { + lines.append(line) + } + } + } catch { + // CancellationError or ShellClientError after SIGTERM both indicate + // the cancellation pathway is connected. + } + return lines + } + + try await Task.sleep(for: .milliseconds(120)) + + let start = ContinuousClock.now + consumer.cancel() + _ = await consumer.value + let elapsed = ContinuousClock.now - start + + #expect( + elapsed < .seconds(2), + "consumer cancel should propagate to the shell process; took \(elapsed)" + ) + } + + @Test func runReturnsQuicklyWhenCallingTaskIsCancelled() async { + let shell = ShellClient.liveValue + let commandURL = URL(fileURLWithPath: "/bin/sleep") + + let runTask = Task { + try await shell.run(commandURL, ["30"], nil) + } + + try? await Task.sleep(for: .milliseconds(120)) + + let start = ContinuousClock.now + runTask.cancel() + _ = await runTask.result + let elapsed = ContinuousClock.now - start + + #expect( + elapsed < .seconds(2), + "run() should propagate cancellation to the process; took \(elapsed)" + ) + } + + @Test func runStreamSucceedsForShortLivedProcessAfterCancellationFixes() async throws { + // Regression guard: terminationHandler / isRunning race in waitForExit + // must not deadlock or double-resume on fast-exiting processes. + let shell = ShellClient.liveValue + let commandURL = URL(fileURLWithPath: "/bin/sh") + let stream = shell.runStream(commandURL, ["-c", "true"], nil) + var finished: ShellOutput? + for try await event in stream { + if case .finished(let output) = event { + finished = output + } + } + #expect(finished?.exitCode == 0) + } + @Test func runLoginStreamForwardsParameters() async throws { let recorder = LoginStreamCallRecorder() let shell = ShellClient( -- 2.51.2 From 71aebdccf1326d3ca8bb2e1cd5d7d290cc94f2b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 00:16:00 +0900 Subject: [PATCH 248/528] Fix initial terminal surface context --- .../Terminal/Models/WorktreeTerminalState.swift | 5 +---- .../Infrastructure/Ghostty/GhosttySurfaceView.swift | 3 +++ supacodeTests/WorktreeTerminalManagerTests.swift | 13 +++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 5e83317a..5abcab91 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -286,10 +286,7 @@ final class WorktreeTerminalState { inheritingFromSurfaceId: UUID? = nil, workingDirectoryOverride: URL? = nil ) -> TerminalTabID? { - let context: ghostty_surface_context_e = - tabManager.tabs.isEmpty - ? GHOSTTY_SURFACE_CONTEXT_WINDOW - : GHOSTTY_SURFACE_CONTEXT_TAB + let context = GHOSTTY_SURFACE_CONTEXT_TAB let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() let title = "\(worktree.name) \(nextTabIndex())" let setupInput = setupScriptInput(setupScript: setupScript) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 88e975b8..e63c9c17 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -112,6 +112,9 @@ final class GhosttySurfaceView: NSView, Identifiable { private let envVarCount: Int private let fontSize: Float32 private let context: ghostty_surface_context_e + var surfaceContextForTesting: ghostty_surface_context_e { + context + } private let skipsSurfaceCreationForTesting: Bool private var trackingArea: NSTrackingArea? private var lastBackingSize: CGSize = .zero diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 751ccce6..17b44d6e 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,6 +1,7 @@ import ConcurrencyExtras import DependenciesTestSupport import Foundation +import GhosttyKit import Testing @testable import supacode @@ -88,6 +89,18 @@ struct WorktreeTerminalManagerTests { #expect(state.canCloseFocusedSurface == false) } + @Test func firstTabUsesTabSurfaceContext() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + let surface = try #require(state.surfaceView(for: surfaceId)) + + #expect(surface.surfaceContextForTesting == GHOSTTY_SURFACE_CONTEXT_TAB) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From 37a3001b8ca11e4e799d0985b197b6bdebb789d2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 00:28:27 +0900 Subject: [PATCH 249/528] Add terminal surface lifecycle diagnostics --- .../Models/WorktreeTerminalState.swift | 11 ++++ .../Ghostty/GhosttySurfaceView.swift | 51 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 5abcab91..904f7bbe 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -6,6 +6,7 @@ import Observation import Sharing private let terminalStateLogger = SupaLogger("TerminalState") +private let terminalLifecycleLogger = SupaLogger("TerminalLifecycle") private let activeAgentDetectionInterval: Duration = .milliseconds(300) private let idleAgentDetectionInterval: Duration = .seconds(2) @@ -668,6 +669,11 @@ final class WorktreeTerminalState { workingDirectoryOverride: workingDirectoryOverride, context: context ) + terminalLifecycleLogger.info( + "created surfaceID=\(surface.id.uuidString) tabID=\(tabId) worktreeID=\(worktree.id) " + + "context=\(context.rawValue) inheritedFrom=\(inheritingFromSurfaceId?.uuidString ?? "nil") " + + "workingDirectory=\((workingDirectoryOverride ?? worktree.workingDirectory).path(percentEncoded: false)) " + + "hasInitialInput=\(initialInput != nil)") let tree = SplitTree(view: surface) trees[tabId] = tree focusedSurfaceIdByTab[tabId] = surface.id @@ -1999,6 +2005,8 @@ final class WorktreeTerminalState { private func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } + terminalLifecycleLogger.info( + "removeTree tabID=\(tabId) surfaceIDs=\(tree.leaves().map { $0.id.uuidString })") for surface in tree.leaves() { surface.closeSurface() forgetSurface(surface.id) @@ -2152,6 +2160,9 @@ final class WorktreeTerminalState { } private func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { + terminalLifecycleLogger.info( + "handleCloseRequest surfaceID=\(view.id.uuidString) processAlive=\(processAlive) " + + "tracked=\(surfaces[view.id] != nil)") guard surfaces[view.id] != nil else { return } if processAlive { guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index e63c9c17..c1b5ad3f 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1,11 +1,13 @@ import AppKit import Carbon import CoreText +import Darwin import GhosttyKit import QuartzCore import SwiftUI private let surfaceLogger = SupaLogger("Surface") +private let surfaceLifecycleLogger = SupaLogger("SurfaceLifecycle") final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { @@ -332,6 +334,11 @@ final class GhosttySurfaceView: NSView, Identifiable { func closeSurface() { clearNotificationObservers() if let surface { + let childPID = ghostty_surface_pid(surface) + let foregroundProcessGroupID = ghostty_surface_foreground_process_group(surface) + let processExited = ghostty_surface_process_exited(surface) + logLifecycle( + "close begin childPID=\(childPID) foregroundPGID=\(foregroundProcessGroupID) processExited=\(processExited)") if let surfaceRef { runtime.unregisterSurface(surfaceRef) self.surfaceRef = nil @@ -341,6 +348,10 @@ final class GhosttySurfaceView: NSView, Identifiable { bridge.surface = nil occlusionState.reset() lastSurfaceFocus = nil + logLifecycle("close returned childPID=\(childPID)") + scheduleChildExitProbe(childPID: childPID) + } else { + logLifecycle("close skipped surface=nil") } } @@ -1058,6 +1069,14 @@ final class GhosttySurfaceView: NSView, Identifiable { } surface = ghostty_surface_new(app, &config) bridge.surface = surface + if let surface { + logLifecycle( + "created childPID=\(ghostty_surface_pid(surface)) " + + "foregroundPGID=\(ghostty_surface_foreground_process_group(surface)) " + + "processExited=\(ghostty_surface_process_exited(surface))") + } else { + logLifecycle("create failed") + } occlusionState.reset() lastSurfaceFocus = nil // A freshly created Ghostty surface defaults to focused (blinking cursor). @@ -1084,6 +1103,38 @@ final class GhosttySurfaceView: NSView, Identifiable { return 2.0 } + private func logLifecycle(_ message: String) { + surfaceLifecycleLogger.info( + "surfaceID=\(id.uuidString) context=\(context.rawValue) \(message)") + } + + private func scheduleChildExitProbe(childPID: Int32) { + guard childPID > 0 else { return } + let surfaceID = id.uuidString + let context = context.rawValue + Task { @MainActor in + try? await ContinuousClock().sleep(for: .milliseconds(500)) + Self.logChildExitProbe(surfaceID: surfaceID, context: context, childPID: childPID, delay: "0.5s") + try? await ContinuousClock().sleep(for: .milliseconds(1500)) + Self.logChildExitProbe(surfaceID: surfaceID, context: context, childPID: childPID, delay: "2.0s") + } + } + + private static func logChildExitProbe( + surfaceID: String, + context: UInt32, + childPID: Int32, + delay: String + ) { + surfaceLifecycleLogger.info( + "surfaceID=\(surfaceID) context=\(context) close probe delay=\(delay) childPID=\(childPID) " + + "alive=\(isProcessAlive(pid: pid_t(childPID)))") + } + + private static func isProcessAlive(pid: pid_t) -> Bool { + kill(pid, 0) == 0 || errno == EPERM + } + func setOcclusion(_ visible: Bool) { guard let surface else { guard skipsSurfaceCreationForTesting else { return } -- 2.51.2 From 47d29615bcb8c38945e0e1c4909f92cd9d567e66 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 00:41:05 +0900 Subject: [PATCH 250/528] Avoid recreating closed terminal tabs --- .../Terminal/Models/WorktreeTerminalState.swift | 4 ++++ supacodeTests/WorktreeTerminalManagerTests.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 904f7bbe..82ff1cdd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -659,6 +659,10 @@ final class WorktreeTerminalState { workingDirectoryOverride: URL? = nil, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB ) -> SplitTree { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { + terminalLifecycleLogger.info("skip splitTree for stale tabID=\(tabId)") + return SplitTree() + } if let existing = trees[tabId] { return existing } diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 17b44d6e..4d8ba37e 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -101,6 +101,22 @@ struct WorktreeTerminalManagerTests { #expect(surface.surfaceContextForTesting == GHOSTTY_SURFACE_CONTEXT_TAB) } + @Test func splitTreeDoesNotRecreateSurfaceForClosedTab() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + state.closeTab(tabId) + let staleTree = state.splitTree(for: tabId) + + #expect(staleTree.isEmpty) + #expect(state.surfaceView(for: surfaceId) == nil) + #expect(state.surfaceView(for: tabId) == nil) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From 8426ad6547eef7326db55b8bcbc6c563e8c18063 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 00:53:34 +0900 Subject: [PATCH 251/528] Remove terminal lifecycle diagnostics --- .../Models/WorktreeTerminalState.swift | 12 ----- .../Ghostty/GhosttySurfaceView.swift | 51 ------------------- .../WorktreeTerminalManagerTests.swift | 18 +++++++ 3 files changed, 18 insertions(+), 63 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 82ff1cdd..d7ea0fab 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -6,7 +6,6 @@ import Observation import Sharing private let terminalStateLogger = SupaLogger("TerminalState") -private let terminalLifecycleLogger = SupaLogger("TerminalLifecycle") private let activeAgentDetectionInterval: Duration = .milliseconds(300) private let idleAgentDetectionInterval: Duration = .seconds(2) @@ -660,7 +659,6 @@ final class WorktreeTerminalState { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB ) -> SplitTree { guard tabManager.tabs.contains(where: { $0.id == tabId }) else { - terminalLifecycleLogger.info("skip splitTree for stale tabID=\(tabId)") return SplitTree() } if let existing = trees[tabId] { @@ -673,11 +671,6 @@ final class WorktreeTerminalState { workingDirectoryOverride: workingDirectoryOverride, context: context ) - terminalLifecycleLogger.info( - "created surfaceID=\(surface.id.uuidString) tabID=\(tabId) worktreeID=\(worktree.id) " - + "context=\(context.rawValue) inheritedFrom=\(inheritingFromSurfaceId?.uuidString ?? "nil") " - + "workingDirectory=\((workingDirectoryOverride ?? worktree.workingDirectory).path(percentEncoded: false)) " - + "hasInitialInput=\(initialInput != nil)") let tree = SplitTree(view: surface) trees[tabId] = tree focusedSurfaceIdByTab[tabId] = surface.id @@ -2009,8 +2002,6 @@ final class WorktreeTerminalState { private func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } - terminalLifecycleLogger.info( - "removeTree tabID=\(tabId) surfaceIDs=\(tree.leaves().map { $0.id.uuidString })") for surface in tree.leaves() { surface.closeSurface() forgetSurface(surface.id) @@ -2164,9 +2155,6 @@ final class WorktreeTerminalState { } private func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { - terminalLifecycleLogger.info( - "handleCloseRequest surfaceID=\(view.id.uuidString) processAlive=\(processAlive) " - + "tracked=\(surfaces[view.id] != nil)") guard surfaces[view.id] != nil else { return } if processAlive { guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index c1b5ad3f..e63c9c17 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1,13 +1,11 @@ import AppKit import Carbon import CoreText -import Darwin import GhosttyKit import QuartzCore import SwiftUI private let surfaceLogger = SupaLogger("Surface") -private let surfaceLifecycleLogger = SupaLogger("SurfaceLifecycle") final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { @@ -334,11 +332,6 @@ final class GhosttySurfaceView: NSView, Identifiable { func closeSurface() { clearNotificationObservers() if let surface { - let childPID = ghostty_surface_pid(surface) - let foregroundProcessGroupID = ghostty_surface_foreground_process_group(surface) - let processExited = ghostty_surface_process_exited(surface) - logLifecycle( - "close begin childPID=\(childPID) foregroundPGID=\(foregroundProcessGroupID) processExited=\(processExited)") if let surfaceRef { runtime.unregisterSurface(surfaceRef) self.surfaceRef = nil @@ -348,10 +341,6 @@ final class GhosttySurfaceView: NSView, Identifiable { bridge.surface = nil occlusionState.reset() lastSurfaceFocus = nil - logLifecycle("close returned childPID=\(childPID)") - scheduleChildExitProbe(childPID: childPID) - } else { - logLifecycle("close skipped surface=nil") } } @@ -1069,14 +1058,6 @@ final class GhosttySurfaceView: NSView, Identifiable { } surface = ghostty_surface_new(app, &config) bridge.surface = surface - if let surface { - logLifecycle( - "created childPID=\(ghostty_surface_pid(surface)) " - + "foregroundPGID=\(ghostty_surface_foreground_process_group(surface)) " - + "processExited=\(ghostty_surface_process_exited(surface))") - } else { - logLifecycle("create failed") - } occlusionState.reset() lastSurfaceFocus = nil // A freshly created Ghostty surface defaults to focused (blinking cursor). @@ -1103,38 +1084,6 @@ final class GhosttySurfaceView: NSView, Identifiable { return 2.0 } - private func logLifecycle(_ message: String) { - surfaceLifecycleLogger.info( - "surfaceID=\(id.uuidString) context=\(context.rawValue) \(message)") - } - - private func scheduleChildExitProbe(childPID: Int32) { - guard childPID > 0 else { return } - let surfaceID = id.uuidString - let context = context.rawValue - Task { @MainActor in - try? await ContinuousClock().sleep(for: .milliseconds(500)) - Self.logChildExitProbe(surfaceID: surfaceID, context: context, childPID: childPID, delay: "0.5s") - try? await ContinuousClock().sleep(for: .milliseconds(1500)) - Self.logChildExitProbe(surfaceID: surfaceID, context: context, childPID: childPID, delay: "2.0s") - } - } - - private static func logChildExitProbe( - surfaceID: String, - context: UInt32, - childPID: Int32, - delay: String - ) { - surfaceLifecycleLogger.info( - "surfaceID=\(surfaceID) context=\(context) close probe delay=\(delay) childPID=\(childPID) " - + "alive=\(isProcessAlive(pid: pid_t(childPID)))") - } - - private static func isProcessAlive(pid: pid_t) -> Bool { - kill(pid, 0) == 0 || errno == EPERM - } - func setOcclusion(_ visible: Bool) { guard let surface else { guard skipsSurfaceCreationForTesting else { return } diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4d8ba37e..d10f0415 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -117,6 +117,24 @@ struct WorktreeTerminalManagerTests { #expect(state.surfaceView(for: tabId) == nil) } + @Test func ghosttyCloseRequestDoesNotRecreateSurfaceForClosedTab() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + let surface = try #require(state.surfaceView(for: surfaceId)) + + surface.bridge.closeSurface(processAlive: false) + let staleTree = state.splitTree(for: tabId) + + #expect(staleTree.isEmpty) + #expect(state.tabManager.tabs.isEmpty) + #expect(state.surfaceView(for: surfaceId) == nil) + #expect(state.surfaceView(for: tabId) == nil) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From 73d1b07aec50b973149eb74c66bac864537611fe Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 01:29:26 +0900 Subject: [PATCH 252/528] Refresh worktrees from git registry changes --- supacode/App/ContentView.swift | 3 + .../WorktreeInfoWatcherClient.swift | 1 + .../WorktreeInfoWatcherManager.swift | 239 ++++++++++++++++++ .../Reducer/RepositoriesFeature.swift | 2 + supacodeTests/RepositoriesFeatureTests.swift | 22 ++ .../WorktreeInfoWatcherManagerTests.swift | 110 ++++++++ 6 files changed, 377 insertions(+) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 2218f37e..208b37e0 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -41,6 +41,9 @@ struct ContentView: View { } } .environment(\.surfaceBackgroundOpacity, terminalManager.surfaceBackgroundOpacity()) + .task { + store.send(.scenePhaseChanged(scenePhase)) + } .onChange(of: scenePhase) { _, newValue in store.send(.scenePhaseChanged(newValue)) } diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index 3d608a15..bc3f4038 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -17,6 +17,7 @@ struct WorktreeInfoWatcherClient { enum Event: Equatable { case branchChanged(worktreeID: Worktree.ID) case filesChanged(worktreeID: Worktree.ID) + case repositoryWorktreesChanged(repositoryRootURL: URL) case repositoryPullRequestRefresh(repositoryRootURL: URL, worktreeIDs: [Worktree.ID]) } } diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index b62d0ac3..e5bc4d17 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -8,6 +8,11 @@ protocol WorktreeFileEventMonitoring: AnyObject { func cancel() } +@MainActor +protocol WorktreeRegistryMonitoring: AnyObject { + func cancel() +} + @MainActor final class WorktreeInfoWatcherManager { typealias WorktreePhaseOffset = @Sendable (Worktree.ID, Duration) -> Duration @@ -17,6 +22,11 @@ final class WorktreeInfoWatcherManager { _ worktree: Worktree, _ onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeFileEventMonitoring? + typealias WorktreeRegistryMonitorFactory = + @MainActor @Sendable ( + _ repositoryRootURL: URL, + _ onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeRegistryMonitoring? private struct HeadWatcher { let headURL: URL @@ -48,6 +58,7 @@ final class WorktreeInfoWatcherManager { } private let filesChangedDebounceInterval: Duration + private let repositoryWorktreesEventDebounceInterval: Duration private let lineChangesEventDebounceInterval: Duration private let lineChangesSafetyRefreshInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration @@ -55,12 +66,15 @@ final class WorktreeInfoWatcherManager { private let lineChangePhaseOffset: WorktreePhaseOffset private let pullRequestPhaseOffset: RepositoryPhaseOffset private let worktreeFileEventMonitorFactory: WorktreeFileEventMonitorFactory + private let worktreeRegistryMonitorFactory: WorktreeRegistryMonitorFactory private let sleep: @Sendable (Duration) async throws -> Void private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] private var worktreeFileEventMonitors: [Worktree.ID: WorktreeFileEventMonitoring] = [:] + private var worktreeRegistryMonitors: [URL: WorktreeRegistryMonitoring] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] + private var repositoryWorktreesDebounceTasks: [URL: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var pullRequestTasks: [URL: RefreshTask] = [:] private var lineChangeSafetyTasks: [Worktree.ID: RefreshTask] = [:] @@ -77,6 +91,7 @@ final class WorktreeInfoWatcherManager { focusedInterval: Duration = .seconds(30), unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), + repositoryWorktreesEventDebounceInterval: Duration = .seconds(2), lineChangesEventDebounceInterval: Duration = .seconds(30), lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), @@ -84,16 +99,20 @@ final class WorktreeInfoWatcherManager { pullRequestPhaseOffset: @escaping RepositoryPhaseOffset = WorktreeInfoWatcherManager.defaultPullRequestPhaseOffset, worktreeFileEventMonitorFactory: @escaping WorktreeFileEventMonitorFactory = WorktreeInfoWatcherManager.defaultWorktreeFileEventMonitorFactory, + worktreeRegistryMonitorFactory: @escaping WorktreeRegistryMonitorFactory = + WorktreeInfoWatcherManager.defaultWorktreeRegistryMonitorFactory, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval + self.repositoryWorktreesEventDebounceInterval = repositoryWorktreesEventDebounceInterval self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown self.lineChangePhaseOffset = lineChangePhaseOffset self.pullRequestPhaseOffset = pullRequestPhaseOffset self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory + self.worktreeRegistryMonitorFactory = worktreeRegistryMonitorFactory self.sleep = { duration in try await clock.sleep(for: duration) } @@ -154,6 +173,7 @@ final class WorktreeInfoWatcherManager { hasCompletedInitialWorktreeLoad = true } let repositoryRoots = Set(worktrees.map(\.repositoryRootURL)) + syncWorktreeRegistryMonitors(repositoryRoots: repositoryRoots) for repositoryRootURL in repositoryRoots { updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) } @@ -361,10 +381,18 @@ final class WorktreeInfoWatcherManager { for monitor in worktreeFileEventMonitors.values { monitor.cancel() } + for monitor in worktreeRegistryMonitors.values { + monitor.cancel() + } + for task in repositoryWorktreesDebounceTasks.values { + task.cancel() + } headWatchers.removeAll() worktreeFileEventMonitors.removeAll() + worktreeRegistryMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() + repositoryWorktreesDebounceTasks.removeAll() restartTasks.removeAll() pullRequestTasks.removeAll() lineChangeSafetyTasks.removeAll() @@ -561,6 +589,39 @@ final class WorktreeInfoWatcherManager { worktreeFileEventMonitors.removeValue(forKey: worktreeID)?.cancel() } + private func syncWorktreeRegistryMonitors(repositoryRoots: Set) { + let normalizedRoots = Set(repositoryRoots.map { $0.standardizedFileURL }) + let obsoleteRoots = worktreeRegistryMonitors.keys.filter { !normalizedRoots.contains($0) } + for repositoryRootURL in obsoleteRoots { + worktreeRegistryMonitors.removeValue(forKey: repositoryRootURL)?.cancel() + repositoryWorktreesDebounceTasks.removeValue(forKey: repositoryRootURL)?.cancel() + } + for repositoryRootURL in normalizedRoots where worktreeRegistryMonitors[repositoryRootURL] == nil { + worktreeRegistryMonitors[repositoryRootURL] = worktreeRegistryMonitorFactory(repositoryRootURL) { [weak self] in + self?.scheduleRepositoryWorktreesChanged(repositoryRootURL: repositoryRootURL) + } + } + } + + private func scheduleRepositoryWorktreesChanged(repositoryRootURL: URL) { + let normalizedRootURL = repositoryRootURL.standardizedFileURL + repositoryWorktreesDebounceTasks[normalizedRootURL]?.cancel() + let debounceInterval = repositoryWorktreesEventDebounceInterval + let sleep = self.sleep + let task = Task { [weak self, sleep] in + do { + try await sleep(debounceInterval) + } catch { + return + } + await MainActor.run { + self?.repositoryWorktreesDebounceTasks.removeValue(forKey: normalizedRootURL) + self?.emit(.repositoryWorktreesChanged(repositoryRootURL: normalizedRootURL)) + } + } + repositoryWorktreesDebounceTasks[normalizedRootURL] = task + } + private func updateRepeatingTask( _ request: RepeatingTaskRequest, tasks: inout [Worktree.ID: RefreshTask] @@ -629,6 +690,13 @@ final class WorktreeInfoWatcherManager { FSEventsWorktreeFileEventMonitor(rootURL: worktree.workingDirectory, onEvent: onEvent) } + private static func defaultWorktreeRegistryMonitorFactory( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeRegistryMonitoring? { + GitWorktreeRegistryMonitor(repositoryRootURL: repositoryRootURL, onEvent: onEvent) + } + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { let intervalMilliseconds = durationMilliseconds(interval) guard intervalMilliseconds > 0 else { @@ -767,3 +835,174 @@ private final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitorin self.stream = nil } } + +@MainActor +private final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { + private let commonGitDirectoryURL: URL + private let worktreesDirectoryURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private var commonDirectorySource: DispatchSourceFileSystemObject? + private var worktreesDirectorySource: DispatchSourceFileSystemObject? + private var isWorktreesDirectoryPresent: Bool + + init?( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void, + fileManager: FileManager = .default + ) { + guard + let commonGitDirectoryURL = Self.commonGitDirectoryURL( + for: repositoryRootURL, + fileManager: fileManager + ) + else { + return nil + } + self.commonGitDirectoryURL = commonGitDirectoryURL + worktreesDirectoryURL = commonGitDirectoryURL.appending(path: "worktrees") + self.onEvent = onEvent + isWorktreesDirectoryPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: fileManager) + commonDirectorySource = Self.makeDirectorySource(url: commonGitDirectoryURL) { [weak self] in + self?.handleCommonDirectoryEvent() + } + if isWorktreesDirectoryPresent { + startWorktreesDirectorySourceIfNeeded() + } + guard commonDirectorySource != nil || worktreesDirectorySource != nil else { + return nil + } + } + + func cancel() { + commonDirectorySource?.cancel() + worktreesDirectorySource?.cancel() + commonDirectorySource = nil + worktreesDirectorySource = nil + } + + private func handleCommonDirectoryEvent() { + let isPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: .default) + guard isPresent != isWorktreesDirectoryPresent else { + return + } + isWorktreesDirectoryPresent = isPresent + if isPresent { + startWorktreesDirectorySourceIfNeeded() + } else { + worktreesDirectorySource?.cancel() + worktreesDirectorySource = nil + } + onEvent() + } + + private func handleWorktreesDirectoryEvent() { + guard Self.isDirectory(worktreesDirectoryURL, fileManager: .default) else { + if isWorktreesDirectoryPresent { + isWorktreesDirectoryPresent = false + worktreesDirectorySource?.cancel() + worktreesDirectorySource = nil + onEvent() + } + return + } + onEvent() + } + + private func startWorktreesDirectorySourceIfNeeded() { + guard worktreesDirectorySource == nil else { + return + } + worktreesDirectorySource = Self.makeDirectorySource(url: worktreesDirectoryURL) { [weak self] in + self?.handleWorktreesDirectoryEvent() + } + } + + private static func makeDirectorySource( + url: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> DispatchSourceFileSystemObject? { + let fileDescriptor = open(url.path(percentEncoded: false), O_EVTONLY) + guard fileDescriptor >= 0 else { + return nil + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .rename, .delete, .attrib], + queue: DispatchQueue(label: "worktree-registry-watcher.\(url.path(percentEncoded: false))") + ) + source.setEventHandler { + Task { @MainActor in + onEvent() + } + } + source.setCancelHandler { + close(fileDescriptor) + } + source.resume() + return source + } + + private static func commonGitDirectoryURL( + for repositoryRootURL: URL, + fileManager: FileManager + ) -> URL? { + let repositoryRootURL = repositoryRootURL.standardizedFileURL + let dotGitURL = repositoryRootURL.appending(path: ".git") + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { + if isDirectory.boolValue { + return dotGitURL.standardizedFileURL + } + guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { + return nil + } + return commonDirectoryURL(from: gitdirURL, fileManager: fileManager) + } + if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), + fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) + { + return repositoryRootURL + } + return nil + } + + private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { + guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "gitdir:" + guard trimmed.hasPrefix(prefix) else { + return nil + } + let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return nil + } + return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) + .standardizedFileURL + } + + private static func commonDirectoryURL(from gitdirURL: URL, fileManager: FileManager) -> URL { + let commondirURL = gitdirURL.appending(path: "commondir") + guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return gitdirURL.standardizedFileURL + } + let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return gitdirURL.standardizedFileURL + } + return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) + .standardizedFileURL + } + + private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory = ObjCBool(false) + return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + && isDirectory.boolValue + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7ccc2929..7a0ca063 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1180,6 +1180,8 @@ struct RepositoriesFeature { ) } } + case .repositoryWorktreesChanged: + return .send(.reloadRepositories(animated: true)) case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): return .send( .githubIntegration( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index ccac9876..e82a4147 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -263,6 +263,28 @@ struct RepositoriesFeatureTests { } } + @Test func repositoryWorktreesChangedReloadsRepositories() async { + let existingWorktree = makeWorktree(id: "/tmp/repo/main", name: "main") + let discoveredWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature") + let repository = makeRepository(id: "/tmp/repo", worktrees: [existingWorktree]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.worktrees = { _ in [existingWorktree, discoveredWorktree] } + } + + await store.send(.worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) + await store.receive(\.reloadRepositories) + await store.receive(\.repositoriesLoaded) { + $0.repositories[id: repository.id] = makeRepository( + id: repository.id, + worktrees: [existingWorktree, discoveredWorktree] + ) + $0.isInitialLoadComplete = true + $0.snapshotPersistencePhase = .active + } + } + @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 962b686b..dcda4906 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -207,6 +207,69 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func repositoryRegistryEventRefreshesWorktreesAfterDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let registryMonitorStore = TestWorktreeRegistryMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + repositoryWorktreesEventDebounceInterval: .milliseconds(80), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeRegistryMonitorFactory: registryMonitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + let monitor = try #require(registryMonitorStore.monitor(for: tempRepository.tempRoot)) + monitor.emit() + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func removedRepositoryCancelsRegistryMonitor() async throws { + let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) + let registryMonitorStore = TestWorktreeRegistryMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + worktreeRegistryMonitorFactory: registryMonitorStore.makeMonitor + ) + let (_, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees(firstRepository.worktrees + secondRepository.worktrees)) + await drainAsyncEvents(120) + let removedMonitor = try #require(registryMonitorStore.monitor(for: firstRepository.tempRoot)) + #expect(!removedMonitor.isCanceled) + + manager.handleCommand(.setWorktrees(secondRepository.worktrees)) + await drainAsyncEvents(120) + #expect(removedMonitor.isCanceled) + #expect(registryMonitorStore.monitor(for: secondRepository.tempRoot)?.isCanceled == false) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: firstRepository.tempRoot) + try FileManager.default.removeItem(at: secondRepository.tempRoot) + } + @Test func staggersPeriodicPullRequestRefreshAcrossRepositories() async throws { let clock = TestClock() let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) @@ -371,6 +434,17 @@ actor EventCollector { } } } + + func repositoryWorktreesChangedCount(repositoryRootURL: URL) -> Int { + let expectedURL = repositoryRootURL.standardizedFileURL + return events.reduce(into: 0) { result, event in + if case .repositoryWorktreesChanged(let rootURL) = event, + rootURL.standardizedFileURL == expectedURL + { + result += 1 + } + } + } } private struct TempWorktree { @@ -480,3 +554,39 @@ private final class TestWorktreeFileEventMonitor: WorktreeFileEventMonitoring { isCanceled = true } } + +@MainActor +private final class TestWorktreeRegistryMonitorStore { + private var monitors: [URL: TestWorktreeRegistryMonitor] = [:] + + func makeMonitor( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> WorktreeRegistryMonitoring? { + let monitor = TestWorktreeRegistryMonitor(onEvent: onEvent) + monitors[repositoryRootURL.standardizedFileURL] = monitor + return monitor + } + + func monitor(for repositoryRootURL: URL) -> TestWorktreeRegistryMonitor? { + monitors[repositoryRootURL.standardizedFileURL] + } +} + +@MainActor +private final class TestWorktreeRegistryMonitor: WorktreeRegistryMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private(set) var isCanceled = false + + init(onEvent: @escaping @MainActor @Sendable () -> Void) { + self.onEvent = onEvent + } + + func emit() { + onEvent() + } + + func cancel() { + isCanceled = true + } +} -- 2.51.2 From 3b167b37dd6a51d6b98838b352a4dc8454cbed1b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 09:44:32 +0900 Subject: [PATCH 253/528] Fix worktree registry watcher event handling --- .../WorktreeInfoWatcherManager.swift | 6 ++-- .../Ghostty/GhosttySurfaceView.swift | 33 +++++++++++------- supacodeTests/MirroredTerminalKeyTests.swift | 34 +++++++++++++++++++ 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index e5bc4d17..2e8487c9 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -928,12 +928,10 @@ private final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, eventMask: [.write, .rename, .delete, .attrib], - queue: DispatchQueue(label: "worktree-registry-watcher.\(url.path(percentEncoded: false))") + queue: .main ) source.setEventHandler { - Task { @MainActor in - onEvent() - } + onEvent() } source.setCancelHandler { close(fileDescriptor) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index e63c9c17..03739102 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -7,6 +7,26 @@ import SwiftUI private let surfaceLogger = SupaLogger("Surface") +enum GhosttyEventText { + static func characters(for event: NSEvent) -> String? { + guard event.type == .keyDown || event.type == .keyUp else { + return nil + } + guard let characters = event.characters else { return nil } + if characters.count == 1, + let scalar = characters.unicodeScalars.first + { + if scalar.value < 0x20 { + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + } + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } + } + return characters + } +} + final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { private(set) var desired: Bool? @@ -1694,18 +1714,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } private func ghosttyCharacters(_ event: NSEvent) -> String? { - guard let characters = event.characters else { return nil } - if characters.count == 1, - let scalar = characters.unicodeScalars.first - { - if scalar.value < 0x20 { - return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) - } - if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { - return nil - } - } - return characters + GhosttyEventText.characters(for: event) } private func syncPreedit(clearIfNeeded: Bool = true) { diff --git a/supacodeTests/MirroredTerminalKeyTests.swift b/supacodeTests/MirroredTerminalKeyTests.swift index c01da83d..633686c8 100644 --- a/supacodeTests/MirroredTerminalKeyTests.swift +++ b/supacodeTests/MirroredTerminalKeyTests.swift @@ -118,4 +118,38 @@ struct MirroredTerminalKeyTests { #expect(MirroredTerminalKey(event: try #require(event)) == nil) } + + @Test func ghosttyTextIgnoresFlagsChangedEvents() throws { + let event = NSEvent.keyEvent( + with: .flagsChanged, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 55 + ) + + #expect(GhosttyEventText.characters(for: try #require(event)) == nil) + } + + @Test func ghosttyTextReadsKeyEvents() throws { + let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + ) + + #expect(GhosttyEventText.characters(for: try #require(event)) == "a") + } } -- 2.51.2 From b4fc2c2f3ef7b94f9ac80c0deb067d6b68c85633 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 09:52:42 +0900 Subject: [PATCH 254/528] Keep worktree refresh PR scoped --- .../Ghostty/GhosttySurfaceView.swift | 33 +++++++----------- supacodeTests/MirroredTerminalKeyTests.swift | 34 ------------------- supacodeTests/RepositoriesFeatureTests.swift | 1 + 3 files changed, 13 insertions(+), 55 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 03739102..e63c9c17 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -7,26 +7,6 @@ import SwiftUI private let surfaceLogger = SupaLogger("Surface") -enum GhosttyEventText { - static func characters(for event: NSEvent) -> String? { - guard event.type == .keyDown || event.type == .keyUp else { - return nil - } - guard let characters = event.characters else { return nil } - if characters.count == 1, - let scalar = characters.unicodeScalars.first - { - if scalar.value < 0x20 { - return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) - } - if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { - return nil - } - } - return characters - } -} - final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { private(set) var desired: Bool? @@ -1714,7 +1694,18 @@ final class GhosttySurfaceView: NSView, Identifiable { } private func ghosttyCharacters(_ event: NSEvent) -> String? { - GhosttyEventText.characters(for: event) + guard let characters = event.characters else { return nil } + if characters.count == 1, + let scalar = characters.unicodeScalars.first + { + if scalar.value < 0x20 { + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + } + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } + } + return characters } private func syncPreedit(clearIfNeeded: Bool = true) { diff --git a/supacodeTests/MirroredTerminalKeyTests.swift b/supacodeTests/MirroredTerminalKeyTests.swift index 633686c8..c01da83d 100644 --- a/supacodeTests/MirroredTerminalKeyTests.swift +++ b/supacodeTests/MirroredTerminalKeyTests.swift @@ -118,38 +118,4 @@ struct MirroredTerminalKeyTests { #expect(MirroredTerminalKey(event: try #require(event)) == nil) } - - @Test func ghosttyTextIgnoresFlagsChangedEvents() throws { - let event = NSEvent.keyEvent( - with: .flagsChanged, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "", - charactersIgnoringModifiers: "", - isARepeat: false, - keyCode: 55 - ) - - #expect(GhosttyEventText.characters(for: try #require(event)) == nil) - } - - @Test func ghosttyTextReadsKeyEvents() throws { - let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - ) - - #expect(GhosttyEventText.characters(for: try #require(event)) == "a") - } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index e82a4147..db54f2a4 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -283,6 +283,7 @@ struct RepositoriesFeatureTests { $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } + await store.receive(\.delegate.repositoriesChanged) } @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { -- 2.51.2 From 6f3da26923fb7a7567d1e077ef70b10775c4a412 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 09:55:50 +0900 Subject: [PATCH 255/528] Avoid reading text from modifier events --- .../Ghostty/GhosttySurfaceView.swift | 33 +++++++++++------- supacodeTests/MirroredTerminalKeyTests.swift | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index e63c9c17..03739102 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -7,6 +7,26 @@ import SwiftUI private let surfaceLogger = SupaLogger("Surface") +enum GhosttyEventText { + static func characters(for event: NSEvent) -> String? { + guard event.type == .keyDown || event.type == .keyUp else { + return nil + } + guard let characters = event.characters else { return nil } + if characters.count == 1, + let scalar = characters.unicodeScalars.first + { + if scalar.value < 0x20 { + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + } + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } + } + return characters + } +} + final class GhosttySurfaceView: NSView, Identifiable { struct OcclusionState { private(set) var desired: Bool? @@ -1694,18 +1714,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } private func ghosttyCharacters(_ event: NSEvent) -> String? { - guard let characters = event.characters else { return nil } - if characters.count == 1, - let scalar = characters.unicodeScalars.first - { - if scalar.value < 0x20 { - return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) - } - if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { - return nil - } - } - return characters + GhosttyEventText.characters(for: event) } private func syncPreedit(clearIfNeeded: Bool = true) { diff --git a/supacodeTests/MirroredTerminalKeyTests.swift b/supacodeTests/MirroredTerminalKeyTests.swift index c01da83d..633686c8 100644 --- a/supacodeTests/MirroredTerminalKeyTests.swift +++ b/supacodeTests/MirroredTerminalKeyTests.swift @@ -118,4 +118,38 @@ struct MirroredTerminalKeyTests { #expect(MirroredTerminalKey(event: try #require(event)) == nil) } + + @Test func ghosttyTextIgnoresFlagsChangedEvents() throws { + let event = NSEvent.keyEvent( + with: .flagsChanged, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 55 + ) + + #expect(GhosttyEventText.characters(for: try #require(event)) == nil) + } + + @Test func ghosttyTextReadsKeyEvents() throws { + let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + ) + + #expect(GhosttyEventText.characters(for: try #require(event)) == "a") + } } -- 2.51.2 From 69bc0cabcedd7f156f11b6563c4b36dd96df2128 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 10:32:53 +0900 Subject: [PATCH 256/528] Make worktree branch deletion explicit and safe --- supacode/App/ContentView.swift | 24 +++ supacode/Clients/Git/GitClient.swift | 74 ++++++-- .../Repositories/GitClientDependency.swift | 6 + ...epositoriesFeature+GithubIntegration.swift | 10 +- ...RepositoriesFeature+WorktreeCreation.swift | 5 + ...epositoriesFeature+WorktreeLifecycle.swift | 175 +++++++++++++----- .../Reducer/RepositoriesFeature.swift | 60 ++++-- .../DeleteWorktreeConfirmationView.swift | 51 +++++ .../Settings/Models/GlobalSettings.swift | 2 +- .../Settings/Views/WorktreeSettingsView.swift | 6 +- .../AppFeatureCommandPaletteTests.swift | 22 +-- .../GitClientRemoveWorktreeTests.swift | 109 +++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 130 ++++++++++--- .../SettingsFilePersistenceTests.swift | 2 +- 14 files changed, 550 insertions(+), 126 deletions(-) create mode 100644 supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift create mode 100644 supacodeTests/GitClientRemoveWorktreeTests.swift diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 208b37e0..4c03a34f 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -31,6 +31,14 @@ struct ContentView: View { get: { store.runScriptDraft }, set: { store.send(.runScriptDraftChanged($0)) } ) + let deleteWorktreeConfirmationPresented = Binding( + get: { repositoriesStore.deleteWorktreeConfirmation != nil }, + set: { isPresented in + if !isPresented { + repositoriesStore.send(.worktreeLifecycle(.deleteWorktreePromptDismissed)) + } + } + ) Group { if store.repositories.isInitialLoadComplete { mainSplitView @@ -72,6 +80,22 @@ struct ContentView: View { promptStore in WorktreeCreationPromptView(store: promptStore) } + .sheet(isPresented: deleteWorktreeConfirmationPresented) { + if let confirmation = repositoriesStore.deleteWorktreeConfirmation { + DeleteWorktreeConfirmationView( + confirmation: confirmation, + onDeleteBranchChanged: { + repositoriesStore.send(.worktreeLifecycle(.deleteWorktreePromptDeleteBranchChanged($0))) + }, + onCancel: { + repositoriesStore.send(.worktreeLifecycle(.deleteWorktreePromptDismissed)) + }, + onDelete: { + repositoriesStore.send(.worktreeLifecycle(.deleteWorktreePromptConfirmed)) + } + ) + } + } .sheet(isPresented: isRunScriptPromptPresented) { RunScriptPromptView( script: runScriptDraft, diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 9ca5e5ff..7af1e6ac 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -45,6 +45,13 @@ enum GitWorktreeCreateEvent: Equatable, Sendable { case finished(Worktree) } +enum LocalBranchDeletionOutcome: Equatable, Sendable { + case deleted + case notFound + case protected + case notRequested +} + nonisolated enum GitRemoteMatcher { static func matchingRemote(for ref: String, from remotes: [String]) -> String? { remotes @@ -590,14 +597,12 @@ struct GitClient { } catch { await runGitWorktreeRemove(rootPath: rootPath, worktreePath: worktreePath) } - if deleteBranch, !worktree.name.isEmpty { - let names = try await localBranchNames(for: worktree.repositoryRootURL) - if names.contains(worktree.name.lowercased()) { - _ = try? await runGit( - operation: .branchDelete, - arguments: ["-C", rootPath, "branch", "-D", worktree.name] - ) - } + if deleteBranch { + _ = try? await deleteLocalBranch( + named: worktree.name, + for: worktree.repositoryRootURL, + force: false + ) } Task.detached { try? FileManager.default.removeItem(at: relocatedURL) @@ -605,18 +610,55 @@ struct GitClient { return worktree.workingDirectory } await runGitWorktreeRemove(rootPath: rootPath, worktreePath: worktreePath) - if deleteBranch, !worktree.name.isEmpty { - let names = try await localBranchNames(for: worktree.repositoryRootURL) - if names.contains(worktree.name.lowercased()) { - _ = try? await runGit( - operation: .branchDelete, - arguments: ["-C", rootPath, "branch", "-D", worktree.name] - ) - } + if deleteBranch { + _ = try? await deleteLocalBranch( + named: worktree.name, + for: worktree.repositoryRootURL, + force: false + ) } return worktree.workingDirectory } + nonisolated func deleteLocalBranch( + named branchName: String, + for repoRoot: URL, + force: Bool + ) async throws -> LocalBranchDeletionOutcome { + guard !branchName.isEmpty else { return .notRequested } + let rootPath = repoRoot.path(percentEncoded: false) + let normalizedName = branchName.lowercased() + let names = try await localBranchNames(for: repoRoot) + guard names.contains(normalizedName) else { return .notFound } + let protectedNames = await protectedLocalBranchNames(for: repoRoot) + guard !protectedNames.contains(normalizedName) else { return .protected } + _ = try await runGit( + operation: .branchDelete, + arguments: ["-C", rootPath, "branch", force ? "-D" : "-d", branchName] + ) + return .deleted + } + + nonisolated private func protectedLocalBranchNames(for repoRoot: URL) async -> Set { + var names: Set = ["main", "master"] + if let defaultRef = try? await defaultRemoteBranchRef(for: repoRoot), + let defaultBranchName = Self.localBranchName(fromRef: defaultRef) + { + names.insert(defaultBranchName.lowercased()) + } + return names + } + + nonisolated private static func localBranchName(fromRef ref: String) -> String? { + let trimmed = ref.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let slashIndex = trimmed.firstIndex(of: "/") else { + return trimmed + } + let name = trimmed[trimmed.index(after: slashIndex)...] + return name.isEmpty ? nil : String(name) + } + nonisolated private func parseShortstat(_ output: String) -> (added: Int, removed: Int) { let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index c0561b2d..26ee0f36 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -32,6 +32,9 @@ struct GitClientDependency: Sendable { _ baseRef: String ) -> AsyncThrowingStream var removeWorktree: @Sendable (_ worktree: Worktree, _ deleteBranch: Bool) async throws -> URL + var deleteLocalBranch: + @Sendable (_ branchName: String, _ repoRoot: URL, _ force: Bool) async throws + -> LocalBranchDeletionOutcome var isBareRepository: @Sendable (_ repoRoot: URL) async throws -> Bool var branchName: @Sendable (URL) async -> String? var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? @@ -77,6 +80,9 @@ extension GitClientDependency: DependencyKey { removeWorktree: { worktree, deleteBranch in try await GitClient().removeWorktree(worktree, deleteBranch: deleteBranch) }, + deleteLocalBranch: { branchName, repoRoot, force in + try await GitClient().deleteLocalBranch(named: branchName, for: repoRoot, force: force) + }, isBareRepository: { repoRoot in try await GitClient().isBareRepository(for: repoRoot) }, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index d91cc7af..0310bfb5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -237,9 +237,17 @@ extension RepositoriesFeature { } ) case .delete: + @Shared(.settingsFile) var settingsFile return .merge( mergedWorktreeIDs.map { worktreeID in - .send(.worktreeLifecycle(.deleteWorktreeConfirmed(worktreeID, repositoryID))) + .send( + .worktreeLifecycle( + .deleteWorktreeConfirmed( + worktreeID, + repositoryID, + deleteBranch: settingsFile.global.deleteBranchOnDeleteWorktree + )) + ) } ) case nil: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift index d0c3fffb..227feb5e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift @@ -556,6 +556,11 @@ extension RepositoriesFeature { analyticsClient.capture("worktree_created", [String: Any]?.none) state.pendingSetupScriptWorktreeIDs.insert(worktree.id) state.pendingTerminalFocusWorktreeIDs.insert(worktree.id) + state.$prowlCreatedWorktreeIDs.withLock { + if !$0.contains(worktree.id) { + $0.append(worktree.id) + } + } removePendingWorktree(pendingID, state: &state) if state.selection == .worktree(pendingID) { setSingleWorktreeSelection(worktree.id, state: &state, recordHistory: false) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift index 1fa2815b..d685a7d8 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift @@ -295,24 +295,12 @@ extension RepositoriesFeature { if state.deletingWorktreeIDs.contains(worktree.id) { return .none } - @Shared(.settingsFile) var settingsFile - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree - let removalMessage = - deleteBranchOnDeleteWorktree - ? "This deletes the worktree directory and its local branch." - : "This deletes the worktree directory and keeps the local branch." - state.alert = AlertState { - TextState("🚨 Delete worktree?") - } actions: { - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { - TextState("Delete (⌘↩)") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState("Delete \(worktree.name)? " + removalMessage) - } + state.deleteWorktreeConfirmation = makeDeleteWorktreeConfirmation( + id: state.nextDeleteWorktreeConfirmationID, + targets: [DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + state: state + ) + state.nextDeleteWorktreeConfirmationID += 1 return .none case .requestDeleteWorktrees(let targets): @@ -339,28 +327,41 @@ extension RepositoriesFeature { guard !validTargets.isEmpty else { return .none } - @Shared(.settingsFile) var settingsFile - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree - let removalMessage = - deleteBranchOnDeleteWorktree - ? "This deletes the worktree directories and their local branches." - : "This deletes the worktree directories and keeps their local branches." - let count = validTargets.count - state.alert = AlertState { - TextState("🚨 Delete \(count) worktrees?") - } actions: { - ButtonState(role: .destructive, action: .confirmDeleteWorktrees(validTargets)) { - TextState("Delete \(count) (⌘↩)") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState("Delete \(count) worktrees? " + removalMessage) - } + state.deleteWorktreeConfirmation = makeDeleteWorktreeConfirmation( + id: state.nextDeleteWorktreeConfirmationID, + targets: validTargets, + state: state + ) + state.nextDeleteWorktreeConfirmationID += 1 + return .none + + case .deleteWorktreePromptDeleteBranchChanged(let deleteBranch): + state.deleteWorktreeConfirmation?.deleteBranch = deleteBranch return .none - case .deleteWorktreeConfirmed(let worktreeID, let repositoryID): + case .deleteWorktreePromptDismissed: + state.deleteWorktreeConfirmation = nil + return .none + + case .deleteWorktreePromptConfirmed: + guard let confirmation = state.deleteWorktreeConfirmation else { + return .none + } + state.deleteWorktreeConfirmation = nil + return .merge( + confirmation.targets.map { target in + .send( + .worktreeLifecycle( + .deleteWorktreeConfirmed( + target.worktreeID, + target.repositoryID, + deleteBranch: confirmation.deleteBranch + )) + ) + } + ) + + case .deleteWorktreeConfirmed(let worktreeID, let repositoryID, let deleteBranch): guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { @@ -379,21 +380,35 @@ extension RepositoriesFeature { selectionWasRemoved ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) : nil - @Shared(.settingsFile) var settingsFile - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree return .run { send in do { _ = try await gitClient.removeWorktree( worktree, - deleteBranchOnDeleteWorktree + false ) + let forceDeleteBranchRequest: ForceDeleteBranchRequest? + if deleteBranch { + do { + _ = try await gitClient.deleteLocalBranch(worktree.name, worktree.repositoryRootURL, false) + forceDeleteBranchRequest = nil + } catch { + forceDeleteBranchRequest = ForceDeleteBranchRequest( + branchName: worktree.name, + repositoryRootURL: worktree.repositoryRootURL, + errorMessage: error.localizedDescription + ) + } + } else { + forceDeleteBranchRequest = nil + } await send( .worktreeLifecycle( .worktreeDeleted( worktree.id, repositoryID: repository.id, selectionWasRemoved: selectionWasRemoved, - nextSelection: nextSelection + nextSelection: nextSelection, + forceDeleteBranchRequest: forceDeleteBranchRequest ) ) ) @@ -406,7 +421,8 @@ extension RepositoriesFeature { let worktreeID, let repositoryID, _, - let nextSelection + let nextSelection, + let forceDeleteBranchRequest ): analyticsClient.capture("worktree_deleted", [String: Any]?.none) let previousSelection = state.selectedWorktreeID @@ -424,6 +440,9 @@ extension RepositoriesFeature { state.worktreeInfoByID.removeValue(forKey: worktreeID) state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } state.archivedWorktrees.removeAll { $0.id == worktreeID } + state.$prowlCreatedWorktreeIDs.withLock { + $0.removeAll { $0 == worktreeID } + } if var order = state.worktreeOrderByRepository[repositoryID] { order.removeAll { $0 == worktreeID } if order.isEmpty { @@ -482,6 +501,9 @@ extension RepositoriesFeature { } ) } + if let forceDeleteBranchRequest { + state.alert = forceDeleteBranchAlert(forceDeleteBranchRequest) + } return .concatenate( .merge(immediateEffects), .merge(followupEffects) @@ -491,6 +513,20 @@ extension RepositoriesFeature { state.deletingWorktreeIDs.remove(worktreeID) state.alert = messageAlert(title: "Unable to delete worktree", message: message) return .none + + case .forceDeleteBranchConfirmed(let request): + state.alert = nil + return .run { send in + do { + _ = try await gitClient.deleteLocalBranch(request.branchName, request.repositoryRootURL, true) + } catch { + await send(.worktreeLifecycle(.forceDeleteBranchFailed(error.localizedDescription))) + } + } + + case .forceDeleteBranchFailed(let message): + state.alert = messageAlert(title: "Unable to delete branch", message: message) + return .none } } @@ -513,3 +549,56 @@ private func archiveWorktreesAlertMessage() -> String { let shortcut = AppShortcuts.archivedWorktrees.display return "Find them later in Menu Bar > Worktrees > Archived Worktrees (\(shortcut))." } + +private func makeDeleteWorktreeConfirmation( + id: Int, + targets: [RepositoriesFeature.DeleteWorktreeTarget], + state: RepositoriesFeature.State +) -> DeleteWorktreeConfirmation { + @Shared(.settingsFile) var settingsFile + let count = targets.count + let allProwlCreated = targets.allSatisfy { target in + state.prowlCreatedWorktreeIDs.contains(target.worktreeID) + } + let defaultDeleteBranch = settingsFile.global.deleteBranchOnDeleteWorktree && allProwlCreated + if count == 1, + let target = targets.first, + let worktree = state.repositories[id: target.repositoryID]?.worktrees[id: target.worktreeID] + { + return DeleteWorktreeConfirmation( + id: id, + title: "Delete worktree?", + message: "Delete \(worktree.name)? The worktree directory will be removed.", + targets: targets, + deleteBranch: defaultDeleteBranch + ) + } + return DeleteWorktreeConfirmation( + id: id, + title: "Delete \(count) worktrees?", + message: "Delete \(count) worktrees? Their worktree directories will be removed.", + targets: targets, + deleteBranch: defaultDeleteBranch + ) +} + +private func forceDeleteBranchAlert(_ request: ForceDeleteBranchRequest) -> AlertState { + AlertState { + TextState("Force delete branch?") + } actions: { + ButtonState(role: .destructive, action: .confirmForceDeleteBranch(request)) { + TextState("Force Delete") + } + ButtonState(role: .cancel) { + TextState("Keep Branch") + } + } message: { + TextState( + """ + The worktree was deleted, but \(request.branchName) could not be deleted safely. + + \(request.errorMessage) + """ + ) + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7a0ca063..d37f9885 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -55,6 +55,20 @@ struct PendingRenameBranchRequest: Equatable, Sendable { let worktreeID: Worktree.ID } +struct DeleteWorktreeConfirmation: Equatable, Identifiable { + let id: Int + let title: String + let message: String + let targets: [RepositoriesFeature.DeleteWorktreeTarget] + var deleteBranch: Bool +} + +struct ForceDeleteBranchRequest: Equatable { + let branchName: String + let repositoryRootURL: URL + let errorMessage: String +} + @Reducer struct RepositoriesFeature { enum CancelID { @@ -133,14 +147,20 @@ struct RepositoriesFeature { case unarchiveWorktree(Worktree.ID) case requestDeleteWorktree(Worktree.ID, Repository.ID) case requestDeleteWorktrees([DeleteWorktreeTarget]) - case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) + case deleteWorktreePromptDeleteBranchChanged(Bool) + case deleteWorktreePromptConfirmed + case deleteWorktreePromptDismissed + case deleteWorktreeConfirmed(Worktree.ID, Repository.ID, deleteBranch: Bool) case worktreeDeleted( Worktree.ID, repositoryID: Repository.ID, selectionWasRemoved: Bool, - nextSelection: Worktree.ID? + nextSelection: Worktree.ID?, + forceDeleteBranchRequest: ForceDeleteBranchRequest? ) case deleteWorktreeFailed(String, worktreeID: Worktree.ID) + case forceDeleteBranchConfirmed(ForceDeleteBranchRequest) + case forceDeleteBranchFailed(String) } @CasePathable @@ -244,6 +264,9 @@ struct RepositoriesFeature { var remoteInfoByRepositoryID: [Repository.ID: GithubRemoteInfo] = [:] var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] + @Shared(.appStorage("prowlCreatedWorktreeIDs")) var prowlCreatedWorktreeIDs: [Worktree.ID] = [] + var nextDeleteWorktreeConfirmationID = 0 + var deleteWorktreeConfirmation: DeleteWorktreeConfirmation? var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? var nextPendingRenameBranchRequestID = 0 @@ -377,8 +400,7 @@ struct RepositoriesFeature { enum Alert: Equatable { case confirmArchiveWorktree(Worktree.ID, Repository.ID) case confirmArchiveWorktrees([ArchiveWorktreeTarget]) - case confirmDeleteWorktree(Worktree.ID, Repository.ID) - case confirmDeleteWorktrees([DeleteWorktreeTarget]) + case confirmForceDeleteBranch(ForceDeleteBranchRequest) case confirmRemoveRepository(Repository.ID) } @@ -545,6 +567,7 @@ struct RepositoriesFeature { guard !expiredEntries.isEmpty else { return .none } + @Shared(.settingsFile) var settingsFile var deleteEffects: [Effect] = [] for entry in expiredEntries { guard @@ -558,7 +581,14 @@ struct RepositoriesFeature { continue } deleteEffects.append( - .send(.worktreeLifecycle(.deleteWorktreeConfirmed(worktree.id, repository.id))) + .send( + .worktreeLifecycle( + .deleteWorktreeConfirmed( + worktree.id, + repository.id, + deleteBranch: settingsFile.global.deleteBranchOnDeleteWorktree + )) + ) ) } guard !deleteEffects.isEmpty else { @@ -1098,15 +1128,8 @@ struct RepositoriesFeature { } ) - case .alert(.presented(.confirmDeleteWorktree(let worktreeID, let repositoryID))): - return .send(.worktreeLifecycle(.deleteWorktreeConfirmed(worktreeID, repositoryID))) - - case .alert(.presented(.confirmDeleteWorktrees(let targets))): - return .merge( - targets.map { target in - .send(.worktreeLifecycle(.deleteWorktreeConfirmed(target.worktreeID, target.repositoryID))) - } - ) + case .alert(.presented(.confirmForceDeleteBranch(let request))): + return .send(.worktreeLifecycle(.forceDeleteBranchConfirmed(request))) case .alert(.presented(.confirmRemoveRepository(let repositoryID))): guard let repository = state.repositories[id: repositoryID] else { @@ -1497,6 +1520,9 @@ struct RepositoriesFeature { let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) } + state.$prowlCreatedWorktreeIDs.withLock { + $0.removeAll { !availableWorktreeIDs.contains($0) } + } let identifiedRepositories = IdentifiedArray(uniqueElements: repositories) if animated { withAnimation { @@ -2016,12 +2042,6 @@ extension RepositoriesFeature.State { if case .confirmArchiveWorktrees(let targets)? = button.action.action { return .confirmArchiveWorktrees(targets) } - if case .confirmDeleteWorktree(let worktreeID, let repositoryID)? = button.action.action { - return .confirmDeleteWorktree(worktreeID, repositoryID) - } - if case .confirmDeleteWorktrees(let targets)? = button.action.action { - return .confirmDeleteWorktrees(targets) - } } return nil } diff --git a/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift new file mode 100644 index 00000000..70c8250c --- /dev/null +++ b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct DeleteWorktreeConfirmationView: View { + let confirmation: DeleteWorktreeConfirmation + let onDeleteBranchChanged: (Bool) -> Void + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(confirmation.title) + .font(.headline) + Text(confirmation.message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Toggle( + "Also delete local branch", + isOn: Binding( + get: { confirmation.deleteBranch }, + set: onDeleteBranchChanged + ) + ) + .help("Try to delete the local branch with git branch -d after removing the worktree.") + + Text("Protected branches are kept. If safe branch deletion fails, Prowl asks before forcing it.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + Button("Cancel", role: .cancel) { + onCancel() + } + .keyboardShortcut(.cancelAction) + .help("Cancel") + + Button("Delete", role: .destructive) { + onDelete() + } + .keyboardShortcut(.defaultAction) + .help("Delete worktree") + } + } + .padding(24) + .frame(width: 420) + } +} diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 53861d4c..ffbbf019 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -54,7 +54,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { analyticsEnabled: true, crashReportsEnabled: true, githubIntegrationEnabled: true, - deleteBranchOnDeleteWorktree: true, + deleteBranchOnDeleteWorktree: false, mergedWorktreeAction: nil, promptForWorktreeCreation: true, fetchOriginBeforeWorktreeCreation: true, diff --git a/supacode/Features/Settings/Views/WorktreeSettingsView.swift b/supacode/Features/Settings/Views/WorktreeSettingsView.swift index 73b25a63..b8b5ce58 100644 --- a/supacode/Features/Settings/Views/WorktreeSettingsView.swift +++ b/supacode/Features/Settings/Views/WorktreeSettingsView.swift @@ -50,11 +50,11 @@ struct WorktreeSettingsView: View { Section("Cleanup") { VStack(alignment: .leading) { Toggle( - "Also delete local branch when deleting a worktree", + "Preselect branch deletion for Prowl-created worktrees", isOn: $store.deleteBranchOnDeleteWorktree ) - .help("Delete the local branch when deleting a worktree") - Text("Removes the local branch along with the worktree. Remote branches must be deleted on GitHub.") + .help("Preselect local branch deletion for worktrees created by Prowl.") + Text("External worktrees stay unchecked by default. Remote branches must be deleted on GitHub.") .foregroundStyle(.secondary) Text("Uncommitted changes will be lost.") .foregroundStyle(.red) diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index deccdd61..ce8363e1 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -642,22 +642,16 @@ struct AppFeatureCommandPaletteTests { AppFeature() } - let expectedAlert = AlertState { - TextState("🚨 Delete worktree?") - } actions: { - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { - TextState("Delete (⌘↩)") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState("Delete \(worktree.name)? This deletes the worktree directory and its local branch.") - } - await store.send(.commandPalette(.delegate(.deleteWorktree(worktree.id, repository.id)))) await store.receive(\.repositories.worktreeLifecycle.requestDeleteWorktree) { - $0.repositories.alert = expectedAlert + $0.repositories.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( + id: 0, + title: "Delete worktree?", + message: "Delete \(worktree.name)? The worktree directory will be removed.", + targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + deleteBranch: false + ) + $0.repositories.nextDeleteWorktreeConfirmationID = 1 } } diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift new file mode 100644 index 00000000..ab92e416 --- /dev/null +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing + +@testable import supacode + +struct GitClientRemoveWorktreeTests { + @Test func removeWorktreeDoesNotDeleteMainBranch() async throws { + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("for-each-ref") { + return ShellOutput(stdout: "main\nfeature\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let worktree = Worktree( + id: "/tmp/repo-main-copy", + name: "main", + detail: "../repo-main-copy", + workingDirectory: URL(fileURLWithPath: "/tmp/repo-main-copy"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + + _ = try await client.removeWorktree(worktree, deleteBranch: true) + + let calls = await store.calls + #expect(calls.contains { $0.contains("worktree") && $0.contains("remove") }) + #expect(calls.contains { $0.contains("for-each-ref") }) + #expect(!calls.contains { $0.suffix(3) == ["branch", "-d", "main"] }) + #expect(!calls.contains { $0.suffix(3) == ["branch", "-D", "main"] }) + } + + @Test func removeWorktreeDeletesNonProtectedLocalBranchWhenRequested() async throws { + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("for-each-ref") { + return ShellOutput(stdout: "main\nfeature\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let worktree = Worktree( + id: "/tmp/repo-feature", + name: "feature", + detail: "../repo-feature", + workingDirectory: URL(fileURLWithPath: "/tmp/repo-feature"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + + _ = try await client.removeWorktree(worktree, deleteBranch: true) + + let calls = await store.calls + #expect(calls.contains { $0.suffix(3) == ["branch", "-d", "feature"] }) + } + + @Test func forceDeleteLocalBranchUsesForceFlag() async throws { + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("for-each-ref") { + return ShellOutput(stdout: "feature\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let outcome = try await client.deleteLocalBranch( + named: "feature", + for: URL(fileURLWithPath: "/tmp/repo"), + force: true + ) + + #expect(outcome == .deleted) + let calls = await store.calls + #expect(calls.contains { $0.suffix(3) == ["branch", "-D", "feature"] }) + } + + @Test func safeDeleteLocalBranchPropagatesGitFailure() async { + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("for-each-ref") { + return ShellOutput(stdout: "feature\n", stderr: "", exitCode: 0) + } + throw ShellClientError(command: "git branch -d feature", stdout: "", stderr: "not fully merged", exitCode: 1) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + await #expect(throws: GitClientError.self) { + _ = try await client.deleteLocalBranch( + named: "feature", + for: URL(fileURLWithPath: "/tmp/repo"), + force: false + ) + } + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index db54f2a4..4fb38c9e 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2262,22 +2262,99 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - let expectedAlert = AlertState { - TextState("🚨 Delete worktree?") - } actions: { - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { - TextState("Delete (⌘↩)") + await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { + $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( + id: 0, + title: "Delete worktree?", + message: "Delete \(worktree.name)? The worktree directory will be removed.", + targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + deleteBranch: false + ) + $0.nextDeleteWorktreeConfirmationID = 1 + } + } + + @Test(.dependencies) func requestDeleteProwlCreatedWorktreeCanPreselectBranchDeletion() async { + let worktree = makeWorktree(id: "/tmp/wt", name: "owl") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + state.$prowlCreatedWorktreeIDs.withLock { + $0 = [worktree.id] + } + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global.deleteBranchOnDeleteWorktree = true + } + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { + $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( + id: 0, + title: "Delete worktree?", + message: "Delete \(worktree.name)? The worktree directory will be removed.", + targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + deleteBranch: true + ) + $0.nextDeleteWorktreeConfirmationID = 1 + } + } + + @Test(.dependencies) func deletePromptConfirmedAsksBeforeForceDeletingBranch() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) + var state = makeState(repositories: [repository]) + state.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( + id: 0, + title: "Delete worktree?", + message: "Delete feature? The worktree directory will be removed.", + targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + deleteBranch: true + ) + let forceDeleteAttempts = LockIsolated<[Bool]>([]) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.removeWorktree = { worktree, deleteBranch in + #expect(deleteBranch == false) + return worktree.workingDirectory } - ButtonState(role: .cancel) { - TextState("Cancel") + $0.gitClient.deleteLocalBranch = { _, _, force in + forceDeleteAttempts.withValue { $0.append(force) } + if force { + return .deleted + } + throw GitClientError.commandFailed(command: "git branch -d feature", message: "not fully merged") } - } message: { - TextState("Delete \(worktree.name)? This deletes the worktree directory and its local branch.") + $0.gitClient.worktrees = { _ in [mainWorktree] } } + store.exhaustivity = .off - await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { - $0.alert = expectedAlert + await store.send(.worktreeLifecycle(.deleteWorktreePromptConfirmed)) { + $0.deleteWorktreeConfirmation = nil + } + await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { + $0.deletingWorktreeIDs = [worktree.id] + } + await store.receive(\.worktreeLifecycle.worktreeDeleted) { + $0.deletingWorktreeIDs = [] + $0.repositories = [makeRepository(id: repoRoot, worktrees: [mainWorktree])] + #expect($0.alert != nil) } + await store.send( + .alert( + .presented( + .confirmForceDeleteBranch( + ForceDeleteBranchRequest( + branchName: "feature", + repositoryRootURL: URL(fileURLWithPath: repoRoot), + errorMessage: "Git command failed: git branch -d feature\nnot fully merged" + ))))) + + #expect(forceDeleteAttempts.value == [false, true]) } @Test func requestDeleteMainWorktreeShowsNotAllowedAlert() async { @@ -2314,21 +2391,15 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - let expectedAlert = AlertState { - TextState("🚨 Delete 2 worktrees?") - } actions: { - ButtonState(role: .destructive, action: .confirmDeleteWorktrees(targets)) { - TextState("Delete 2 (⌘↩)") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState("Delete 2 worktrees? This deletes the worktree directories and their local branches.") - } - await store.send(.worktreeLifecycle(.requestDeleteWorktrees(targets))) { - $0.alert = expectedAlert + $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( + id: 0, + title: "Delete 2 worktrees?", + message: "Delete 2 worktrees? Their worktree directories will be removed.", + targets: targets, + deleteBranch: false + ) + $0.nextDeleteWorktreeConfirmationID = 1 } } @@ -3228,7 +3299,8 @@ struct RepositoriesFeatureTests { removedWorktree.id, repositoryID: repository.id, selectionWasRemoved: false, - nextSelection: nil + nextSelection: nil, + forceDeleteBranchRequest: nil )) ) { $0.deletingWorktreeIDs = [] @@ -3268,7 +3340,8 @@ struct RepositoriesFeatureTests { removedWorktree.id, repositoryID: repository.id, selectionWasRemoved: false, - nextSelection: nil + nextSelection: nil, + forceDeleteBranchRequest: nil )) ) { $0.deletingWorktreeIDs = [] @@ -3320,6 +3393,9 @@ struct RepositoriesFeatureTests { $0.pendingWorktrees = [] $0.selection = .worktree(newWorktree.id) $0.sidebarSelectedWorktreeIDs = [newWorktree.id] + $0.$prowlCreatedWorktreeIDs.withLock { + $0.append(newWorktree.id) + } $0.repositories = [updatedRepository] } diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index cf2ddbea..378313b6 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -159,7 +159,7 @@ struct SettingsFilePersistenceTests { #expect(settings.global.analyticsEnabled == true) #expect(settings.global.crashReportsEnabled == true) #expect(settings.global.githubIntegrationEnabled == true) - #expect(settings.global.deleteBranchOnDeleteWorktree == true) + #expect(settings.global.deleteBranchOnDeleteWorktree == false) #expect(settings.global.mergedWorktreeAction == nil) #expect(settings.global.promptForWorktreeCreation == true) #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) -- 2.51.2 From 3d31204ad6fc9cf70040cf933c67257f0374cf94 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 11:40:11 +0900 Subject: [PATCH 257/528] Relax shell cancellation timing tests --- supacodeTests/ShellClientStreamingTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supacodeTests/ShellClientStreamingTests.swift b/supacodeTests/ShellClientStreamingTests.swift index f0d95b35..97e51166 100644 --- a/supacodeTests/ShellClientStreamingTests.swift +++ b/supacodeTests/ShellClientStreamingTests.swift @@ -155,7 +155,7 @@ struct ShellClientStreamingTests { let elapsed = ContinuousClock.now - start #expect( - elapsed < .seconds(2), + elapsed < .seconds(5), "consumer cancel should propagate to the shell process; took \(elapsed)" ) } @@ -176,7 +176,7 @@ struct ShellClientStreamingTests { let elapsed = ContinuousClock.now - start #expect( - elapsed < .seconds(2), + elapsed < .seconds(5), "run() should propagate cancellation to the process; took \(elapsed)" ) } -- 2.51.2 From 2a27653b6c7fa98ee561b4f43ec5d750d5f8e1f2 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Sat, 30 May 2026 11:43:26 +0900 Subject: [PATCH 258/528] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..50c4103a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: onevcat -- 2.51.2 From fb1efd2a6209414741714e311a278d019832adae Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 12:18:10 +0900 Subject: [PATCH 259/528] Address worktree branch deletion review feedback --- ...epositoriesFeature+GithubIntegration.swift | 7 +- ...epositoriesFeature+WorktreeLifecycle.swift | 41 +++++- .../Reducer/RepositoriesFeature.swift | 8 +- supacodeTests/RepositoriesFeatureTests.swift | 129 ++++++++++++++++++ 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 0310bfb5..ff879930 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -240,12 +240,15 @@ extension RepositoriesFeature { @Shared(.settingsFile) var settingsFile return .merge( mergedWorktreeIDs.map { worktreeID in - .send( + let shouldDeleteBranch = + settingsFile.global.deleteBranchOnDeleteWorktree + && state.prowlCreatedWorktreeIDs.contains(worktreeID) + return .send( .worktreeLifecycle( .deleteWorktreeConfirmed( worktreeID, repositoryID, - deleteBranch: settingsFile.global.deleteBranchOnDeleteWorktree + deleteBranch: shouldDeleteBranch )) ) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift index d685a7d8..f12fc7b2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift @@ -502,7 +502,8 @@ extension RepositoriesFeature { ) } if let forceDeleteBranchRequest { - state.alert = forceDeleteBranchAlert(forceDeleteBranchRequest) + state.pendingForceDeleteBranchRequests.append(forceDeleteBranchRequest) + presentNextForceDeleteBranchAlert(state: &state) } return .concatenate( .merge(immediateEffects), @@ -516,6 +517,8 @@ extension RepositoriesFeature { case .forceDeleteBranchConfirmed(let request): state.alert = nil + removePendingForceDeleteBranchRequest(request, state: &state) + presentNextForceDeleteBranchAlert(state: &state) return .run { send in do { _ = try await gitClient.deleteLocalBranch(request.branchName, request.repositoryRootURL, true) @@ -602,3 +605,39 @@ private func forceDeleteBranchAlert(_ request: ForceDeleteBranchRequest) -> Aler ) } } + +func presentNextForceDeleteBranchAlert(state: inout RepositoriesFeature.State) { + guard state.alert == nil, let request = state.pendingForceDeleteBranchRequests.first else { + return + } + state.alert = forceDeleteBranchAlert(request) +} + +func dismissCurrentForceDeleteBranchRequest(state: inout RepositoriesFeature.State) { + guard let request = currentForceDeleteBranchRequest(from: state.alert) else { + state.alert = nil + return + } + state.alert = nil + removePendingForceDeleteBranchRequest(request, state: &state) + presentNextForceDeleteBranchAlert(state: &state) +} + +private func removePendingForceDeleteBranchRequest( + _ request: ForceDeleteBranchRequest, + state: inout RepositoriesFeature.State +) { + state.pendingForceDeleteBranchRequests.removeAll { $0 == request } +} + +private func currentForceDeleteBranchRequest( + from alert: AlertState? +) -> ForceDeleteBranchRequest? { + guard let alert else { return nil } + for button in alert.buttons { + if case .confirmForceDeleteBranch(let request)? = button.action.action { + return request + } + } + return nil +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index d37f9885..40736ceb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -267,6 +267,7 @@ struct RepositoriesFeature { @Shared(.appStorage("prowlCreatedWorktreeIDs")) var prowlCreatedWorktreeIDs: [Worktree.ID] = [] var nextDeleteWorktreeConfirmationID = 0 var deleteWorktreeConfirmation: DeleteWorktreeConfirmation? + var pendingForceDeleteBranchRequests: [ForceDeleteBranchRequest] = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? var nextPendingRenameBranchRequestID = 0 @@ -580,13 +581,16 @@ struct RepositoriesFeature { else { continue } + let shouldDeleteBranch = + settingsFile.global.deleteBranchOnDeleteWorktree + && state.prowlCreatedWorktreeIDs.contains(worktree.id) deleteEffects.append( .send( .worktreeLifecycle( .deleteWorktreeConfirmed( worktree.id, repository.id, - deleteBranch: settingsFile.global.deleteBranchOnDeleteWorktree + deleteBranch: shouldDeleteBranch )) ) ) @@ -1230,7 +1234,7 @@ struct RepositoriesFeature { return .none case .alert(.dismiss): - state.alert = nil + dismissCurrentForceDeleteBranchRequest(state: &state) return .none case .alert: diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 4fb38c9e..0774d03a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2357,6 +2357,62 @@ struct RepositoriesFeatureTests { #expect(forceDeleteAttempts.value == [false, true]) } + @Test(.dependencies, .serialized) func worktreeDeletedPresentsQueuedForceDeleteBranchPromptsInOrder() async { + let repoRoot = "/tmp/repo" + let firstRequest = ForceDeleteBranchRequest( + branchName: "first", + repositoryRootURL: URL(fileURLWithPath: repoRoot), + errorMessage: "first failed" + ) + let secondRequest = ForceDeleteBranchRequest( + branchName: "second", + repositoryRootURL: URL(fileURLWithPath: repoRoot), + errorMessage: "second failed" + ) + let store = TestStore(initialState: makeState(repositories: [])) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.deleteLocalBranch = { _, _, _ in .deleted } + } + store.exhaustivity = .off + + await store.send( + .worktreeLifecycle( + .worktreeDeleted( + "/tmp/repo/first", + repositoryID: repoRoot, + selectionWasRemoved: false, + nextSelection: nil, + forceDeleteBranchRequest: firstRequest + )) + ) { + $0.pendingForceDeleteBranchRequests = [firstRequest] + #expect($0.alert != nil) + } + await store.send( + .worktreeLifecycle( + .worktreeDeleted( + "/tmp/repo/second", + repositoryID: repoRoot, + selectionWasRemoved: false, + nextSelection: nil, + forceDeleteBranchRequest: secondRequest + )) + ) { + $0.pendingForceDeleteBranchRequests = [firstRequest, secondRequest] + #expect($0.alert != nil) + } + await store.send(.alert(.presented(.confirmForceDeleteBranch(firstRequest)))) + await store.receive(\.worktreeLifecycle.forceDeleteBranchConfirmed) { + $0.pendingForceDeleteBranchRequests = [secondRequest] + #expect($0.alert != nil) + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + $0.pendingForceDeleteBranchRequests = [] + } + } + @Test func requestDeleteMainWorktreeShowsNotAllowedAlert() async { let mainWorktree = makeWorktree(id: "/tmp/repo", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [mainWorktree]) @@ -3506,6 +3562,46 @@ struct RepositoriesFeatureTests { } } + @Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let externalWorktree = makeWorktree( + id: "\(repoRoot)/external", + name: "external", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, externalWorktree]) + var state = makeState(repositories: [repository]) + state.mergedWorktreeAction = .delete + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global.deleteBranchOnDeleteWorktree = true + } + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.removeWorktree = { worktree, deleteBranch in + #expect(worktree.id == externalWorktree.id) + #expect(deleteBranch == false) + return worktree.workingDirectory + } + } + store.exhaustivity = .off + let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: externalWorktree.name) + + await store.send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repository.id, + pullRequestsByWorktreeID: [externalWorktree.id: mergedPullRequest] + )) + ) + await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { + $0.deletingWorktreeIDs = [externalWorktree.id] + } + await store.receive(\.worktreeLifecycle.worktreeDeleted) + } + @Test func repositoryPullRequestsLoadedSkipsAutoDeleteForMainWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -4288,6 +4384,39 @@ struct RepositoriesFeatureTests { } store.exhaustivity = .off + await store.send(.autoDeleteExpiredArchivedWorktrees) + await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { + $0.deletingWorktreeIDs = [expiredWorktree.id] + } + await store.receive(\.worktreeLifecycle.worktreeDeleted) + } + + @Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let expiredWorktree = makeWorktree(id: "/tmp/repo/expired", name: "expired", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, expiredWorktree]) + let fixedDate = Date(timeIntervalSince1970: 1_000_000) + let archivedAt = fixedDate.addingTimeInterval(-2 * 86_400) + var state = makeState(repositories: [repository]) + state.archivedWorktrees = [ArchivedWorktree(id: expiredWorktree.id, archivedAt: archivedAt)] + state.archivedAutoDeletePeriod = .oneDay + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global.deleteBranchOnDeleteWorktree = true + } + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.date = .constant(fixedDate) + $0.gitClient.removeWorktree = { worktree, deleteBranch in + #expect(worktree.id == expiredWorktree.id) + #expect(deleteBranch == false) + return worktree.workingDirectory + } + } + store.exhaustivity = .off + await store.send(.autoDeleteExpiredArchivedWorktrees) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [expiredWorktree.id] -- 2.51.2 From e85b39f7bf1984c78fa1248174fc400cc250b192 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 15:18:53 +0900 Subject: [PATCH 260/528] Add per-repository diff & PR observation toggles Let each repository opt out of background refresh work that some users do not need, especially in very large repositories where periodic `git diff` is expensive. - Add `observeLineDiffsAutomatically` and `fetchPullRequestState` to `RepositorySettings` (both default on; decoded unconditionally so old data keeps working without a schema bump). - Rename the "Pull Requests" settings section to "Diffs & Pull Requests" and add two toggles with explanatory captions and tooltips. - Skip the line-change `git diff` in the `.filesChanged` reducer path when observation is disabled. - Skip the batched PR-state refresh at the single `repositoryPullRequestRefreshRequested` choke point when fetching is disabled. - Cover the model defaulting and both reducer gates with tests. Follow-up to the discussion on #364. --- ...epositoriesFeature+GithubIntegration.swift | 4 ++ .../Reducer/RepositoriesFeature.swift | 4 ++ .../Reducer/RepositorySettingsFeature.swift | 8 +++ .../Settings/Models/RepositorySettings.swift | 36 ++++++++++- .../Views/RepositorySettingsView.swift | 60 +++++++++++++++---- ...atchedPullRequestRefreshReducerTests.swift | 35 +++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 29 +++++++++ .../RepositorySettingsKeyTests.swift | 32 ++++++++++ 8 files changed, 194 insertions(+), 14 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index ff879930..ec81b132 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -53,6 +53,10 @@ extension RepositoriesFeature { .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true) case .repositoryPullRequestRefreshRequested(let repositoryRootURL, let worktreeIDs): + @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings + guard repositorySettings.fetchesPullRequestState else { + return .none + } let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) } guard let firstWorktree = worktrees.first, let repositoryID = state.repositoryID(containing: firstWorktree.id) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 40736ceb..ce1a587b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1189,6 +1189,10 @@ struct RepositoriesFeature { guard let worktree = state.worktree(for: worktreeID) else { return .none } + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + guard repositorySettings.observesLineDiffsAutomatically else { + return .none + } let worktreeURL = worktree.workingDirectory let gitClient = gitClient let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 6d4b8388..01b960e9 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -39,10 +39,18 @@ struct RepositorySettingsFeature { capabilities.supportsWorktrees } + var showsDiffSettings: Bool { + capabilities.supportsDiff + } + var showsPullRequestSettings: Bool { capabilities.supportsPullRequests } + var showsDiffsAndPullRequestSettings: Bool { + showsDiffSettings || showsPullRequestSettings + } + var showsSetupScriptSettings: Bool { capabilities.supportsWorktrees } diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index 79ceb7b0..fe808511 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -16,6 +16,14 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var copyUntrackedOnWorktreeCreate: Bool? var pullRequestMergeStrategy: PullRequestMergeStrategy? var customTitle: String? + /// When `nil` (unset) or `true`, Prowl keeps the worktree line-change badges + /// up to date in the background. Set to `false` to skip the periodic `git diff` + /// work for large repositories. + var observeLineDiffsAutomatically: Bool? + /// When `nil` (unset) or `true`, Prowl periodically fetches pull request state + /// for this repository's branches. Set to `false` to skip background GitHub + /// queries (saving API rate-limit budget). + var fetchPullRequestState: Bool? private var schemaVersion: Int private enum CodingKeys: String, CodingKey { @@ -30,6 +38,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy case customTitle + case observeLineDiffsAutomatically + case fetchPullRequestState } static let `default` = RepositorySettings( @@ -42,7 +52,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, pullRequestMergeStrategy: nil, - customTitle: nil + customTitle: nil, + observeLineDiffsAutomatically: nil, + fetchPullRequestState: nil ) init( @@ -55,7 +67,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: Bool? = nil, copyUntrackedOnWorktreeCreate: Bool? = nil, pullRequestMergeStrategy: PullRequestMergeStrategy? = nil, - customTitle: String? = nil + customTitle: String? = nil, + observeLineDiffsAutomatically: Bool? = nil, + fetchPullRequestState: Bool? = nil ) { self.setupScript = setupScript self.archiveScript = archiveScript @@ -67,6 +81,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy self.customTitle = customTitle + self.observeLineDiffsAutomatically = observeLineDiffsAutomatically + self.fetchPullRequestState = fetchPullRequestState schemaVersion = Self.currentSchemaVersion } @@ -93,6 +109,10 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) customTitle = try container.decodeIfPresent(String.self, forKey: .customTitle) + observeLineDiffsAutomatically = + try container.decodeIfPresent(Bool.self, forKey: .observeLineDiffsAutomatically) + fetchPullRequestState = + try container.decodeIfPresent(Bool.self, forKey: .fetchPullRequestState) if decodedSchemaVersion >= Self.currentSchemaVersion { copyIgnoredOnWorktreeCreate = try container.decodeIfPresent( @@ -137,6 +157,18 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { } extension RepositorySettings { + /// Resolved value for background line-change observation. Defaults to `true` + /// when the override is unset. + var observesLineDiffsAutomatically: Bool { + observeLineDiffsAutomatically ?? true + } + + /// Resolved value for background pull request state fetching. Defaults to + /// `true` when the override is unset. + var fetchesPullRequestState: Bool { + fetchPullRequestState ?? true + } + nonisolated private static func normalizeLegacyOverride( _ value: Value?, legacyDefault: Value diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 51202dd7..a8683197 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -67,6 +67,14 @@ struct RepositorySettingsView: View { get: { settings.customTitle.wrappedValue ?? "" }, set: { settings.customTitle.wrappedValue = $0 }, ) + let observeLineDiffsAutomatically = Binding( + get: { settings.observeLineDiffsAutomatically.wrappedValue ?? true }, + set: { settings.observeLineDiffsAutomatically.wrappedValue = $0 }, + ) + let fetchPullRequestState = Binding( + get: { settings.fetchPullRequestState.wrappedValue ?? true }, + set: { settings.fetchPullRequestState.wrappedValue = $0 }, + ) let exampleWorktreePath = store.exampleWorktreePath let folderName = Repository.name(for: store.rootURL) @@ -185,24 +193,52 @@ struct RepositorySettingsView: View { } } - if store.showsPullRequestSettings { + if store.showsDiffsAndPullRequestSettings { Section { - Picker(selection: settings.pullRequestMergeStrategy) { - Text( - "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" + if store.showsDiffSettings { + Toggle(isOn: observeLineDiffsAutomatically) { + Text("Observe line diffs automatically") + Text( + "Keeps each workspace's line-change badge up to date in the background. " + + "Turn off for very large repositories to avoid background git diff work." + ) + } + .help( + "Refresh workspace line-change badges automatically. " + + "Disable to skip background git diff for large repositories." ) - .tag(PullRequestMergeStrategy?.none) - ForEach(PullRequestMergeStrategy.allCases) { strategy in - Text(strategy.title).tag(PullRequestMergeStrategy?.some(strategy)) + } + + if store.showsPullRequestSettings { + Toggle(isOn: fetchPullRequestState) { + Text("Fetch pull request state") + Text( + "Periodically checks pull request status (open, merged, checks) for this repository's branches. " + + "Turn off to skip background GitHub queries." + ) + } + .help( + "Fetch pull request status for this repository's branches. " + + "Disable to skip background GitHub queries and save API rate limit." + ) + + Picker(selection: settings.pullRequestMergeStrategy) { + Text( + "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" + ) + .tag(PullRequestMergeStrategy?.none) + ForEach(PullRequestMergeStrategy.allCases) { strategy in + Text(strategy.title).tag(PullRequestMergeStrategy?.some(strategy)) + } + } label: { + Text("Merge strategy") + Text("Used when merging PRs from the command palette.") } - } label: { - Text("Merge strategy") - Text("Used when merging PRs from the command palette.") } } header: { VStack(alignment: .leading, spacing: 4) { - Text("Pull Requests") - Text("Used when merging PRs from the command palette") + Text("Diffs & Pull Requests") + Text("Background refresh of line-change badges and pull request status") .foregroundStyle(.secondary) } } diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 0daa3f27..0a3e3918 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -169,6 +169,41 @@ struct BatchedPullRequestRefreshReducerTests { } await store.finish() } + + @Test(.dependencies) func refreshSkippedWhenPullRequestStateFetchDisabled() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + var initialState = context.state + initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + + @Shared(.repositorySettings(context.repoRootURL)) var repositorySettings + $repositorySettings.withLock { $0.fetchPullRequestState = false } + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) + await store.finish() + + #expect(enqueued.value.isEmpty) + } } // MARK: - Fixtures diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 0774d03a..aef64e4b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -263,6 +263,35 @@ struct RepositoriesFeatureTests { } } + @Test(.dependencies) func filesChangedSkipsLineChangesWhenObservationDisabled() async { + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( + addedLines: 12, + removedLines: 4, + pullRequest: nil + ) + + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + $repositorySettings.withLock { $0.observeLineDiffsAutomatically = false } + + let lineChangeRequests = LockIsolated(0) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.lineChanges = { _ in + lineChangeRequests.withValue { $0 += 1 } + return (15, 9) + } + } + + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) + await store.finish() + + #expect(lineChangeRequests.value == 0) + } + @Test func repositoryWorktreesChangedReloadsRepositories() async { let existingWorktree = makeWorktree(id: "/tmp/repo/main", name: "main") let discoveredWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature") diff --git a/supacodeTests/RepositorySettingsKeyTests.swift b/supacodeTests/RepositorySettingsKeyTests.swift index 847219c4..ee0497d5 100644 --- a/supacodeTests/RepositorySettingsKeyTests.swift +++ b/supacodeTests/RepositorySettingsKeyTests.swift @@ -136,6 +136,38 @@ struct RepositorySettingsKeyTests { #expect(decoded.pullRequestMergeStrategy == .merge) } + @Test func decodeMissingObservationOverridesDefaultsToEnabled() throws { + let data = Data( + """ + { + "setupScript": "", + "archiveScript": "", + "runScript": "echo run", + "openActionID": "automatic" + } + """.utf8 + ) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + + #expect(settings.observeLineDiffsAutomatically == nil) + #expect(settings.fetchPullRequestState == nil) + #expect(settings.observesLineDiffsAutomatically) + #expect(settings.fetchesPullRequestState) + } + + @Test func decodePreservesExplicitObservationOverrides() throws { + var settings = RepositorySettings.default + settings.observeLineDiffsAutomatically = false + settings.fetchPullRequestState = false + + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: encode(settings)) + + #expect(decoded.observeLineDiffsAutomatically == false) + #expect(decoded.fetchPullRequestState == false) + #expect(!decoded.observesLineDiffsAutomatically) + #expect(!decoded.fetchesPullRequestState) + } + @Test(.dependencies) func loadPrefersLocalSupacodeJSONOverGlobalEntry() throws { let globalStorage = SettingsTestStorage() let localStorage = RepositoryLocalSettingsTestStorage() -- 2.51.2 From f19d2ee7fbea561121a6b59d47226bea5c016ce2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 16:17:51 +0900 Subject: [PATCH 261/528] bump v2026.5.30 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 8ff5e1d5..3febe610 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260526; + CURRENT_PROJECT_VERSION = 20260530; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.26; + MARKETING_VERSION = 2026.5.30; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260526; + CURRENT_PROJECT_VERSION = 20260530; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.26; + MARKETING_VERSION = 2026.5.30; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260526; + CURRENT_PROJECT_VERSION = 20260530; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.26; + MARKETING_VERSION = 2026.5.30; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260526; + CURRENT_PROJECT_VERSION = 20260530; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.26; + MARKETING_VERSION = 2026.5.30; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 0e4c77ff..9addde19 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.26" + public static let current = "2026.5.30" } -- 2.51.2 From 7b0d0f38869a4280a37b63460e3f2478dcdbd36f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 16:17:55 +0900 Subject: [PATCH 262/528] Update CHANGELOG for 2026.5.30 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe64544..9a0f1707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [2026.5.30](https://github.com/onevcat/Prowl/releases/tag/v2026.5.30) + +This release adds per-repository background refresh controls, safer worktree branch deletion, and a range of notification and toolbar customization options. + +### New + +- Each repository now has two toggles in its settings — **Observe line diffs automatically** and **Fetch pull request state** — to opt out of background Git and GitHub refresh work for repositories where you don't need it. +- The Open-in-Editor and Run buttons can now be hidden individually from Settings. A Dock badge showing the unread worktree count and an optional icon bounce are also configurable for when notifications arrive while Prowl is in the background. +- Custom commands and run scripts now work in Canvas mode, with the toolbar updating as you switch between cards. +- Shelf spine tint preferences let you choose a neutral or system-accent fallback color and optionally disable per-repository color overrides. + +### Fixed + +- Closing a terminal tab no longer leaves a stray shell (e.g. `zsh`) running in the background. A stale view update could recreate a terminal surface for an already-closed tab and spawn an orphaned shell process; closed tabs are now never recreated, and a closing tab also tears down its underlying shell. +- The notification bell and Dock badge could remain lit after closing a terminal tab with an unread notification; closing a tab now clears its notifications. +- Active Agents now shows the correct repository and branch based on the agent's actual working directory, not the tab's owning worktree. + +### Improved + +- Deleting a worktree no longer preselects local branch deletion by default. A confirmation sheet now makes the choice explicit, preselects deletion only for Prowl-created worktrees when that preference is enabled, and always prompts before a force-delete. +- Background CPU and I/O on large repositories is reduced: line-diff refreshes are now event-driven (file-system changes plus a 5-minute safety fallback) instead of periodic polling, and PR status is fetched in a single batched GraphQL query per GitHub host rather than one request per repository. + ## [2026.5.26](https://github.com/onevcat/Prowl/releases/tag/v2026.5.26) ### Fixed -- 2.51.2 From b8c85fa30886579525590849576c24240b9b73e1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 30 May 2026 16:29:12 +0900 Subject: [PATCH 263/528] Fix non-Sendable closure conversion warning in delete worktree toggle Passing the stored `onDeleteBranchChanged` property directly as the `set:` of `Binding(get:set:)` triggered a non-Sendable function value conversion warning, since SwiftUI's setter now expects an `@isolated(any) @Sendable` closure. Wrapping it in a closure literal lets the compiler infer `@MainActor` isolation from the view body, satisfying the requirement without changing behavior. --- .../Repositories/Views/DeleteWorktreeConfirmationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift index 70c8250c..98ac9573 100644 --- a/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift +++ b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift @@ -20,7 +20,7 @@ struct DeleteWorktreeConfirmationView: View { "Also delete local branch", isOn: Binding( get: { confirmation.deleteBranch }, - set: onDeleteBranchChanged + set: { onDeleteBranchChanged($0) } ) ) .help("Try to delete the local branch with git branch -d after removing the worktree.") -- 2.51.2 From 396885a7f3df3472c71423399e0d504a14dd0ca8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 1 Jun 2026 10:57:56 +0900 Subject: [PATCH 264/528] Fix duplicate PR refresh repo requests Co-authored-by: onevpaw --- .../PullRequestRefreshCoordinator.swift | 106 ++++++++++++------ .../PullRequestRefreshCoordinatorTests.swift | 102 ++++++++++++++++- 2 files changed, 169 insertions(+), 39 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index 0dca1139..ecfb0610 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -157,28 +157,34 @@ final class PullRequestRefreshCoordinator { } private func processBatch(host: String, requests: [Request]) async { - let crossRepoRequests = requests.map { - CrossRepoPullRequestRequest(owner: $0.owner, repo: $0.repo, branches: $0.branches) + let groupsByKey = groupRequestsByRepo(requests) + let crossRepoRequests = groupsByKey.values.map { group in + CrossRepoPullRequestRequest( + owner: group.key.owner, + repo: group.key.repo, + branches: group.branches + ) } do { let result = try await runBatchWithTimeout(host: host, requests: crossRepoRequests) - let requestsByKey = Dictionary( - uniqueKeysWithValues: requests.map { (RepoKey(owner: $0.owner, repo: $0.repo), $0) } - ) for (key, prsByBranch) in result.successByRepo { - guard let request = requestsByKey[key] else { + guard let group = groupsByKey[key] else { continue } - resultHandler( - .refreshed( - repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, - worktreeIDs: request.worktreeIDs, - prsByBranch: prsByBranch + for request in group.requests { + resultHandler( + .refreshed( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + worktreeIDs: request.worktreeIDs, + prsByBranch: prsByBranch.filter { request.branches.contains($0.key) } + ) ) - ) + } + } + let failedRequests = result.failedRepos.keys.flatMap { key in + groupsByKey[key]?.requests ?? [] } - let failedRequests = result.failedRepos.keys.compactMap { requestsByKey[$0] } if !failedRequests.isEmpty { await fanOutFallback(failedRequests) } @@ -188,12 +194,13 @@ final class PullRequestRefreshCoordinator { } private func fanOutFallback(_ requests: [Request]) async { + let groups = Array(groupRequestsByRepo(requests).values) // Run per-repo fallback requests concurrently; serial awaits here would multiply // a slow recovery path by the number of repos in the batch. await withTaskGroup(of: Void.self) { group in - for request in requests { + for requestGroup in groups { group.addTask { [weak self] in - await self?.fallbackPerRepo(request) + await self?.fallbackPerRepo(requestGroup) } } } @@ -228,30 +235,61 @@ final class PullRequestRefreshCoordinator { } } - private func fallbackPerRepo(_ request: Request) async { + private func fallbackPerRepo(_ group: RepoRequestGroup) async { do { let prs = try await githubCLI.batchPullRequests( - request.host, - request.owner, - request.repo, - request.branches + group.requests[0].host, + group.key.owner, + group.key.repo, + group.branches ) - resultHandler( - .refreshed( - repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, - worktreeIDs: request.worktreeIDs, - prsByBranch: prs + for request in group.requests { + resultHandler( + .refreshed( + repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, + worktreeIDs: request.worktreeIDs, + prsByBranch: prs.filter { request.branches.contains($0.key) } + ) ) - ) + } } catch { - resultHandler( - .failed( - repositoryID: request.repositoryID, - worktreeIDs: request.worktreeIDs, - message: String(describing: error) + for request in group.requests { + resultHandler( + .failed( + repositoryID: request.repositoryID, + worktreeIDs: request.worktreeIDs, + message: String(describing: error) + ) ) - ) + } + } + } + + private func groupRequestsByRepo(_ requests: [Request]) -> [RepoKey: RepoRequestGroup] { + var groupsByKey: [RepoKey: RepoRequestGroup] = [:] + for request in requests { + let key = RepoKey(owner: request.owner, repo: request.repo) + groupsByKey[key, default: RepoRequestGroup(key: key)].append(request) + } + return groupsByKey + } + + private struct RepoRequestGroup: Sendable { + let key: RepoKey + private(set) var requests: [Request] = [] + private(set) var branches: [String] = [] + private var seenBranches: Set = [] + + init(key: RepoKey) { + self.key = key + } + + mutating func append(_ request: Request) { + requests.append(request) + for branch in request.branches where seenBranches.insert(branch).inserted { + branches.append(branch) + } } } diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 5dac9692..c0d1df66 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -227,6 +227,94 @@ struct PullRequestRefreshCoordinatorTests { #expect(Set(alphaRequest.branches) == ["feat-1", "feat-2"]) } + @Test func duplicateRepoKeysBatchOnceAndFanOutToEachRepository() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var dict: [RepoKey: [String: GithubPullRequest]] = [:] + for request in requests { + dict[request.key] = [ + "feat-1": makeFixturePullRequest(repo: request.repo), + "feat-2": makeFixturePullRequest(repo: request.repo), + ] + } + return CrossRepoPullRequestResult(successByRepo: dict) + } + ) + + coordinator.enqueue( + request(repo: "alpha", repositoryID: "alpha-a", branches: ["feat-1"]) + ) + coordinator.enqueue( + request(repo: "alpha", repositoryID: "alpha-b", branches: ["feat-2"]) + ) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 1) + let batchedRequest = try #require(calls.first?.requests.first) + #expect(calls.first?.requests.count == 1) + #expect(batchedRequest.owner == "khoi") + #expect(batchedRequest.repo == "alpha") + #expect(Set(batchedRequest.branches) == ["feat-1", "feat-2"]) + + let snapshots = await outcomes.snapshot() + let refreshed = snapshots.compactMap { outcome -> (Repository.ID, [String])? in + if case .refreshed(let id, _, _, let prs) = outcome { + return (id, Array(prs.keys)) + } + return nil + } + #expect(Set(refreshed.map(\.0)) == ["alpha-a", "alpha-b"]) + #expect(refreshed.first { $0.0 == "alpha-a" }?.1 == ["feat-1"]) + #expect(refreshed.first { $0.0 == "alpha-b" }?.1 == ["feat-2"]) + } + + @Test func duplicateRepoKeysFallbackOnceAndFanOutToEachRepository() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, _ in + throw GithubCLIError.commandFailed("batch unavailable") + }, + legacy: { _, _, repo, branches in + Dictionary( + uniqueKeysWithValues: branches.map { branch in + (branch, makeFixturePullRequest(repo: repo)) + } + ) + } + ) + + coordinator.enqueue( + request(repo: "alpha", repositoryID: "alpha-a", branches: ["feat-1"]) + ) + coordinator.enqueue( + request(repo: "alpha", repositoryID: "alpha-b", branches: ["feat-2"]) + ) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let legacyCalls = await probe.legacyCalls() + #expect(legacyCalls.count == 1) + #expect(legacyCalls.first?.repo == "alpha") + #expect(Set(legacyCalls.first?.branches ?? []) == ["feat-1", "feat-2"]) + let refreshed = await outcomes.refreshedRepositories() + #expect(Set(refreshed) == ["alpha-a", "alpha-b"]) + } + @Test func softTimeoutFallsBackToLegacy() async throws { let clock = TestClock() let probe = CoordinatorProbe() @@ -439,17 +527,21 @@ private func waitUntil( nonisolated private func request( repo: String, + repositoryID: Repository.ID? = nil, + rootPath: String? = nil, host: String = "github.com", - branches: [String] = ["feat-1"] + branches: [String] = ["feat-1"], + worktreeIDs: [Worktree.ID]? = nil ) -> PullRequestRefreshCoordinator.Request { - PullRequestRefreshCoordinator.Request( - repositoryID: repo, - repositoryRootURL: URL(fileURLWithPath: "/tmp/\(repo)"), + let resolvedRepositoryID = repositoryID ?? repo + return PullRequestRefreshCoordinator.Request( + repositoryID: resolvedRepositoryID, + repositoryRootURL: URL(fileURLWithPath: rootPath ?? "/tmp/\(resolvedRepositoryID)"), host: host, owner: "khoi", repo: repo, branches: branches, - worktreeIDs: ["\(repo)-wt"] + worktreeIDs: worktreeIDs ?? ["\(resolvedRepositoryID)-wt"] ) } -- 2.51.2 From 2221dd9e0789741576f516dc624b9bf15400031c Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 1 Jun 2026 13:28:45 +0900 Subject: [PATCH 265/528] bump v2026.6.1 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 3febe610..b9db43bd 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260530; + CURRENT_PROJECT_VERSION = 20260601; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.30; + MARKETING_VERSION = 2026.6.1; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260530; + CURRENT_PROJECT_VERSION = 20260601; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.5.30; + MARKETING_VERSION = 2026.6.1; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260530; + CURRENT_PROJECT_VERSION = 20260601; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.30; + MARKETING_VERSION = 2026.6.1; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260530; + CURRENT_PROJECT_VERSION = 20260601; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.5.30; + MARKETING_VERSION = 2026.6.1; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 9addde19..18f06480 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.5.30" + public static let current = "2026.6.1" } -- 2.51.2 From f5f363031454007e2aee6b2a050e1bd7b3822c63 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 1 Jun 2026 13:28:46 +0900 Subject: [PATCH 266/528] Update CHANGELOG for 2026.6.1 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0f1707..c393ed79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2026.6.1](https://github.com/onevcat/Prowl/releases/tag/v2026.6.1) + +### Fixed + +- Fixed a crash that occurred when multiple local repositories pointed to the same GitHub remote: pull request refresh now correctly batches and fans out results to all matching repos instead of crashing on a duplicate key. +- Internal stability improvements. + ## [2026.5.30](https://github.com/onevcat/Prowl/releases/tag/v2026.5.30) This release adds per-repository background refresh controls, safer worktree branch deletion, and a range of notification and toolbar customization options. -- 2.51.2 From 919be9ba2abd11d601d93ad39e1f8e8105e4ae39 Mon Sep 17 00:00:00 2001 From: Nikolay Zhuravlev Date: Mon, 1 Jun 2026 16:04:05 +0200 Subject: [PATCH 267/528] Fix Default View (Shelf/Canvas) launch race and restoration hang (#380) * Fix Default View (Shelf/Canvas) launch race and restoration hang - Modified WorktreeTerminalManager to always emit layoutRestored(nil) when no snapshot is found, preventing the app from hanging in restoration phase. - Centralized Default View application logic in AppFeature.applyDefaultViewMode. - Fixed race condition where settings might not be loaded yet in RepositoriesFeature by moving launch view application to AppFeature. - Ensured Default View preference is honored in all launch paths (normal, layout restore success, and layout restore failure). * Cover default view layout restore fallback --------- Co-authored-by: onevcat --- .../Features/App/Reducer/AppFeature.swift | 95 ++++++++--- .../Reducer/RepositoriesFeature.swift | 24 --- .../WorktreeTerminalManager.swift | 1 + ...AppFeatureTerminalLayoutRestoreTests.swift | 153 ++++++++++++++++++ supacodeTests/ShelfFeatureTests.swift | 123 -------------- .../WorktreeTerminalManagerTests.swift | 20 +++ 6 files changed, 244 insertions(+), 172 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index be77efd5..b9a33319 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -59,6 +59,7 @@ struct AppFeature { var notificationIndicatorCount: Int = 0 var lastKnownSystemNotificationsEnabled: Bool var launchRestoreMode: LaunchRestoreMode + var hasAppliedInitialViewMode = false var suppressLayoutSaveUntilRelaunch = false var launchedAt: Date? var leftSidebarVisibility: NavigationSplitViewVisibility = .all @@ -287,6 +288,57 @@ struct AppFeature { } } + private func applyDefaultViewMode(into state: inout State) -> Effect { + guard !state.hasAppliedInitialViewMode else { return .none } + state.hasAppliedInitialViewMode = true + + @Shared(.settingsFile) var settingsFile + let shouldEnterShelf = + settingsFile.global.defaultViewMode == .shelf + && !state.repositories.isShelfActive + let shouldEnterCanvas = + settingsFile.global.defaultViewMode == .canvas + && !state.repositories.isShowingCanvas + + var effects: [Effect] = [] + if shouldEnterShelf { + effects.append(.send(.repositories(.toggleShelf))) + } + // Enter Canvas after the selection effects so `.selectCanvas` + // records the just-selected worktree as the pre-Canvas anchor. + if shouldEnterCanvas { + if state.repositories.selectedTerminalWorktree == nil, + let targetID = defaultViewFallbackSelectionID(in: state.repositories) + { + effects.append(defaultViewSelectionEffect(for: targetID, repositories: state.repositories)) + } + effects.append(.send(.repositories(.toggleCanvas))) + } + return effects.isEmpty ? .none : .concatenate(effects) + } + + private func defaultViewFallbackSelectionID(in repositories: RepositoriesFeature.State) -> Worktree.ID? { + let candidateIDs = + [repositories.lastFocusedWorktreeID].compactMap(\.self) + + repositories.orderedWorktreeRows().map(\.id) + return candidateIDs.first { id in + repositories.worktree(for: id) != nil + || repositories.repositories[id: id]?.kind == .plain + } + } + + private func defaultViewSelectionEffect( + for targetID: Worktree.ID, + repositories: RepositoriesFeature.State + ) -> Effect { + if repositories.worktree(for: targetID) == nil, + repositories.repositories[id: targetID]?.kind == .plain + { + return .send(.repositories(.selectRepository(targetID))) + } + return .send(.repositories(.selectWorktree(targetID))) + } + var body: some Reducer { let core = Reduce { state, action in switch action { @@ -458,6 +510,7 @@ struct AppFeature { let shouldRestoreLayout = state.launchRestoreMode == .restoreLayout && state.repositories.snapshotPersistencePhase == .active + let shouldDeferDefaultView = state.launchRestoreMode == .restoreLayout appLogger.info( "[LayoutRestore] repositoriesChanged: mode=\(String(describing: state.launchRestoreMode))" + " phase=\(String(describing: state.repositories.snapshotPersistencePhase))" @@ -470,6 +523,10 @@ struct AppFeature { state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories)) appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)") + var allEffects: [Effect] = [] + if !shouldDeferDefaultView { + allEffects.append(applyDefaultViewMode(into: &state)) + } if case .repository(let repositoryID)? = state.settings.selection, !repositories.contains(where: { $0.id == repositoryID }) { @@ -494,7 +551,8 @@ struct AppFeature { } ) } - return .merge(effects) + allEffects.append(.merge(effects)) + return .merge(allEffects) } var effects: [Effect] = [ .send(.commandPalette(.pruneRecency(recencyIDs))), @@ -516,7 +574,8 @@ struct AppFeature { } ) } - return .merge(effects) + allEffects.append(.merge(effects)) + return .merge(allEffects) case .repositories(.delegate(.openRepositorySettings(let repositoryID))): guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { @@ -1345,19 +1404,10 @@ struct AppFeature { case .terminalEvent(.layoutRestored(let selectedWorktreeID)): appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") - // Once layout is restored the saved tabs have all been re-created - // (each emits `tabCreated` → `markWorktreeOpened`) and a valid - // active worktree is in hand — the right moment to honor the - // "Default View = Shelf" preference for Layout-Restore launches, - // which the `repositorySnapshotLoaded` hook intentionally - // deferred to avoid a selection flash. - @Shared(.settingsFile) var settingsFile - let shouldEnterShelf = - settingsFile.global.defaultViewMode == .shelf - && !state.repositories.isShelfActive - let shouldEnterCanvas = - settingsFile.global.defaultViewMode == .canvas - && !state.repositories.isShowingCanvas + // Layout restore has settled: tabs are re-created, selection is set. + // Now apply the default view preference, which was deferred in + // `repositoriesChanged` (via `shouldDeferDefaultView`) to avoid + // stray spines and a selection flash. var effects: [Effect] = [] if let selectedWorktreeID { // Plain folders use .repository selection, not .worktree @@ -1369,19 +1419,14 @@ struct AppFeature { effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) } } - if shouldEnterShelf { - effects.append(.send(.repositories(.toggleShelf))) - } - // Enter Canvas after the selection effects so `.selectCanvas` - // records the just-selected worktree as the pre-Canvas anchor. - if shouldEnterCanvas { - effects.append(.send(.repositories(.toggleCanvas))) - } - return effects.isEmpty ? .none : .merge(effects) + return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) case .terminalEvent(.layoutRestoreFailed(let message)): appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") - return .send(.repositories(.showToast(.warning(message)))) + return .merge( + .send(.repositories(.showToast(.warning(message)))), + applyDefaultViewMode(into: &state) + ) case .terminalEvent(.tabCreated(let worktreeID)): // Every tab creation (user +, CLI open, layout restore, …) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ce1a587b..0fc814f3 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -520,30 +520,6 @@ struct RepositoriesFeature { if selectionChanged { allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) } - // Apply "Default View = Shelf" preference once the initial - // repository snapshot has landed — reuses `.toggleShelf`'s - // guards (needs ≥1 book, falls back to `lastFocusedWorktreeID` - // when no selection is set yet). `repositorySnapshotLoaded` - // is only sent from `.task` at launch, so this won't re-enter - // Shelf if the user has already exited to Normal in the same - // session. When Layout Restore is about to run, defer to the - // `.layoutRestored` event in AppFeature — otherwise Layout - // Restore clears the selection we just set, and any books not - // in the saved layout would linger as stray spines. - @Shared(.settingsFile) var settingsFile - if state.launchRestoreMode != .restoreLayout { - switch settingsFile.global.defaultViewMode { - case .shelf where !state.isShelfActive: - allEffects.append(.send(.toggleShelf)) - case .canvas where !state.isShowingCanvas: - // `.toggleCanvas` shares Shelf's guards: it only enters - // when at least one worktree row exists, otherwise it - // falls back to Normal. - allEffects.append(.send(.toggleCanvas)) - case .normal, .shelf, .canvas: - break - } - } return .merge(allEffects) case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4dc29a89..6e5f2880 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -575,6 +575,7 @@ final class WorktreeTerminalManager { terminalLogger.info("[LayoutRestore] restore: loading snapshot from disk") guard let payload = await layoutPersistence.loadSnapshot() else { terminalLogger.info("[LayoutRestore] restore: no snapshot found on disk, skipping") + emit(.layoutRestored(selectedWorktreeID: nil)) return } terminalLogger.info( diff --git a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift index 531b90f4..533cfde3 100644 --- a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift +++ b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift @@ -228,6 +228,150 @@ struct AppFeatureTerminalLayoutRestoreTests { } } + @Test(.dependencies) func repositoriesChangedAppliesDefaultShelfWhenNotRestoringLayout() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.selection = .worktree(worktree.id) + setDefaultViewMode(.shelf) + defer { setDefaultViewMode(.normal) } + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { _ in } + $0.worktreeInfoWatcher.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { + $0.hasAppliedInitialViewMode = true + } + await store.receive(\.repositories.toggleShelf) { + $0.repositories.isShelfActive = true + $0.repositories.openedWorktreeIDs = [worktree.id] + $0.repositories.pendingTerminalFocusWorktreeIDs = [worktree.id] + } + await store.finish() + } + + @Test(.dependencies) func repositoriesChangedAppliesDefaultCanvasWhenNotRestoringLayout() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.selection = .worktree(worktree.id) + setDefaultViewMode(.canvas) + defer { setDefaultViewMode(.normal) } + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + $0.worktreeInfoWatcher.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { + $0.hasAppliedInitialViewMode = true + } + await store.receive(\.repositories.toggleCanvas) + await store.receive(\.repositories.selectCanvas) { + $0.repositories.preCanvasWorktreeID = worktree.id + $0.repositories.preCanvasTerminalTargetID = worktree.id + $0.repositories.selection = .canvas + } + await store.finish() + + #expect( + sentCommands.value.contains( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) + ) + ) + } + + @Test(.dependencies) func layoutRestoredNilAppliesDefaultCanvasWithLastFocusedAnchor() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.lastFocusedWorktreeID = worktree.id + repositoriesState.selection = nil + setDefaultViewMode(.canvas) + defer { setDefaultViewMode(.normal) } + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestored(selectedWorktreeID: nil))) { + $0.hasAppliedInitialViewMode = true + } + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + $0.repositories.openedWorktreeIDs = [worktree.id] + } + await store.receive(\.repositories.toggleCanvas) + await store.receive(\.repositories.selectCanvas) { + $0.repositories.preCanvasWorktreeID = worktree.id + $0.repositories.preCanvasTerminalTargetID = worktree.id + $0.repositories.selection = .canvas + } + await store.finish() + + #expect( + sentCommands.value.contains( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) + ) + ) + } + + @Test(.dependencies) func layoutRestoreFailedAppliesDefaultShelf() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.lastFocusedWorktreeID = worktree.id + repositoriesState.selection = nil + setDefaultViewMode(.shelf) + defer { setDefaultViewMode(.normal) } + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.terminalEvent(.layoutRestoreFailed(message: "Invalid layout"))) { + $0.hasAppliedInitialViewMode = true + } + await store.receive(\.repositories.showToast) { + $0.repositories.statusToast = .warning("Invalid layout") + } + await store.receive(\.repositories.toggleShelf) { + $0.repositories.isShelfActive = true + } + await store.receive(\.repositories.selectWorktree) { + $0.repositories.selection = .worktree(worktree.id) + $0.repositories.openedWorktreeIDs = [worktree.id] + $0.repositories.pendingTerminalFocusWorktreeIDs = [worktree.id] + } + await store.finish() + } + @Test(.dependencies) func scenePhaseInactiveSavesLayoutSnapshot() async { let sentCommands = LockIsolated<[TerminalClient.Command]>([]) var settings = SettingsFeature.State() @@ -351,3 +495,12 @@ private func makePlainRepository() -> Repository { worktrees: IdentifiedArray() ) } + +private func setDefaultViewMode(_ mode: DefaultViewMode) { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + var updated = $0.global + updated.defaultViewMode = mode + $0.global = updated + } +} diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index a801298e..6e513b19 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -809,68 +809,6 @@ struct ShelfFeatureTests { await store.finish() } - @Test(.dependencies) func defaultViewShelfPreferenceDispatchesToggleAfterSnapshot() async { - let repoRoot = "/tmp/default-shelf-repo" - let rootURL = URL(fileURLWithPath: repoRoot) - let worktree = Worktree( - id: "\(repoRoot)/main", - name: "main", - detail: "", - workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), - repositoryRootURL: rootURL - ) - let repository = Repository( - id: repoRoot, - rootURL: rootURL, - name: "repo", - worktrees: IdentifiedArray(uniqueElements: [worktree]) - ) - - @Shared(.settingsFile) var settingsFile - $settingsFile.withLock { - var updated = $0.global - updated.defaultViewMode = .shelf - $0.global = updated - } - // Restore settings after the test so `@Shared` state doesn't leak - // across parallel test runs in the same process. - defer { - $settingsFile.withLock { - var updated = $0.global - updated.defaultViewMode = .normal - $0.global = updated - } - } - - // Drive only the reducer action under test, not the full `.task` - // flow — the launch pipeline is covered elsewhere. This isolates - // the new "default view shelf" hook from unrelated effects and - // keeps the assertion surface minimal. - var initialState = RepositoriesFeature.State() - initialState.lastFocusedWorktreeID = worktree.id - initialState.shouldRestoreLastFocusedWorktree = true - initialState.snapshotPersistencePhase = .restoring - let store = TestStore(initialState: initialState) { - RepositoriesFeature() - } - - await store.send(.repositorySnapshotLoaded([repository])) { - $0.repositories = [repository] - $0.repositoryRoots = [rootURL] - $0.selection = .worktree(worktree.id) - $0.shouldRestoreLastFocusedWorktree = false - $0.isInitialLoadComplete = true - } - await store.receive(\.delegate.repositoriesChanged) - await store.receive(\.delegate.selectedWorktreeChanged) - await store.receive(\.toggleShelf) { - $0.isShelfActive = true - $0.openedWorktreeIDs = [worktree.id] - $0.pendingTerminalFocusWorktreeIDs = [worktree.id] - } - await store.finish() - } - @Test(.dependencies) func defaultViewShelfDefersDuringLayoutRestore() async { // When Layout Restore is active the snapshot-load hook must stay // quiet: Layout Restore clears selection and replays tabs, so @@ -930,67 +868,6 @@ struct ShelfFeatureTests { await store.finish() } - @Test(.dependencies) func defaultViewCanvasPreferenceDispatchesToggleAfterSnapshot() async { - let repoRoot = "/tmp/default-canvas-repo" - let rootURL = URL(fileURLWithPath: repoRoot) - let worktree = Worktree( - id: "\(repoRoot)/main", - name: "main", - detail: "", - workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), - repositoryRootURL: rootURL - ) - let repository = Repository( - id: repoRoot, - rootURL: rootURL, - name: "repo", - worktrees: IdentifiedArray(uniqueElements: [worktree]) - ) - - @Shared(.settingsFile) var settingsFile - $settingsFile.withLock { - var updated = $0.global - updated.defaultViewMode = .canvas - $0.global = updated - } - // Restore settings after the test so `@Shared` state doesn't leak - // across parallel test runs in the same process. - defer { - $settingsFile.withLock { - var updated = $0.global - updated.defaultViewMode = .normal - $0.global = updated - } - } - - var initialState = RepositoriesFeature.State() - initialState.lastFocusedWorktreeID = worktree.id - initialState.shouldRestoreLastFocusedWorktree = true - initialState.snapshotPersistencePhase = .restoring - let store = TestStore(initialState: initialState) { - RepositoriesFeature() - } - - await store.send(.repositorySnapshotLoaded([repository])) { - $0.repositories = [repository] - $0.repositoryRoots = [rootURL] - $0.selection = .worktree(worktree.id) - $0.shouldRestoreLastFocusedWorktree = false - $0.isInitialLoadComplete = true - } - await store.receive(\.delegate.repositoriesChanged) - await store.receive(\.delegate.selectedWorktreeChanged) - // `.toggleCanvas` enters Canvas via `.selectCanvas`, which records the - // current worktree as the pre-Canvas anchor before flipping selection. - await store.receive(\.toggleCanvas) - await store.receive(\.selectCanvas) { - $0.preCanvasWorktreeID = worktree.id - $0.preCanvasTerminalTargetID = worktree.id - $0.selection = .canvas - } - await store.finish() - } - @Test(.dependencies) func defaultViewCanvasDefersDuringLayoutRestore() async { // Mirrors the Shelf deferral: during Layout Restore the snapshot-load // hook stays quiet and the AppFeature `.layoutRestored` path takes over. diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index d10f0415..72dfa940 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -473,6 +473,26 @@ struct WorktreeTerminalManagerTests { #expect(event == .layoutRestoreFailed(message: "Saved terminal layout was invalid and has been reset")) } + @Test func restoreLayoutSnapshotEmitsRestoredNilWhenSnapshotMissing() async { + let manager = WorktreeTerminalManager( + runtime: GhosttyRuntime(), + layoutPersistence: TerminalLayoutPersistenceClient( + loadSnapshot: { nil }, + saveSnapshot: { _ in true }, + clearSnapshot: { true } + ) + ) + let stream = manager.eventStream() + + await manager.restoreLayoutSnapshot(from: [makeWorktree()]) + + let event = await nextEvent(stream) { event in + event == .layoutRestored(selectedWorktreeID: nil) + } + + #expect(event == .layoutRestored(selectedWorktreeID: nil)) + } + @Test func persistLayoutSnapshotWithoutTabsClearsSnapshot() async { let clearCount = LockIsolated(0) let saveCount = LockIsolated(0) -- 2.51.2 From eaddf55aa2b5c45b6f7e768d90bcab20a655512c Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 1 Jun 2026 23:28:47 +0900 Subject: [PATCH 268/528] Harden main window surfacing diagnostics --- supacode/App/WindowSurfacing.swift | 100 ++++++++++++++++++++--- supacode/App/supacodeApp.swift | 9 +- supacodeTests/WindowSurfacingTests.swift | 54 ++++++++++++ 3 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 supacodeTests/WindowSurfacingTests.swift diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index 4092b8ba..99752642 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -15,9 +15,11 @@ extension NSApplication { // SwiftUI tore down (or never created) the singleton main Window scene. // A bare activate() cannot bring it back; only openWindow(id:) rebuilds // it. See MainWindowOpener / issue #297. + if MainWindowOpener.shared.hasRegisteredOpener { + WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(openWindowRequested)") + } if MainWindowOpener.shared.openMainWindow() { WindowLifecycleDiagnostics.log("surfaceMainWindow: no candidate -> openWindow(id:.main) requested") - WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(openWindowRequested)") activate(ignoringOtherApps: true) return true } @@ -35,23 +37,74 @@ extension NSApplication { } activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) + WindowLifecycleDiagnostics.noteMainWindowAppeared() return true } private func mainWindowCandidate() -> NSWindow? { - if let window = windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { - return window - } - let candidates = windows.filter { !($0 is NSPanel) } - if let window = candidates.first(where: { $0.identifier?.rawValue != WindowID.settings }) { - return window - } - return candidates.first + MainWindowSurface.mainWindowCandidate(in: windows) } } // MARK: - Issue #297 diagnostics +enum MainWindowSurface { + struct Snapshot: Equatable { + let identifier: String? + let isVisible: Bool + } + + static func mainWindowCandidate(in windows: [NSWindow]) -> NSWindow? { + let snapshots = windows.map(snapshot(for:)) + guard let index = mainWindowIndex(in: snapshots) else { return nil } + return windows[index] + } + + static func hasVisibleMainWindow(in windows: [NSWindow]) -> Bool { + hasVisibleMainWindow(in: windows.map(snapshot(for:))) + } + + static func mainWindowCount(in windows: [NSWindow]) -> Int { + mainWindowCount(in: windows.map(snapshot(for:))) + } + + static func visibleMainWindowCount(in windows: [NSWindow]) -> Int { + visibleMainWindowCount(in: windows.map(snapshot(for:))) + } + + static func visibleWindowCount(in windows: [NSWindow]) -> Int { + visibleWindowCount(in: windows.map(snapshot(for:))) + } + + static func mainWindowIndex(in snapshots: [Snapshot]) -> Int? { + snapshots.firstIndex(where: isMainWindow) + } + + static func hasVisibleMainWindow(in snapshots: [Snapshot]) -> Bool { + snapshots.contains { isMainWindow($0) && $0.isVisible } + } + + static func mainWindowCount(in snapshots: [Snapshot]) -> Int { + snapshots.filter(isMainWindow).count + } + + static func visibleMainWindowCount(in snapshots: [Snapshot]) -> Int { + snapshots.filter { isMainWindow($0) && $0.isVisible }.count + } + + static func visibleWindowCount(in snapshots: [Snapshot]) -> Int { + snapshots.filter(\.isVisible).count + } + + private static func snapshot(for window: NSWindow) -> Snapshot { + Snapshot(identifier: window.identifier?.rawValue, isVisible: window.isVisible) + } + + private static func isMainWindow(_ snapshot: Snapshot) -> Bool { + snapshot.identifier == WindowID.main + } +} + @MainActor enum WindowLifecycleDiagnostics { private static let logger = SupaLogger("WindowLifecycle") @@ -93,8 +146,10 @@ enum WindowLifecycleDiagnostics { } static func noteWindowlessIfNoMainWindow(_ context: String) { - let hasMain = NSApplication.shared.windows.contains { $0.identifier?.rawValue == WindowID.main } - guard !hasMain else { return } + guard !MainWindowSurface.hasVisibleMainWindow(in: NSApplication.shared.windows) else { + noteMainWindowAppeared() + return + } noteWindowless(context) } @@ -113,6 +168,10 @@ enum WindowLifecycleDiagnostics { MainActor.assumeIsolated { windowlessReminderScheduled = false guard let since = windowlessSince else { return } + guard !MainWindowSurface.hasVisibleMainWindow(in: NSApplication.shared.windows) else { + noteMainWindowAppeared() + return + } let seconds = Date.now.timeIntervalSince(since) log( String( @@ -198,15 +257,27 @@ enum WindowLifecycleDiagnostics { event.message = SentryMessage(formatted: "Prowl main window surfacing \(kind)") event.logger = "WindowLifecycle" event.fingerprint = ["prowl", "main-window-surfacing", kind] + let windows = NSApplication.shared.windows + let mainWindowCount = MainWindowSurface.mainWindowCount(in: windows) + let visibleMainWindowCount = MainWindowSurface.visibleMainWindowCount(in: windows) + let visibleWindowCount = MainWindowSurface.visibleWindowCount(in: windows) event.tags = [ "window_lifecycle_kind": kind, + "windowless_context": context, "app_active": NSApp.isActive ? "true" : "false", "main_window_opener_registered": MainWindowOpener.shared.hasRegisteredOpener ? "true" : "false", + "has_visible_main_window": visibleMainWindowCount > 0 ? "true" : "false", + "main_window_count": "\(mainWindowCount)", + "visible_main_window_count": "\(visibleMainWindowCount)", + "visible_window_count": "\(visibleWindowCount)", ] var extra: [String: Any] = [ "elapsed_seconds": elapsed, "windowless_context": context, - "windows": windowsSummary(), + "windows": windowsSummary(windows), + "main_window_count": mainWindowCount, + "visible_main_window_count": visibleMainWindowCount, + "visible_window_count": visibleWindowCount, "max_heartbeat_lag_seconds": maxHeartbeatLagDuringWindowless, "arguments": ProcessInfo.processInfo.arguments.joined(separator: " "), ] @@ -219,7 +290,10 @@ enum WindowLifecycleDiagnostics { } private static func windowsSummary() -> String { - let windows = NSApplication.shared.windows + windowsSummary(NSApplication.shared.windows) + } + + private static func windowsSummary(_ windows: [NSWindow]) -> String { guard !windows.isEmpty else { return "windows[0]=(none)" } let parts = windows.map { window -> String in let id = window.identifier?.rawValue ?? "nil" diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index d6f5aa51..b0f868ca 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -59,9 +59,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { func applicationDidBecomeActive(_ notification: Notification) { let app = NSApplication.shared - let hasVisibleMainWindow = app.windows.contains { window in - window.isVisible && !(window is NSPanel) - } + let hasVisibleMainWindow = MainWindowSurface.hasVisibleMainWindow(in: app.windows) WindowLifecycleDiagnostics.logWithWindows( "applicationDidBecomeActive hasVisibleMainWindow=\(hasVisibleMainWindow)" ) @@ -72,7 +70,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { WindowLifecycleDiagnostics.logWithWindows("applicationShouldHandleReopen hasVisibleWindows=\(flag)") - if flag { return true } + if flag, MainWindowSurface.hasVisibleMainWindow(in: sender.windows) { + WindowLifecycleDiagnostics.noteMainWindowAppeared() + return true + } let surfaced = sender.surfaceMainWindow() WindowLifecycleDiagnostics.log("applicationShouldHandleReopen surfaced=\(surfaced) -> handled=\(!surfaced)") return !surfaced diff --git a/supacodeTests/WindowSurfacingTests.swift b/supacodeTests/WindowSurfacingTests.swift new file mode 100644 index 00000000..f2a2ce4f --- /dev/null +++ b/supacodeTests/WindowSurfacingTests.swift @@ -0,0 +1,54 @@ +import Testing + +@testable import supacode + +struct WindowSurfacingTests { + @Test func mainWindowCandidateIgnoresUntaggedHelperWindows() { + let snapshots = [ + MainWindowSurface.Snapshot(identifier: nil, isVisible: true), + MainWindowSurface.Snapshot(identifier: WindowID.settings, isVisible: true), + ] + + #expect(MainWindowSurface.mainWindowIndex(in: snapshots) == nil) + } + + @Test func mainWindowCandidateReturnsTaggedMainWindowIndex() { + let snapshots = [ + MainWindowSurface.Snapshot(identifier: nil, isVisible: true), + MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: false), + ] + + #expect(MainWindowSurface.mainWindowIndex(in: snapshots) == 1) + } + + @Test func visibleMainWindowDetectionRequiresMainIdentifier() { + #expect( + MainWindowSurface.hasVisibleMainWindow( + in: [MainWindowSurface.Snapshot(identifier: nil, isVisible: true)] + ) == false + ) + #expect( + MainWindowSurface.hasVisibleMainWindow( + in: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: false)] + ) == false + ) + #expect( + MainWindowSurface.hasVisibleMainWindow( + in: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: true)] + ) == true + ) + } + + @Test func windowCountsSeparateMainAndVisibleWindows() { + let snapshots = [ + MainWindowSurface.Snapshot(identifier: nil, isVisible: true), + MainWindowSurface.Snapshot(identifier: WindowID.settings, isVisible: true), + MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: false), + MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: true), + ] + + #expect(MainWindowSurface.mainWindowCount(in: snapshots) == 2) + #expect(MainWindowSurface.visibleMainWindowCount(in: snapshots) == 1) + #expect(MainWindowSurface.visibleWindowCount(in: snapshots) == 3) + } +} -- 2.51.2 From c7cd7db7f59521e469767cfccde27b68ad8be34f Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Wed, 3 Jun 2026 22:15:29 +0900 Subject: [PATCH 269/528] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- supacode/App/WindowSurfacing.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index 99752642..4d6644a4 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -145,6 +145,7 @@ enum WindowLifecycleDiagnostics { scheduleWindowlessReminder() } + /// Marks the app as windowless when there is no *visible* `WindowID.main` window. static func noteWindowlessIfNoMainWindow(_ context: String) { guard !MainWindowSurface.hasVisibleMainWindow(in: NSApplication.shared.windows) else { noteMainWindowAppeared() -- 2.51.2 From 1c067b5cab800c29cff685c446914b70af58b529 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 3 Jun 2026 22:30:18 +0900 Subject: [PATCH 270/528] Harden failed worktree cleanup --- supacode/Clients/Git/GitClient.swift | 38 +++++++++- ...RepositoriesFeature+WorktreeCreation.swift | 2 +- .../GitClientRemoveWorktreeTests.swift | 74 ++++++++++++++++++- supacodeTests/RepositoriesFeatureTests.swift | 15 ++-- 4 files changed, 117 insertions(+), 12 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 7af1e6ac..7c756685 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -587,7 +587,14 @@ struct GitClient { let rootPath = worktree.repositoryRootURL.path(percentEncoded: false) let worktreeURL = worktree.workingDirectory.standardizedFileURL let worktreePath = worktreeURL.path(percentEncoded: false) - let relocatedURL = Self.relocateWorktreeDirectory(worktreeURL) + let registeredWorktreePaths = try await registeredWorktreePaths(rootPath: rootPath) + guard registeredWorktreePaths.contains(worktreePath) else { + return worktree.workingDirectory + } + let relocatedURL = + Self.worktreeDirectoryHasGitMetadata(worktreeURL) + ? Self.relocateWorktreeDirectory(worktreeURL) + : nil if let relocatedURL { do { _ = try await runGit( @@ -620,6 +627,14 @@ struct GitClient { return worktree.workingDirectory } + nonisolated private func registeredWorktreePaths(rootPath: String) async throws -> Set { + let output = try await runGit( + operation: .worktreeList, + arguments: ["-C", rootPath, "worktree", "list", "--porcelain"] + ) + return Self.parseGitWorktreePorcelainPaths(output) + } + nonisolated func deleteLocalBranch( named branchName: String, for repoRoot: URL, @@ -914,6 +929,27 @@ struct GitClient { return nil } + nonisolated private static func worktreeDirectoryHasGitMetadata(_ worktreeURL: URL) -> Bool { + let gitMetadataURL = worktreeURL.appending(path: ".git") + return FileManager.default.fileExists(atPath: gitMetadataURL.path(percentEncoded: false)) + } + + nonisolated static func parseGitWorktreePorcelainPaths(_ output: String) -> Set { + Set( + output + .split(whereSeparator: \.isNewline) + .compactMap { line -> String? in + let prefix = "worktree " + guard line.hasPrefix(prefix) else { + return nil + } + return String(line.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .filter { !$0.isEmpty } + ) + } + nonisolated static func parseRepositoryWebInfo(_ remoteURL: String) -> GitRemoteWebInfo? { let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift index 227feb5e..afe9228e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift @@ -626,7 +626,7 @@ extension RepositoriesFeature { let repositoryRootURL = cleanupWorktree.repositoryRootURL effects.append( .run { send in - _ = try? await gitClient.removeWorktree(cleanupWorktree, true) + _ = try? await gitClient.removeWorktree(cleanupWorktree, false) _ = try? await gitClient.pruneWorktrees(repositoryRootURL) await send(.reloadRepositories(animated: true)) } diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift index ab92e416..398e0052 100644 --- a/supacodeTests/GitClientRemoveWorktreeTests.swift +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -6,9 +6,14 @@ import Testing struct GitClientRemoveWorktreeTests { @Test func removeWorktreeDoesNotDeleteMainBranch() async throws { let store = ShellCallStore() + let worktreePath = "/tmp/repo-main-copy" let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) + if arguments.contains("--porcelain") { + return ShellOutput( + stdout: "worktree \(worktreePath)\nHEAD abc\nbranch refs/heads/main\n", stderr: "", exitCode: 0) + } if arguments.contains("for-each-ref") { return ShellOutput(stdout: "main\nfeature\n", stderr: "", exitCode: 0) } @@ -18,10 +23,10 @@ struct GitClientRemoveWorktreeTests { ) let client = GitClient(shell: shell) let worktree = Worktree( - id: "/tmp/repo-main-copy", + id: worktreePath, name: "main", detail: "../repo-main-copy", - workingDirectory: URL(fileURLWithPath: "/tmp/repo-main-copy"), + workingDirectory: URL(fileURLWithPath: worktreePath), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ) @@ -36,9 +41,17 @@ struct GitClientRemoveWorktreeTests { @Test func removeWorktreeDeletesNonProtectedLocalBranchWhenRequested() async throws { let store = ShellCallStore() + let worktreePath = "/tmp/repo-feature" let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) + if arguments.contains("--porcelain") { + return ShellOutput( + stdout: "worktree \(worktreePath)\nHEAD abc\nbranch refs/heads/feature\n", + stderr: "", + exitCode: 0 + ) + } if arguments.contains("for-each-ref") { return ShellOutput(stdout: "main\nfeature\n", stderr: "", exitCode: 0) } @@ -48,10 +61,10 @@ struct GitClientRemoveWorktreeTests { ) let client = GitClient(shell: shell) let worktree = Worktree( - id: "/tmp/repo-feature", + id: worktreePath, name: "feature", detail: "../repo-feature", - workingDirectory: URL(fileURLWithPath: "/tmp/repo-feature"), + workingDirectory: URL(fileURLWithPath: worktreePath), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ) @@ -61,6 +74,59 @@ struct GitClientRemoveWorktreeTests { #expect(calls.contains { $0.suffix(3) == ["branch", "-d", "feature"] }) } + @Test func removeWorktreeDoesNotMoveDirectoryWhenPathIsNotRegisteredExactly() async throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory.appending( + path: "prowl-remove-worktree-\(UUID().uuidString)", + directoryHint: .isDirectory + ) + let parentURL = rootURL.appending(path: ".worktrees/feat", directoryHint: .isDirectory) + let childURL = parentURL.appending(path: "foo", directoryHint: .isDirectory) + try fileManager.createDirectory(at: childURL, withIntermediateDirectories: true) + try Data("gitdir: ../../.git/worktrees/foo\n".utf8).write(to: childURL.appending(path: ".git")) + defer { + try? fileManager.removeItem(at: rootURL) + } + + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("--porcelain") { + return ShellOutput( + stdout: "worktree \(childURL.path(percentEncoded: false))\nHEAD abc\nbranch refs/heads/feat/foo\n", + stderr: "", + exitCode: 0 + ) + } + if arguments.contains("for-each-ref") { + return ShellOutput(stdout: "feat\nfeat/foo\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let worktree = Worktree( + id: parentURL.path(percentEncoded: false), + name: "feat", + detail: ".worktrees/feat", + workingDirectory: parentURL, + repositoryRootURL: rootURL + ) + + _ = try await client.removeWorktree(worktree, deleteBranch: true) + + let parentExists = fileManager.fileExists(atPath: parentURL.path(percentEncoded: false)) + let childExists = fileManager.fileExists(atPath: childURL.path(percentEncoded: false)) + #expect(parentExists) + #expect(childExists) + let calls = await store.calls + #expect(!calls.contains { $0.contains("prune") }) + #expect(!calls.contains { $0.contains("remove") }) + #expect(!calls.contains { $0.suffix(3) == ["branch", "-d", "feat"] }) + } + @Test func forceDeleteLocalBranchUsesForceFlag() async throws { let store = ShellCallStore() let shell = ShellClient( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index aef64e4b..964a3318 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2130,7 +2130,7 @@ struct RepositoriesFeatureTests { globalDefaultPath: "/tmp/worktrees-changed", repositoryOverridePath: nil ) - let removedWorktreePath = LockIsolated(nil) + let removedWorktree = LockIsolated<(path: String, deleteBranch: Bool)?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/worktrees-changed" @@ -2138,9 +2138,11 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { - $0.gitClient.removeWorktree = { worktree, _ in + $0.gitClient.removeWorktree = { worktree, deleteBranch in let workingDirectory = await MainActor.run { worktree.workingDirectory } - removedWorktreePath.withValue { $0 = workingDirectory.path(percentEncoded: false) } + removedWorktree.withValue { + $0 = (workingDirectory.path(percentEncoded: false), deleteBranch) + } return workingDirectory } $0.gitClient.pruneWorktrees = { _ in } @@ -2174,15 +2176,16 @@ struct RepositoriesFeatureTests { await store.finish() #expect(changedBaseDirectory != createTimeBaseDirectory) - #expect(removedWorktreePath.value != nil) + #expect(removedWorktree.value != nil) + #expect(removedWorktree.value?.deleteBranch == false) #expect( - removedWorktreePath.value + removedWorktree.value?.path == createTimeBaseDirectory .appending(path: "new-branch", directoryHint: .isDirectory) .path(percentEncoded: false) ) #expect( - removedWorktreePath.value + removedWorktree.value?.path != changedBaseDirectory .appending(path: "new-branch", directoryHint: .isDirectory) .path(percentEncoded: false) -- 2.51.2 From 2621f04e0a197e08430ad6c752b32bf34b7f37e0 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Wed, 3 Jun 2026 22:41:00 +0900 Subject: [PATCH 271/528] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- supacodeTests/GitClientRemoveWorktreeTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift index 398e0052..63e5ee6a 100644 --- a/supacodeTests/GitClientRemoveWorktreeTests.swift +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -6,7 +6,9 @@ import Testing struct GitClientRemoveWorktreeTests { @Test func removeWorktreeDoesNotDeleteMainBranch() async throws { let store = ShellCallStore() - let worktreePath = "/tmp/repo-main-copy" + let worktreePath = FileManager.default.temporaryDirectory + .appending(path: "prowl-repo-main-copy-\(UUID().uuidString)", directoryHint: .isDirectory) + .path(percentEncoded: false) let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) -- 2.51.2 From 95957064cae70a89ab0aa7b9d2fedefe4bd6a6df Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Wed, 3 Jun 2026 22:41:07 +0900 Subject: [PATCH 272/528] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- supacodeTests/GitClientRemoveWorktreeTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift index 63e5ee6a..94b78365 100644 --- a/supacodeTests/GitClientRemoveWorktreeTests.swift +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -43,7 +43,9 @@ struct GitClientRemoveWorktreeTests { @Test func removeWorktreeDeletesNonProtectedLocalBranchWhenRequested() async throws { let store = ShellCallStore() - let worktreePath = "/tmp/repo-feature" + let worktreePath = FileManager.default.temporaryDirectory + .appending(path: "prowl-repo-feature-\(UUID().uuidString)", directoryHint: .isDirectory) + .path(percentEncoded: false) let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) -- 2.51.2 From 89469a376d3b4f543ec7d5379f25008f9d999319 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 3 Jun 2026 23:55:15 +0900 Subject: [PATCH 273/528] Harden prowl-cli skill against self-pane targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prowl list` includes the caller's own pane, so sending esc/ctrl-c to it self-interrupts the current request. Add a prominent warning to identify the focused pane and never target it, and note that Claude Code TUI panes render asynchronously — `task.status` idle does not mean the output has finished rendering, so `read` may catch partial content. Recommend stable-read / file dump for final output. --- skills/prowl-cli/SKILL.md | 162 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 skills/prowl-cli/SKILL.md diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md new file mode 100644 index 00000000..9343eaab --- /dev/null +++ b/skills/prowl-cli/SKILL.md @@ -0,0 +1,162 @@ +--- +name: prowl-cli +description: Use the Prowl CLI to inspect or control a running Prowl app, especially when a user asks to read from, coordinate, focus, send text to, or send keys to Prowl worktrees, tabs, panes, or sibling agent sessions. +--- + +# Prowl CLI + +Use `prowl` when the user explicitly wants to inspect or control Prowl: reading another pane, checking sibling agent progress, focusing a tab/pane, opening a repo/path in Prowl, or sending text/keys to a Prowl terminal pane. + +Do not use it just because the current shell happens to be inside a Prowl repository. It is a remote-control interface for the running Prowl GUI app. + +## ⚠️ You are probably running *inside* one of these panes + +If your own session was launched from a Prowl terminal, then **one of the panes `prowl list` returns is you.** `prowl list` does not exclude the caller. Acting on your own pane is self-harm: `prowl key --pane esc` (or `ctrl-c`) interrupts or kills *your own* current request, and `send` injects into your own prompt. + +**Before any `send`, `key`, or `focus`, identify your own pane and never target it:** + +```bash +# Your own pane = the focused one (and its cwd matches your $PWD). +echo "$PWD" +prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id' +``` + +Danger signs that a pane you are reading **is yourself**: its content is your own conversation transcript, or it shows a prompt identical to the one you are currently executing. A pane's *tab title* can say anything (e.g. "UniWebView") while its real `cwd` is somewhere else — trust `cwd` and the focused flag, not the title. + +To run an agent "over in project X", do **not** reuse a same-looking existing pane. Open a fresh one and confirm its id differs from your own: + +```bash +prowl open /path/to/project-X --json # returns a brand-new pane id +# verify: the returned .data.target.pane.id != your own focused pane id +``` + +## Core Workflow + +Discover panes first — and immediately mark which pane is yourself (see the warning above) so you never send/key/focus it: + +```bash +prowl list --json +``` + +Select explicit UUIDs from the JSON. Prefer `--pane ` for `read`, `send`, `key`, and `focus`. + +Read a pane: + +```bash +prowl read --pane --last 80 --json +``` + +Send text without waiting: + +```bash +prowl send --pane 'command here' --no-wait --json +``` + +Send and capture command output when machine verification matters: + +```bash +prowl send --pane 'command here' --capture --timeout 30 --json +``` + +Send a key (double-check `` is **not your own** — `esc`/`ctrl-c` sent to yourself aborts your current request): + +```bash +prowl key --pane enter --json +prowl key --pane ctrl-c --json +``` + +Focus a pane: + +```bash +prowl focus --pane --json +``` + +Open a path in Prowl: + +```bash +prowl open /path/to/repo --json +``` + +## Finding Pane IDs + +By worktree path or repository directory name: + +```bash +prowl list --json | jq -r ' + .data.items[] + | select(.worktree.path | rtrimstr("/") | endswith("/Prowl")) + | .pane.id +' +``` + +By selected/focused pane (this is almost always **yourself** — use it to exclude, not to target): + +```bash +prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id' +``` + +By tab or pane title substring: + +```bash +prowl list --json | jq -r ' + .data.items[] + | select((.tab.title + " " + .pane.title) | contains("ProwlCLI")) + | .pane.id +' +``` + +For a compact human scan: + +```bash +prowl list --no-color +``` + +## Waiting and Completion + +`prowl send` waits for shell integration by default. If the target pane does not report command completion, it can return `WAIT_TIMEOUT`. + +Default to `--no-wait` for simple input delivery. Use `--capture --timeout ` when you need an exit code, duration, and captured output. + +`task.status` from `prowl list --json` is useful for coordinating sibling sessions: + +- `running`: the pane/worktree is still busy. +- `idle`: it is likely ready for the next step. + +Polling pattern: + +```bash +prowl list --json | jq -r ' + .data.items[] + | select(.pane.id == "") + | .task.status +' +``` + +## Quoting + +Protect commands from the local shell when they should expand inside the target pane: + +```bash +prowl send --pane 'printf "PWD:%s\n" "$PWD"' --no-wait +``` + +Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, or command substitutions unless local expansion is intended. + +For multiline or generated input, pipe stdin: + +```bash +printf '%s\n' 'echo first' 'echo second' | prowl send --pane --no-wait --json +``` + +## Error Handling + +- `APP_NOT_RUNNING`: Prowl is not running, or its CLI service is unavailable. Ask the user before restarting Prowl. +- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and resolve an explicit pane UUID. +- `WAIT_TIMEOUT`: retry with `--no-wait`, or use a pane with shell integration for `--capture`. +- `UNSUPPORTED_KEY`: inspect `prowl key --help`; use canonical tokens such as `enter`, `esc`, `tab`, `up`, `down`, `ctrl-c`, `cmd-k`, or `f1`. + +## Notes + +- JSON output is the automation surface; text output is for humans. +- `-t/--target` auto-resolves pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane` is safer for automation. +- Future Prowl versions may add commands such as `prowl action`, `prowl tab`, or `prowl pane`. Check `prowl --help` before using commands not listed here. -- 2.51.2 From b5a227e9945bfe6e01c55b2c745a37536a9f4736 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 00:08:53 +0900 Subject: [PATCH 274/528] Add `prowl read --wait-stable` to wait for output to settle `task.status: idle` reflects when an agent's generation ends, not when the pane's TUI has finished painting its reply, so reading at idle (or after a fixed sleep) often captures a half-rendered screen. Add a `--wait-stable` mode to `prowl read` that re-reads the pane on a fixed interval until its content holds steady for a streak covering `--stable-period` (default 800ms), sampling every `--stable-interval` (default 200ms), capped by `--wait-timeout` (default 10s). The response gains `stabilized`, `waited_ms`, and `samples`. Polling runs app-side in ReadCommandHandler with an injectable clock so it can be driven by TestClock in tests. Document the idle-vs-render caveat and both mitigations (wait-stable / file dump) in the prowl-cli skill. --- ProwlCLI/Commands/ReadCommand.swift | 63 +++++++- ProwlCLI/Output/OutputRenderer.swift | 9 ++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 52 +++++++ skills/prowl-cli/SKILL.md | 44 ++++++ supacode/CLIService/ReadCommandHandler.swift | 129 +++++++++++++--- supacode/CLIService/Shared/InputModels.swift | 21 ++- .../Shared/ReadCommandPayload.swift | 17 ++- .../CLIReadCommandHandlerTests.swift | 138 ++++++++++++++++++ 8 files changed, 452 insertions(+), 21 deletions(-) diff --git a/ProwlCLI/Commands/ReadCommand.swift b/ProwlCLI/Commands/ReadCommand.swift index c654e59e..1bd7fd94 100644 --- a/ProwlCLI/Commands/ReadCommand.swift +++ b/ProwlCLI/Commands/ReadCommand.swift @@ -15,6 +15,27 @@ struct ReadCommand: ParsableCommand { @Option(name: .long, help: "Number of recent lines to read (omit for snapshot).") var last: Int? + @Flag(name: .long, help: "Re-read the pane until its output stops changing before returning (good for live TUIs).") + var waitStable = false + + @Option( + name: .long, + help: "Sampling interval in milliseconds while waiting for stable output (50–5000, default: 200)." + ) + var stableInterval: Int? + + @Option( + name: .long, + help: "Output must stay unchanged for this many milliseconds to count as stable (100–60000, default: 800)." + ) + var stablePeriod: Int? + + @Option( + name: .long, + help: "Maximum seconds to keep waiting for stable output (1–300, default: 10)." + ) + var waitTimeout: Int? + @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") var target: String? @@ -29,11 +50,51 @@ struct ReadCommand: ParsableCommand { ) } + try validateStabilityOptions() + let envelope = CommandEnvelope( output: options.outputMode, - command: .read(ReadInput(selector: sel, last: last)) + command: .read(ReadInput( + selector: sel, + last: last, + waitStable: waitStable, + stableIntervalMs: stableInterval, + stablePeriodMs: stablePeriod, + waitTimeoutSeconds: waitTimeout + )) ) try CLIRunner.execute(envelope) } } + + /// Stability tuning options only apply together with `--wait-stable`; validate ranges up front. + private func validateStabilityOptions() throws { + if !waitStable, stableInterval != nil || stablePeriod != nil || waitTimeout != nil { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--stable-interval/--stable-period/--wait-timeout require --wait-stable." + ) + } + + if let stableInterval, stableInterval < 50 || stableInterval > 5000 { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--stable-interval must be between 50 and 5000 milliseconds." + ) + } + + if let stablePeriod, stablePeriod < 100 || stablePeriod > 60000 { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--stable-period must be between 100 and 60000 milliseconds." + ) + } + + if let waitTimeout, waitTimeout < 1 || waitTimeout > 300 { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--wait-timeout must be between 1 and 300 seconds." + ) + } + } } diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 0e5ca763..3053f679 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -314,6 +314,15 @@ enum OutputRenderer { + " \("lines:".dim) \(payload.lineCount)" ) + if let stabilized = payload.stabilized { + let stableLabel = stabilized ? "yes".green : "timed out".yellow + lines.append( + " \("stable:".dim) \(stableLabel)" + + " \("waited:".dim) \(formatDurationMs(payload.waitedMs ?? 0))" + + " \("samples:".dim) \(payload.samples ?? 0)" + ) + } + if let cwd = pane.cwd { lines.append(" \("cwd:".dim) \(cwd)") } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 104b57f4..b221fc3f 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1166,6 +1166,58 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(result.stdout.contains("a\nb\nc"), "Missing text body: \(result.stdout)") } + func testReadWaitStablePassesOptionsToEnvelope() throws { + let socketPath = temporarySocketPath(suffix: "read-wait-stable") + let response = CommandResponse( + ok: true, + command: "read", + schemaVersion: "prowl.cli.read.v1" + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: [ + "read", "--wait-stable", + "--stable-interval", "150", + "--stable-period", "600", + "--wait-timeout", "5", + "--json", + ] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .read(let input) = envelope.command { + XCTAssertTrue(input.waitStable) + XCTAssertEqual(input.stableIntervalMs, 150) + XCTAssertEqual(input.stablePeriodMs, 600) + XCTAssertEqual(input.waitTimeoutSeconds, 5) + } else { + XCTFail("Expected read command envelope") + } + } + + func testReadStabilityOptionsRequireWaitStable() throws { + let result = try runProwl(args: ["read", "--stable-interval", "150", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + + func testReadWaitStableRejectsOutOfRangeInterval() throws { + let result = try runProwl(args: ["read", "--wait-stable", "--stable-interval", "10", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + // MARK: - Helpers private func runWithMockServer( diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 9343eaab..9867af96 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -132,6 +132,50 @@ prowl list --json | jq -r ' ' ``` +### `idle` does not mean the output finished rendering + +`task.status: idle` means the agent's model generation ended — **not** that the +terminal has finished painting its reply. Claude Code's TUI repaints a long +markdown answer line by line, and that rendering lags well behind `idle`. So +reading a pane the instant it goes `idle` (or after a fixed `sleep`) routinely +catches a half-drawn screen: the visible buffer stops mid-answer with the input +prompt `❯` right under it. (Measured: at `idle` only the first ~2 of 6 sections +had rendered; 10s later it still had not finished.) `--last` size does not fix +this — the rest of the answer is not in the buffer yet. + +Two reliable ways to get the **final** output: + +1. **`prowl read --wait-stable`** — re-reads the pane on an interval until its + content stops changing, then returns the settled snapshot: + + ```bash + # bare: 200ms sampling / settle after 800ms quiet / 10s cap + prowl read --pane --last 200 --wait-stable --json + + # tuned + prowl read --pane --wait-stable \ + --stable-interval 200 \ # sample every 200ms + --stable-period 800 \ # content must hold steady 800ms to count as stable + --wait-timeout 10 --json # give up after 10s, return latest anyway + ``` + + The JSON gains `stabilized` (true = settled, false = hit the timeout), + `waited_ms`, and `samples`. Prefer this over manual `sleep`-then-`read`. + + Note this still only sees the rendered buffer, so content Claude Code has + **folded** (`⎿ … +N lines (ctrl+o to expand)`) is never captured no matter + how stable it is. + +2. **Have the agent write its result to a file**, then read the file. This + bypasses both async rendering and folding, so it is the most robust when you + need the complete answer: + + ```bash + prowl send --pane 'summarize … and write it to /tmp/out.md' --no-wait + # … wait for idle … then: + cat /tmp/out.md + ``` + ## Quoting Protect commands from the local shell when they should expand inside the target pane: diff --git a/supacode/CLIService/ReadCommandHandler.swift b/supacode/CLIService/ReadCommandHandler.swift index 13665414..6ed91363 100644 --- a/supacode/CLIService/ReadCommandHandler.swift +++ b/supacode/CLIService/ReadCommandHandler.swift @@ -54,16 +54,18 @@ final class ReadCommandHandler: CommandHandler { private let resolveProvider: ResolveProvider private let captureProvider: CaptureProvider + private let clock: any Clock init( resolveProvider: @escaping ResolveProvider, - captureProvider: @escaping CaptureProvider + captureProvider: @escaping CaptureProvider, + clock: any Clock = ContinuousClock() ) { self.resolveProvider = resolveProvider self.captureProvider = captureProvider + self.clock = clock } - // swiftlint:disable:next async_without_await func handle(envelope: CommandEnvelope) async -> CommandResponse { guard case .read(let input) = envelope.command else { return errorResponse(code: CLIErrorCode.readFailed, message: "Invalid command.") @@ -77,23 +79,26 @@ final class ReadCommandHandler: CommandHandler { return mapResolverError(error) } - guard let captureInput = captureProvider(target) else { - return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to read terminal text.") - } - let capture: ReadCapture - if let last = input.last { - capture = captureLast( - requestedLineCount: last, - viewportText: captureInput.viewportText, - screenText: captureInput.screenText - ) + let stabilized: Bool? + let waitedMs: Int? + let samples: Int? + if input.waitStable { + guard let result = await pollUntilStable(target: target, input: input) else { + return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to read terminal text.") + } + capture = result.capture + stabilized = result.stabilized + waitedMs = result.waitedMs + samples = result.samples } else { - capture = ReadCapture( - text: captureInput.viewportText, - source: .screen, - truncated: false - ) + guard let single = makeCapture(target: target, last: input.last) else { + return errorResponse(code: CLIErrorCode.readFailed, message: "Failed to read terminal text.") + } + capture = single + stabilized = nil + waitedMs = nil + samples = nil } let payload = ReadCommandPayload( @@ -103,7 +108,10 @@ final class ReadCommandHandler: CommandHandler { source: capture.source, truncated: capture.truncated, lineCount: lineCount(in: capture.text), - text: capture.text + text: capture.text, + stabilized: stabilized, + waitedMs: waitedMs, + samples: samples ) do { @@ -118,6 +126,91 @@ final class ReadCommandHandler: CommandHandler { } } + private struct StableResult { + let capture: ReadCapture + let stabilized: Bool + let waitedMs: Int + let samples: Int + } + + /// Default stability tuning, applied when the request omits the corresponding value. + private enum StabilityDefaults { + static let intervalMs = 200 + static let periodMs = 800 + static let timeoutSeconds = 10 + } + + /// Capture the pane's current content, applying `--last` line selection when requested. + private func makeCapture(target: ReadResolvedTarget, last: Int?) -> ReadCapture? { + guard let captureInput = captureProvider(target) else { return nil } + if let last { + return captureLast( + requestedLineCount: last, + viewportText: captureInput.viewportText, + screenText: captureInput.screenText + ) + } + return ReadCapture( + text: captureInput.viewportText, + source: .screen, + truncated: false + ) + } + + /// Re-read the pane on a fixed interval until its content stops changing for a streak of + /// consecutive samples (≈ `period`), or until the timeout caps the total number of samples. + /// Returns the latest capture either way, flagging whether it actually stabilized. + private func pollUntilStable(target: ReadResolvedTarget, input: ReadInput) async -> StableResult? { + let intervalMs = input.stableIntervalMs ?? StabilityDefaults.intervalMs + let periodMs = input.stablePeriodMs ?? StabilityDefaults.periodMs + let timeoutMs = (input.waitTimeoutSeconds ?? StabilityDefaults.timeoutSeconds) * 1000 + let interval = Duration.milliseconds(intervalMs) + + // Consecutive unchanged samples that together cover `period`, and the hard cap from `timeout`. + let requiredStreak = max(1, Int((Double(periodMs) / Double(intervalMs)).rounded(.up))) + let maxSleeps = max(1, Int((Double(timeoutMs) / Double(intervalMs)).rounded(.up))) + + guard var current = makeCapture(target: target, last: input.last) else { return nil } + var streak = 0 + var samples = 1 + var sleeps = 0 + var stabilized = false + + while true { + if streak >= requiredStreak { + stabilized = true + break + } + if sleeps >= maxSleeps { + stabilized = false + break + } + do { + try await clock.sleep(for: interval) + } catch { + break // Cancelled: return the best capture so far. + } + sleeps += 1 + guard let next = makeCapture(target: target, last: input.last) else { + break // Capture became unavailable mid-poll (e.g. pane closed): stop with what we have. + } + samples += 1 + if next.text == current.text { + streak += 1 + } else { + current = next + streak = 0 + } + } + + return StableResult( + capture: current, + stabilized: stabilized, + waitedMs: sleeps * intervalMs, + samples: samples + ) + } + private func captureLast( requestedLineCount: Int, viewportText: String, diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 6bd9fa15..e2d8b16c 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -108,9 +108,28 @@ public struct KeyInput: Codable, Sendable { public struct ReadInput: Codable, Sendable { public let selector: TargetSelector public let last: Int? + /// When true, the app re-reads the pane until its output stops changing before responding. + public let waitStable: Bool + /// Sampling interval in milliseconds while waiting for stable output (nil → app default). + public let stableIntervalMs: Int? + /// Output must stay unchanged for this many milliseconds to count as stable (nil → app default). + public let stablePeriodMs: Int? + /// Maximum seconds to keep waiting for stable output before returning the latest snapshot (nil → app default). + public let waitTimeoutSeconds: Int? - public init(selector: TargetSelector = .none, last: Int? = nil) { + public init( + selector: TargetSelector = .none, + last: Int? = nil, + waitStable: Bool = false, + stableIntervalMs: Int? = nil, + stablePeriodMs: Int? = nil, + waitTimeoutSeconds: Int? = nil + ) { self.selector = selector self.last = last + self.waitStable = waitStable + self.stableIntervalMs = stableIntervalMs + self.stablePeriodMs = stablePeriodMs + self.waitTimeoutSeconds = waitTimeoutSeconds } } diff --git a/supacode/CLIService/Shared/ReadCommandPayload.swift b/supacode/CLIService/Shared/ReadCommandPayload.swift index 2df7cbb2..89ab1e68 100644 --- a/supacode/CLIService/Shared/ReadCommandPayload.swift +++ b/supacode/CLIService/Shared/ReadCommandPayload.swift @@ -11,6 +11,12 @@ public struct ReadCommandPayload: Codable, Sendable, Equatable { public let truncated: Bool public let lineCount: Int public let text: String + /// Whether `--wait-stable` observed the output settle (true) or hit the timeout (false). Nil when not waiting. + public let stabilized: Bool? + /// Total milliseconds spent waiting for stable output. Nil when not waiting. + public let waitedMs: Int? + /// Number of samples taken while waiting for stable output. Nil when not waiting. + public let samples: Int? enum CodingKeys: String, CodingKey { case target @@ -20,6 +26,9 @@ public struct ReadCommandPayload: Codable, Sendable, Equatable { case truncated case lineCount = "line_count" case text + case stabilized + case waitedMs = "waited_ms" + case samples } public init( @@ -29,7 +38,10 @@ public struct ReadCommandPayload: Codable, Sendable, Equatable { source: ReadSource, truncated: Bool, lineCount: Int, - text: String + text: String, + stabilized: Bool? = nil, + waitedMs: Int? = nil, + samples: Int? = nil ) { self.target = target self.mode = mode @@ -38,6 +50,9 @@ public struct ReadCommandPayload: Codable, Sendable, Equatable { self.truncated = truncated self.lineCount = lineCount self.text = text + self.stabilized = stabilized + self.waitedMs = waitedMs + self.samples = samples } } diff --git a/supacodeTests/CLIReadCommandHandlerTests.swift b/supacodeTests/CLIReadCommandHandlerTests.swift index 01e1befc..aa53089b 100644 --- a/supacodeTests/CLIReadCommandHandlerTests.swift +++ b/supacodeTests/CLIReadCommandHandlerTests.swift @@ -1,3 +1,4 @@ +import Clocks import Foundation import Testing @@ -240,4 +241,141 @@ struct CLIReadCommandHandlerTests { #expect(response.ok == false) #expect(response.error?.code == CLIErrorCode.readFailed) } + + // MARK: - Wait-for-stable polling + + @Test func waitStableReturnsWhenOutputSettles() async throws { + let clock = TestClock() + let capture = CaptureSequence(["ready"]) + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { _ in capture.next() }, + clock: clock + ) + + let task = Task { + await handler.handle( + envelope: Self.makeWaitStableEnvelope(intervalMs: 100, periodMs: 300, timeoutSeconds: 1) + ) + } + await Self.drive(clock, steps: 15) + let response = await task.value + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) + #expect(payload.text == "ready") + #expect(payload.stabilized == true) + // 1 initial sample + 3 consecutive unchanged samples to cover the 300ms / 100ms streak. + #expect(payload.samples == 4) + #expect(payload.waitedMs == 300) + } + + @Test func waitStableReturnsLatestContentAfterChanges() async throws { + let clock = TestClock() + let capture = CaptureSequence(["a", "ab", "abc"]) + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { _ in capture.next() }, + clock: clock + ) + + let task = Task { + await handler.handle( + envelope: Self.makeWaitStableEnvelope(intervalMs: 100, periodMs: 300, timeoutSeconds: 1) + ) + } + await Self.drive(clock, steps: 15) + let response = await task.value + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) + #expect(payload.text == "abc") + #expect(payload.stabilized == true) + } + + @Test func waitStableTimesOutWhenOutputKeepsChanging() async throws { + let clock = TestClock() + let counter = Counter() + let handler = ReadCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + captureProvider: { _ in + ReadCaptureInput(viewportText: "v\(counter.bump())", screenText: nil) + }, + clock: clock + ) + + let task = Task { + await handler.handle( + envelope: Self.makeWaitStableEnvelope(intervalMs: 100, periodMs: 300, timeoutSeconds: 1) + ) + } + await Self.drive(clock, steps: 15) + let response = await task.value + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) + #expect(payload.stabilized == false) + // timeout 1000ms / 100ms interval = 10 sleeps, plus the initial sample. + #expect(payload.samples == 11) + #expect(payload.waitedMs == 1000) + #expect(payload.text == "v11") + } + + // MARK: - Wait-for-stable helpers + + private static func makeWaitStableEnvelope( + last: Int? = nil, + intervalMs: Int, + periodMs: Int, + timeoutSeconds: Int + ) -> CommandEnvelope { + CommandEnvelope( + output: .json, + command: .read( + ReadInput( + selector: .none, + last: last, + waitStable: true, + stableIntervalMs: intervalMs, + stablePeriodMs: periodMs, + waitTimeoutSeconds: timeoutSeconds + )) + ) + } + + /// Yield to let the handler reach its first sleep, then advance the test clock one interval at a + /// time so each `clock.sleep(for:)` wakes and the poll loop makes progress. + private static func drive(_ clock: TestClock, steps: Int) async { + await Task.yield() + for _ in 0.. ReadCaptureInput { + let text = values[Swift.min(index, values.count - 1)] + index += 1 + return ReadCaptureInput(viewportText: text, screenText: nil) + } + } + + @MainActor + private final class Counter { + private var value = 0 + + func bump() -> Int { + value += 1 + return value + } + } } -- 2.51.2 From eff96fe589475049f8c6c3a5c56533eb21382202 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 00:55:30 +0900 Subject: [PATCH 275/528] Harden prowl-cli skill with sharp edges found while testing While verifying --wait-stable end-to-end I exercised every prowl subcommand and folded the findings into the skill: - omitting --target/--pane defaults to the focused (= your own) pane, so a bare send/key hits yourself - send/key positional 0/1/2 argument shapes; key --repeat; the --capture combo constraints (no --no-wait / --no-enter) - open always creates a new tab+pane (even non-git paths like /tmp); use focus to re-focus without spawning a tab - expanded error-code list (EMPTY_INPUT, INVALID_ARGUMENT, PATH_NOT_*, INVALID_REPEAT) and a warning that ArgumentParser parse errors print plaintext (not JSON), so jq pipelines must check the exit code - snake_case jq key reminder; note there is no close/quit command --- skills/prowl-cli/SKILL.md | 42 ++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 9867af96..a37e5f5a 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -13,6 +13,8 @@ Do not use it just because the current shell happens to be inside a Prowl reposi If your own session was launched from a Prowl terminal, then **one of the panes `prowl list` returns is you.** `prowl list` does not exclude the caller. Acting on your own pane is self-harm: `prowl key --pane esc` (or `ctrl-c`) interrupts or kills *your own* current request, and `send` injects into your own prompt. +**Worse: omitting `--target`/`--pane` makes `send`/`key` default to the _focused_ pane — which is usually you.** A bare `prowl send 'x'` types into your own prompt; a bare `prowl key ctrl-c` aborts your own request. Always pass an explicit `--pane`. + **Before any `send`, `key`, or `focus`, identify your own pane and never target it:** ```bash @@ -71,12 +73,28 @@ Focus a pane: prowl focus --pane --json ``` -Open a path in Prowl: +Open a path in Prowl. **Side effect:** this *always creates a new tab + pane* for the path — even a non-git path like `/tmp` (`created_tab: true`) — and brings the app to front. To re-focus an already-open worktree without spawning a tab, use `focus`, not `open`: ```bash prowl open /path/to/repo --json ``` +## `send` / `key` argument shapes + +Positional arguments are **position-sensitive** — the count changes their meaning: + +| command | 0 args | 1 arg | 2 args | +|---|---|---|---| +| `send` | text from **stdin** | text → **current** pane | ` ` | +| `key` | error (`INVALID_ARGUMENT`) | token → **current** pane | ` ` | + +"current pane" = the focused pane = **usually yourself**, so always prefer the explicit `--pane ` form over a positional target. + +- `key --repeat <1-100>` repeats the token, e.g. `prowl key --pane down --repeat 10` (out-of-range → `INVALID_REPEAT`). +- `send --no-enter` sends text without a trailing Enter (key Enter yourself later). +- `send --capture` requires waiting *and* a trailing Enter; it diffs the screen before/after the command, so it **cannot** combine with `--no-wait` or `--no-enter` (either → `INVALID_ARGUMENT`). +- Don't mix stdin piping and a positional text arg (→ `INVALID_ARGUMENT`). + ## Finding Pane IDs By worktree path or repository directory name: @@ -160,7 +178,9 @@ Two reliable ways to get the **final** output: ``` The JSON gains `stabilized` (true = settled, false = hit the timeout), - `waited_ms`, and `samples`. Prefer this over manual `sleep`-then-`read`. + `waited_ms`, and `samples`. Prefer this over manual `sleep`-then-`read`. The + `--stable-*` options only work **with** `--wait-stable` — passing them alone + returns `INVALID_ARGUMENT`. Note this still only sees the rendered buffer, so content Claude Code has **folded** (`⎿ … +N lines (ctrl+o to expand)`) is never captured no matter @@ -194,13 +214,21 @@ printf '%s\n' 'echo first' 'echo second' | prowl send --pane --no-wait ## Error Handling +In `--json` mode, command-level errors come back as `{ "ok": false, "error": { "code", "message" } }`. Common codes: + - `APP_NOT_RUNNING`: Prowl is not running, or its CLI service is unavailable. Ask the user before restarting Prowl. -- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and resolve an explicit pane UUID. -- `WAIT_TIMEOUT`: retry with `--no-wait`, or use a pane with shell integration for `--capture`. -- `UNSUPPORTED_KEY`: inspect `prowl key --help`; use canonical tokens such as `enter`, `esc`, `tab`, `up`, `down`, `ctrl-c`, `cmd-k`, or `f1`. +- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: re-run `prowl list --json` and resolve an explicit pane UUID (the error does **not** enumerate the candidates). +- `EMPTY_INPUT`: `send` got no text (no argument and no stdin). +- `INVALID_ARGUMENT`: bad flag value or illegal combo (e.g. `--capture` with `--no-wait`, `--stable-*` without `--wait-stable`, `--last 0`). +- `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED`: `open` path problems. +- `UNSUPPORTED_KEY` / `INVALID_REPEAT`: bad key token or out-of-range `--repeat`; see `prowl key --help`. Canonical tokens: `enter`, `esc`, `tab`, `up`, `down`, `ctrl-c`, `cmd-k`, `f1`, … +- `WAIT_TIMEOUT`: `send` waited but the command never reported completion — retry with `--no-wait`, or use a shell-integrated pane for `--capture`. + +> ⚠️ **Not every failure is JSON.** Argument-*parsing* errors (unknown flag, wrong type such as `read --last abc`) are caught by the parser *before* `--json` takes effect: they print a plaintext usage error to **stderr** and exit non-zero. A `| jq …` pipeline will choke on these. Get flag names/types right, and always check the exit code rather than assuming stdout is JSON. ## Notes -- JSON output is the automation surface; text output is for humans. +- JSON output is the automation surface; text output is for humans. JSON keys are snake_case (`line_count`, `waited_ms`, `created_tab`, `trailing_enter_sent`, …) — match them exactly in `jq` or you'll silently get `null`. +- `--no-color` is global (works on every command) and safe in automation. - `-t/--target` auto-resolves pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane` is safer for automation. -- Future Prowl versions may add commands such as `prowl action`, `prowl tab`, or `prowl pane`. Check `prowl --help` before using commands not listed here. +- The current commands are `list`, `read`, `send`, `key`, `focus`, and `open` (the default). There is **no close/quit command** — you cannot close a tab/pane via the CLI (send `cmd-w` as a key if you must). Run `prowl --help` to confirm the command set before automating. -- 2.51.2 From f5bdf42ec7a2d160b10b469f1654b4986af4e2d3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 01:07:28 +0900 Subject: [PATCH 276/528] Reframe self-pane guidance: resolve by UUID, allow legitimate self-ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blanket "never target your own pane" framing with two general targeting rules that apply to every command: 1. Always resolve the target by UUID / cwd / focused flag from `prowl list` — never trust the free-form tab title (it can name a different repo than its cwd). 2. Know which pane is yourself. Self-actions are NOT forbidden — read / focus / cmd-k / send --no-enter on yourself are legitimate. What to avoid is *accidentally* sending an interrupting action (esc/ctrl-c, or send with a trailing Enter) to yourself, and the easy way that happens is omitting --target (defaults to focused). --- skills/prowl-cli/SKILL.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index a37e5f5a..23eebeba 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -9,27 +9,34 @@ Use `prowl` when the user explicitly wants to inspect or control Prowl: reading Do not use it just because the current shell happens to be inside a Prowl repository. It is a remote-control interface for the running Prowl GUI app. -## ⚠️ You are probably running *inside* one of these panes +## ⚠️ Resolve the target by UUID — and know which pane is *you* -If your own session was launched from a Prowl terminal, then **one of the panes `prowl list` returns is you.** `prowl list` does not exclude the caller. Acting on your own pane is self-harm: `prowl key --pane esc` (or `ctrl-c`) interrupts or kills *your own* current request, and `send` injects into your own prompt. +Targeting is the #1 source of mistakes. Two rules apply to **every** command: -**Worse: omitting `--target`/`--pane` makes `send`/`key` default to the _focused_ pane — which is usually you.** A bare `prowl send 'x'` types into your own prompt; a bare `prowl key ctrl-c` aborts your own request. Always pass an explicit `--pane`. +**1. Resolve the target from `prowl list --json` by UUID — never from the tab title.** +A pane's tab *title* is free-form and will lie: a tab titled "UniWebView" can actually have `cwd` `/some/other/repo`. Decide the target from `pane.id` (UUID), `worktree.path`, `cwd`, and the `focused` flag — not the title — then pass the explicit `--pane `. -**Before any `send`, `key`, or `focus`, identify your own pane and never target it:** +**2. Know which pane is yourself, because one of them usually is.** +If your session was launched from a Prowl terminal, `prowl list` includes your own pane (it does not exclude the caller). Your own pane is the `focused` one, with `cwd` matching your `$PWD`: ```bash -# Your own pane = the focused one (and its cwd matches your $PWD). echo "$PWD" prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id' ``` -Danger signs that a pane you are reading **is yourself**: its content is your own conversation transcript, or it shows a prompt identical to the one you are currently executing. A pane's *tab title* can say anything (e.g. "UniWebView") while its real `cwd` is somewhere else — trust `cwd` and the focused flag, not the title. +Operating on yourself is **not forbidden** — plenty of self-actions are legitimate: `read` (grab your own scrollback), `focus` (raise the window when a long job finishes), `key cmd-k` (clear), or `send --no-enter` (pre-fill a command for the user to review). What you must avoid is *accidentally* hitting yourself with an **interrupting** action: -To run an agent "over in project X", do **not** reuse a same-looking existing pane. Open a fresh one and confirm its id differs from your own: +- `key esc` / `ctrl-c` on yourself aborts your own current request. +- `send` *with* a trailing Enter on yourself submits a prompt to yourself — this is how runaway self-recursion starts. +- **Omitting `--target`/`--pane` defaults to the focused pane — usually you.** So a bare `prowl send 'x'` or `prowl key ctrl-c` lands on yourself. Always pass an explicit `--pane`. + +Danger signs that a pane you're reading **is yourself**: its content is your own transcript, or a prompt identical to the one you're currently running. + +To run an agent "over in project X", don't reuse a same-looking existing pane — open a fresh one and confirm its id differs from your own: ```bash prowl open /path/to/project-X --json # returns a brand-new pane id -# verify: the returned .data.target.pane.id != your own focused pane id +# verify: .data.target.pane.id != your own focused pane id ``` ## Core Workflow -- 2.51.2 From 2c9f037aae9b654134446e008b6a8f489051089f Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 01:13:34 +0900 Subject: [PATCH 277/528] Track skill-discovery symlinks for prowl-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/skills/` is version-controlled (check-upstream-changes and release skills live there), but prowl-cli was only committed as skills/prowl-cli/ — so a fresh clone could not discover it the way the other skills are. Add the relative discovery symlinks that point at skills/prowl-cli/: - .claude/skills/prowl-cli (Claude Code) - .agents/skills/prowl-cli (agents/ tool layout) They resolve after clone without duplicating the skill body. --- .agents/skills/prowl-cli | 1 + .claude/skills/prowl-cli | 1 + 2 files changed, 2 insertions(+) create mode 120000 .agents/skills/prowl-cli create mode 120000 .claude/skills/prowl-cli diff --git a/.agents/skills/prowl-cli b/.agents/skills/prowl-cli new file mode 120000 index 00000000..a266928b --- /dev/null +++ b/.agents/skills/prowl-cli @@ -0,0 +1 @@ +../../skills/prowl-cli \ No newline at end of file diff --git a/.claude/skills/prowl-cli b/.claude/skills/prowl-cli new file mode 120000 index 00000000..a266928b --- /dev/null +++ b/.claude/skills/prowl-cli @@ -0,0 +1 @@ +../../skills/prowl-cli \ No newline at end of file -- 2.51.2 From c46c07c13cb5fd633de41e3bcf40de0200a78a0c Mon Sep 17 00:00:00 2001 From: onevclaw Date: Thu, 4 Jun 2026 15:44:33 +0900 Subject: [PATCH 278/528] Add active agent tab title display setting --- .../ActiveAgents/Views/ActiveAgentRow.swift | 5 +-- .../Views/ActiveAgentsPanel.swift | 35 +++++++++++++++++- .../Features/App/Reducer/AppFeature.swift | 3 ++ .../Reducer/RepositoriesFeature.swift | 1 + .../Repositories/Views/SidebarListView.swift | 1 + .../Settings/Models/GlobalSettings.swift | 9 +++++ .../Settings/Reducer/SettingsFeature.swift | 4 +++ .../Views/AppearanceSettingsView.swift | 7 +++- supacodeTests/ActiveAgentsFeatureTests.swift | 36 +++++++++++++++++-- .../AppFeatureSettingsChangedTests.swift | 9 +++++ supacodeTests/SettingsFeatureTests.swift | 18 ++++++++++ 11 files changed, 122 insertions(+), 6 deletions(-) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 43ff4576..eb12d7ce 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -4,7 +4,7 @@ import SwiftUI struct ActiveAgentRow: View { let entry: ActiveAgentEntry let repositoryName: String - let branchName: String + let subtitle: String let repositoryColor: RepositoryColorChoice? let isDimmed: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -14,10 +14,11 @@ struct ActiveAgentRow: View { agentIcon VStack(alignment: .leading, spacing: 2) { title - Text(branchName) + Text(subtitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) } Spacer(minLength: 8) statusPill diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index d438fbe2..a44fc20e 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -11,6 +11,7 @@ struct ActiveAgentsPanel: View { /// Merged "⌥⌃↑↓" hint shown while Cmd is held; `nil` hides it (bindings customized /// or Cmd not held). Resolved by the parent so the panel stays presentational. let navigationShortcutHint: String? + let showTabTitles: Bool let height: Double let maximumHeight: Double let onHeightChanged: (Double) -> Void @@ -54,7 +55,7 @@ struct ActiveAgentsPanel: View { ActiveAgentRow( entry: entry, repositoryName: repositoryName(for: entry), - branchName: branchName(for: entry), + subtitle: subtitle(for: entry), repositoryColor: repositoryColor(for: entry), isDimmed: isDimmed(entry) ) @@ -128,6 +129,14 @@ struct ActiveAgentsPanel: View { rowDisplays[entry.id]?.branchName ?? entry.worktreeName } + private func subtitle(for entry: ActiveAgentEntry) -> String { + Self.subtitle( + for: entry, + branchName: branchName(for: entry), + showTabTitles: showTabTitles + ) + } + private func repositoryColor(for entry: ActiveAgentEntry) -> RepositoryColorChoice? { rowDisplays[entry.id]?.color } @@ -145,6 +154,30 @@ struct ActiveAgentsPanel: View { } private func helpText(for entry: ActiveAgentEntry) -> String { + Self.helpText( + for: entry, + branchName: branchName(for: entry), + showTabTitles: showTabTitles + ) + } + + static func subtitle( + for entry: ActiveAgentEntry, + branchName: String, + showTabTitles: Bool + ) -> String { + showTabTitles ? tabTitle(for: entry) : branchName + } + + static func helpText( + for entry: ActiveAgentEntry, + branchName: String, + showTabTitles: Bool + ) -> String { + showTabTitles ? branchName : tabTitle(for: entry) + } + + static func tabTitle(for entry: ActiveAgentEntry) -> String { let trimmed = entry.tabTitle.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled tab" : trimmed } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index b9a33319..f64d11de 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -69,6 +69,8 @@ struct AppFeature { repositories: RepositoriesFeature.State = .init(), settings: SettingsFeature.State = .init() ) { + var repositories = repositories + repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles self.repositories = repositories self.settings = settings lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled @@ -622,6 +624,7 @@ struct AppFeature { settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides + state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( isPanelHidden: state.repositories.activeAgents.isPanelHidden, autoShowPanel: settings.autoShowActiveAgentsPanel diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 0fc814f3..8abd3b88 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -274,6 +274,7 @@ struct RepositoriesFeature { var pendingRenameBranchRequest: PendingRenameBranchRequest? var isSidebarDragActive = false var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] + var showActiveAgentTabTitles = false var activeAgents = ActiveAgentsFeature.State() @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index c09e0e26..5c32d58f 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -160,6 +160,7 @@ struct SidebarListView: View { rowDisplays: agentRowDisplays, selectedSurfaceID: selectedSurfaceID, navigationShortcutHint: activeAgentsShortcutHint, + showTabTitles: state.showActiveAgentTabTitles, height: panelHeight, maximumHeight: maximumPanelHeight, onHeightChanged: { height in diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index ffbbf019..61bb624c 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -29,6 +29,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool + var showActiveAgentTabTitles: Bool var windowTintMode: WindowTintMode var windowTintCustomColor: TintColor var showRunButtonInToolbar: Bool @@ -69,6 +70,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { defaultViewMode: .normal, dimUnfocusedSplits: true, autoShowActiveAgentsPanel: false, + showActiveAgentTabTitles: false, windowTintMode: .repositoryColor, windowTintCustomColor: .default, showRunButtonInToolbar: true, @@ -110,6 +112,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { defaultViewMode: DefaultViewMode = .normal, dimUnfocusedSplits: Bool = true, autoShowActiveAgentsPanel: Bool = false, + showActiveAgentTabTitles: Bool = false, windowTintMode: WindowTintMode = .repositoryColor, windowTintCustomColor: TintColor = .default, showRunButtonInToolbar: Bool = true, @@ -149,6 +152,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.defaultViewMode = defaultViewMode self.dimUnfocusedSplits = dimUnfocusedSplits self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel + self.showActiveAgentTabTitles = showActiveAgentTabTitles self.windowTintMode = windowTintMode self.windowTintCustomColor = windowTintCustomColor self.showRunButtonInToolbar = showRunButtonInToolbar @@ -191,6 +195,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(defaultViewMode, forKey: .defaultViewMode) try container.encode(dimUnfocusedSplits, forKey: .dimUnfocusedSplits) try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) + try container.encode(showActiveAgentTabTitles, forKey: .showActiveAgentTabTitles) try container.encode(windowTintMode, forKey: .windowTintMode) try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) try container.encode(showRunButtonInToolbar, forKey: .showRunButtonInToolbar) @@ -232,6 +237,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case defaultViewMode case dimUnfocusedSplits case autoShowActiveAgentsPanel + case showActiveAgentTabTitles case windowTintMode case windowTintCustomColor case showRunButtonInToolbar @@ -330,6 +336,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoShowActiveAgentsPanel = try container.decodeIfPresent(Bool.self, forKey: .autoShowActiveAgentsPanel) ?? Self.default.autoShowActiveAgentsPanel + showActiveAgentTabTitles = + try container.decodeIfPresent(Bool.self, forKey: .showActiveAgentTabTitles) + ?? Self.default.showActiveAgentTabTitles (windowTintMode, windowTintCustomColor) = try Self.decodeWindowTint(from: container) (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = try Self.decodeShelfSpineTint(from: container) let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 123767b4..7c712209 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -36,6 +36,7 @@ struct SettingsFeature { var defaultViewMode: DefaultViewMode var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool + var showActiveAgentTabTitles: Bool var windowTintMode: WindowTintMode var shelfSpineTintFallback: ShelfSpineTintFallback var shelfSpineTintFollowsRepositoryColor: Bool @@ -90,6 +91,7 @@ struct SettingsFeature { defaultViewMode = settings.defaultViewMode dimUnfocusedSplits = settings.dimUnfocusedSplits autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel + showActiveAgentTabTitles = settings.showActiveAgentTabTitles windowTintMode = settings.windowTintMode shelfSpineTintFallback = settings.shelfSpineTintFallback shelfSpineTintFollowsRepositoryColor = settings.shelfSpineTintFollowsRepositoryColor @@ -134,6 +136,7 @@ struct SettingsFeature { defaultViewMode: defaultViewMode, dimUnfocusedSplits: dimUnfocusedSplits, autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, + showActiveAgentTabTitles: showActiveAgentTabTitles, windowTintMode: windowTintMode, windowTintCustomColor: TintColor(windowTintCustomColor), showRunButtonInToolbar: showRunButtonInToolbar, @@ -246,6 +249,7 @@ struct SettingsFeature { state.defaultViewMode = normalizedSettings.defaultViewMode state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel + state.showActiveAgentTabTitles = normalizedSettings.showActiveAgentTabTitles state.windowTintMode = normalizedSettings.windowTintMode state.shelfSpineTintFallback = normalizedSettings.shelfSpineTintFallback state.shelfSpineTintFollowsRepositoryColor = normalizedSettings.shelfSpineTintFollowsRepositoryColor diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index cf0bb092..63042755 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -93,9 +93,14 @@ struct AppearanceSettingsView: View { isOn: $store.autoShowActiveAgentsPanel ) .help("Open the Active Agents panel when an agent is detected.") - Text("When enabled, hidden panels reopen as soon as an agent starts or updates.") + Text("Hidden panels reopen as soon as an agent starts or updates.") .foregroundStyle(.secondary) .font(.callout) + Toggle( + "Show tab titles in agent rows", + isOn: $store.showActiveAgentTabTitles + ) + .help("Display each agent's tab title in the row and show the branch name on hover.") } Section("Default View") { Picker("Launch in", selection: $store.defaultViewMode) { diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index f2766757..697b01b9 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -157,6 +157,33 @@ struct ActiveAgentsFeatureTests { } } + @Test func panelSubtitleAndHelpSwapTabTitleAndBranchWhenEnabled() { + let entry = entry(id: UUID(0), tabTitle: "Review issue 385", state: .idle, changedAt: Date()) + + #expect( + ActiveAgentsPanel.subtitle(for: entry, branchName: "main", showTabTitles: false) + == "main" + ) + #expect( + ActiveAgentsPanel.helpText(for: entry, branchName: "main", showTabTitles: false) + == "Review issue 385" + ) + #expect( + ActiveAgentsPanel.subtitle(for: entry, branchName: "main", showTabTitles: true) + == "Review issue 385" + ) + #expect( + ActiveAgentsPanel.helpText(for: entry, branchName: "main", showTabTitles: true) + == "main" + ) + } + + @Test func panelTabTitleFallsBackForEmptyTitles() { + let entry = entry(id: UUID(0), tabTitle: " ", state: .idle, changedAt: Date()) + + #expect(ActiveAgentsPanel.tabTitle(for: entry) == "Untitled tab") + } + private func sampleEntries() -> IdentifiedArrayOf { let now = Date(timeIntervalSince1970: 10) return [ @@ -166,14 +193,19 @@ struct ActiveAgentsFeatureTests { ] } - private func entry(id: UUID, state: AgentDisplayState, changedAt: Date) -> ActiveAgentEntry { + private func entry( + id: UUID, + tabTitle: String = "1", + state: AgentDisplayState, + changedAt: Date + ) -> ActiveAgentEntry { ActiveAgentEntry( id: id, worktreeID: "/repo/wt", worktreeName: "wt", workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), - tabTitle: "1", + tabTitle: tabTitle, surfaceID: id, paneIndex: 1, agent: .codex, diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index a1b60b3b..a768268e 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -99,6 +99,15 @@ struct AppFeatureSettingsChangedTests { } } + @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { + var settings = SettingsFeature.State() + settings.showActiveAgentTabTitles = true + + let state = AppFeature.State(settings: settings) + + #expect(state.repositories.showActiveAgentTabTitles == true) + } + @Test(.dependencies) func settingsChangedRecomputesResolvedKeybindings() async { var settings = GlobalSettings.default settings.keybindingUserOverrides = KeybindingUserOverrideStore( diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 4dfa3162..6c736761 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -395,6 +395,24 @@ struct SettingsFeatureTests { #expect(settingsFile.global.autoShowActiveAgentsPanel == true) } + @Test(.dependencies) func showActiveAgentTabTitlesPersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.showActiveAgentTabTitles = false + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.showActiveAgentTabTitles, true))) { + $0.showActiveAgentTabTitles = true + } + await store.receive(\.delegate.settingsChanged) + + #expect(settingsFile.global.showActiveAgentTabTitles == true) + } + @Test(.dependencies) func disablingAnalyticsResetsClient() async { var initialSettings = GlobalSettings.default initialSettings.analyticsEnabled = true -- 2.51.2 From d4102ccb8087a4b4e0a8b7f8d9b208ed1e9d6fe4 Mon Sep 17 00:00:00 2001 From: onevpaw Date: Thu, 4 Jun 2026 15:57:44 +0900 Subject: [PATCH 279/528] Cover active agent tab title settings propagation --- supacodeTests/AppFeatureSettingsChangedTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index a768268e..270d8107 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -17,11 +17,14 @@ struct AppFeatureSettingsChangedTests { settings.githubIntegrationEnabled = false settings.mergedWorktreeAction = .archive settings.moveNotifiedWorktreeToTop = false + settings.showActiveAgentTabTitles = true let store = TestStore(initialState: AppFeature.State()) { AppFeature() } - await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.send(.settings(.delegate(.settingsChanged(settings)))) { + $0.repositories.showActiveAgentTabTitles = true + } await store.receive(\.repositories.githubIntegration.setGithubIntegrationEnabled) { $0.repositories.githubIntegrationAvailability = .disabled } -- 2.51.2 From 63931a758bcbf201849a1ea25eab7d57b3689e03 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 20:43:54 +0900 Subject: [PATCH 280/528] Update skill --- skills/prowl-cli/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 23eebeba..1977f58f 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -14,7 +14,7 @@ Do not use it just because the current shell happens to be inside a Prowl reposi Targeting is the #1 source of mistakes. Two rules apply to **every** command: **1. Resolve the target from `prowl list --json` by UUID — never from the tab title.** -A pane's tab *title* is free-form and will lie: a tab titled "UniWebView" can actually have `cwd` `/some/other/repo`. Decide the target from `pane.id` (UUID), `worktree.path`, `cwd`, and the `focused` flag — not the title — then pass the explicit `--pane `. +A pane's tab *title* is free-form and will lie: a tab titled "MyGreateProject" can actually have `cwd` `/some/other/repo`. Decide the target from `pane.id` (UUID), `worktree.path`, `cwd`, and the `focused` flag — not only the title — then pass the explicit `--pane `. **2. Know which pane is yourself, because one of them usually is.** If your session was launched from a Prowl terminal, `prowl list` includes your own pane (it does not exclude the caller). Your own pane is the `focused` one, with `cwd` matching your `$PWD`: -- 2.51.2 From e520627ab49d84948b6d9615ced6baf62dc130a4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 21:07:24 +0900 Subject: [PATCH 281/528] Refresh Active Agents subtitle immediately on tab title change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab title updates (OSC-2, focus sync, manual rename) only mutated the tab model and never re-emitted the agent entry, so the Active Agents subtitle (when showing tab titles) only refreshed on the next agent state transition — making it feel laggy. Make TerminalTabManager.updateTitle/setCustomTitle return whether the visible displayTitle actually changed, and have WorktreeTerminalState re-emit agent entries for that tab's panes when it did. --- .../Terminal/Models/TerminalTabManager.swift | 23 ++++++++---- .../Models/WorktreeTerminalState.swift | 27 ++++++++++++-- supacodeTests/TerminalTabManagerTests.swift | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index 436b15f4..14be24f0 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -31,17 +31,28 @@ final class TerminalTabManager { selectedTabId = id } - func updateTitle(_ id: TerminalTabID, title: String) { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - guard !tabs[index].isTitleLocked else { return } + /// Updates the live shell title. Returns `true` when the visible + /// `displayTitle` actually changed (a custom title masks live updates), + /// so callers can refresh derived UI like the Active Agents subtitle. + @discardableResult + func updateTitle(_ id: TerminalTabID, title: String) -> Bool { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return false } + guard !tabs[index].isTitleLocked else { return false } + let previousDisplayTitle = tabs[index].displayTitle tabs[index].title = title + return tabs[index].displayTitle != previousDisplayTitle } - func setCustomTitle(_ id: TerminalTabID, title: String) { - guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } - guard !tabs[index].isTitleLocked else { return } + /// Sets (or clears, when blank) the user-defined title. Returns `true` when + /// the visible `displayTitle` actually changed. + @discardableResult + func setCustomTitle(_ id: TerminalTabID, title: String) -> Bool { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return false } + guard !tabs[index].isTitleLocked else { return false } + let previousDisplayTitle = tabs[index].displayTitle let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) tabs[index].customTitle = trimmed.isEmpty ? nil : trimmed + return tabs[index].displayTitle != previousDisplayTitle } func beginTabRename(_ id: TerminalTabID) { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index d7ea0fab..c06fa971 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1220,7 +1220,9 @@ final class WorktreeTerminalState { view.bridge.onTitleChange = { [weak self, weak view] title in guard let self, let view else { return } if self.focusedSurfaceIdByTab[tabId] == view.id { - self.tabManager.updateTitle(tabId, title: title) + if self.tabManager.updateTitle(tabId, title: title) { + self.refreshAgentEntriesForTitleChange(in: tabId) + } } self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) } @@ -1425,7 +1427,9 @@ final class WorktreeTerminalState { guard response == .alertFirstButtonReturn else { return } guard let self else { return } let newTitle = textField.stringValue - self.tabManager.setCustomTitle(tabId, title: newTitle) + if self.tabManager.setCustomTitle(tabId, title: newTitle) { + self.refreshAgentEntriesForTitleChange(in: tabId) + } } } } @@ -1435,7 +1439,9 @@ final class WorktreeTerminalState { let surface = surfaces[focusedId], let title = surface.bridge.state.title else { return } - tabManager.updateTitle(tabId, title: title) + if tabManager.updateTitle(tabId, title: title) { + refreshAgentEntriesForTitleChange(in: tabId) + } } private func focusSurface(in tabId: TerminalTabID) { @@ -1823,6 +1829,21 @@ final class WorktreeTerminalState { onAgentEntryRemoved?(surfaceID) } + /// Re-emit Active Agents entries for every pane in `tabId` so the panel picks + /// up a fresh tab-title snapshot. Title changes (OSC-2, focus sync, manual + /// rename) don't move agent detection state, so without this nudge the + /// subtitle only refreshes on the next agent state transition. + private func refreshAgentEntriesForTitleChange(in tabId: TerminalTabID) { + let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] + for surfaceID in surfaceIDs { + guard let state = surfaceAgentStates[surfaceID], + state.detectedAgent != nil, + state.state != .unknown + else { continue } + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + } + } + private func emitAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) { guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { onAgentEntryRemoved?(surfaceID) diff --git a/supacodeTests/TerminalTabManagerTests.swift b/supacodeTests/TerminalTabManagerTests.swift index fd9c07f6..65ad4901 100644 --- a/supacodeTests/TerminalTabManagerTests.swift +++ b/supacodeTests/TerminalTabManagerTests.swift @@ -28,6 +28,42 @@ struct TerminalTabManagerTests { #expect(manager.tabs.first?.displayTitle == "npm test") } + @Test func updateTitleReportsDisplayTitleChange() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "shell", icon: nil) + + #expect(manager.updateTitle(tabId, title: "npm test") == true) + #expect(manager.updateTitle(tabId, title: "npm test") == false) + } + + @Test func updateTitleReportsNoChangeWhenMaskedByCustomTitle() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "shell", icon: nil) + manager.setCustomTitle(tabId, title: "build") + + // Live title moves but the custom title still masks the visible display. + #expect(manager.updateTitle(tabId, title: "npm test") == false) + #expect(manager.tabs.first?.displayTitle == "build") + } + + @Test func updateTitleReportsNoChangeForLockedTab() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "RUN SCRIPT", icon: "play.fill", isTitleLocked: true) + + #expect(manager.updateTitle(tabId, title: "npm run dev") == false) + } + + @Test func setCustomTitleReportsDisplayTitleChange() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "shell", icon: nil) + + #expect(manager.setCustomTitle(tabId, title: "build") == true) + #expect(manager.setCustomTitle(tabId, title: "build") == false) + // Clearing the custom title restores the live title, another visible change. + #expect(manager.setCustomTitle(tabId, title: " ") == true) + #expect(manager.tabs.first?.displayTitle == "shell") + } + @Test func customTitleIgnoresLockedTabs() { let manager = TerminalTabManager() let tabId = manager.createTab(title: "RUN SCRIPT", icon: "play.fill", isTitleLocked: true) -- 2.51.2 From c3f97f21b534f13b36c02a37558f747563ab4454 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 22:07:12 +0900 Subject: [PATCH 282/528] Fix Prowl CLI socket ownership --- Makefile | 11 +- ProwlCLI/AppLauncher.swift | 29 ++---- supacode/CLIService/CLISocketServer.swift | 111 ++++++++++++++++---- supacodeTests/CLISocketServerTests.swift | 121 ++++++++++++++++++++++ 4 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 supacodeTests/CLISocketServerTests.swift diff --git a/Makefile b/Makefile index b7494728..38dba586 100644 --- a/Makefile +++ b/Makefile @@ -134,7 +134,16 @@ run-app: build-app # Build then launch (Debug) with log streaming echo "error: failed to resolve app path from build settings"; \ exit 1; \ fi; \ - "$$build_dir/$$product/Contents/MacOS/$$exec_name" + app_path="$$build_dir/$$product/Contents/MacOS/$$exec_name"; \ + if [ "$${ALLOW_MULTIPLE_PROWL:-0}" != "1" ]; then \ + existing_pid="$$(ps -axww -o pid= -o command= | awk '$$2 ~ /\/Prowl\.app\/Contents\/MacOS\/ProwlApp$$/ { print $$1; exit }')"; \ + if [ -n "$$existing_pid" ]; then \ + echo "error: another Prowl app instance is already running (pid $$existing_pid). Quit it before make run-app."; \ + echo " Direct debug launches share app state and the CLI socket. Set ALLOW_MULTIPLE_PROWL=1 only if you need a second GUI instance."; \ + exit 1; \ + fi; \ + fi; \ + "$$app_path" install-dev-build: build-app # install dev build to /Applications @set -euo pipefail; \ diff --git a/ProwlCLI/AppLauncher.swift b/ProwlCLI/AppLauncher.swift index 9167f798..b47ef705 100644 --- a/ProwlCLI/AppLauncher.swift +++ b/ProwlCLI/AppLauncher.swift @@ -2,13 +2,15 @@ // Launches Prowl.app when CLI detects the app is not running, then waits // for the CLI socket to become available. +import AppKit +import Foundation +import ProwlCLIShared + #if canImport(Darwin) -import Darwin + import Darwin #elseif canImport(Glibc) -import Glibc + import Glibc #endif -import Foundation -import ProwlCLIShared enum AppLauncher { /// Maximum time to wait for the socket after launching the app. @@ -29,7 +31,7 @@ enum AppLauncher { static func ensureAppRunning() throws -> Bool { // If a custom socket path is set, skip auto-launch entirely. if let override = ProcessInfo.processInfo.environment[ProwlSocket.environmentKey], - !override.isEmpty + !override.isEmpty { return false } @@ -54,20 +56,11 @@ enum AppLauncher { // MARK: - Process detection - /// Check whether a Prowl process is currently running via `pgrep`. + /// Check whether a Prowl app instance is currently running. private static func isAppProcessRunning() -> Bool { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") - process.arguments = ["-x", "Prowl"] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - process.waitUntilExit() - } catch { - return false + NSWorkspace.shared.runningApplications.contains { application in + application.bundleIdentifier == "com.onevcat.prowl" } - return process.terminationStatus == 0 } // MARK: - App launch @@ -98,7 +91,7 @@ enum AppLauncher { private static func launchArguments() -> [String] { var args = ["-a", "Prowl"] if let openPath = ProcessInfo.processInfo.environment[ProwlSocket.cliOpenPathEnvironmentKey], - !openPath.isEmpty + !openPath.isEmpty { args.append(contentsOf: ["--args", ProwlSocket.cliOpenPathArgument, openPath]) } diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index 926cd544..efbe4aed 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -13,13 +13,20 @@ import Foundation final class CLISocketServer { private let router: CLICommandRouter private let socketPath: String + private let lockPath: String private var serverFD: Int32 = -1 + private var lockFD: Int32 = -1 + private var ownsSocket = false private var isRunning = false - private let acceptQueue = DispatchQueue(label: "com.onevcat.prowl.cli-accept", qos: .userInitiated) + private let acceptQueue = DispatchQueue( + label: "com.onevcat.prowl.cli-accept", qos: .userInitiated) - init(router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath) { + init( + router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath, lockPath: String? = nil + ) { self.router = router self.socketPath = socketPath + self.lockPath = lockPath ?? "\(socketPath).lock" } /// Start listening for CLI connections. @@ -31,28 +38,31 @@ final class CLISocketServer { withIntermediateDirectories: true ) - // Clean up stale socket file + var addr = try Self.socketAddress(for: socketPath) + + try acquireSocketLock() + do { + // A reachable socket belongs to an already-running app, including older + // builds that do not hold the lock. Never unlink a live owner. + guard !Self.canConnect(to: socketPath) else { + throw CLIServiceError.socketAlreadyOwned + } + } catch { + releaseSocketLock() + throw error + } + + // Clean up stale socket files only while holding the lock. unlink(socketPath) // Create socket serverFD = socket(AF_UNIX, SOCK_STREAM, 0) guard serverFD >= 0 else { + releaseSocketLock() throw CLIServiceError.socketCreationFailed } // Bind - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 - let copyLen = min(pathBytes.count, maxLen) - withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in - for idx in 0...size)) @@ -61,16 +71,22 @@ final class CLISocketServer { guard bindResult == 0 else { close(serverFD) + serverFD = -1 + releaseSocketLock() throw CLIServiceError.bindFailed } // Listen guard listen(serverFD, 5) == 0 else { close(serverFD) + serverFD = -1 + unlink(socketPath) + releaseSocketLock() throw CLIServiceError.listenFailed } isRunning = true + ownsSocket = true // Run the blocking accept loop on a dedicated dispatch queue so it does // not occupy a Swift cooperative-thread-pool thread (which would starve @@ -88,7 +104,30 @@ final class CLISocketServer { close(serverFD) serverFD = -1 } - unlink(socketPath) + if ownsSocket { + unlink(socketPath) + ownsSocket = false + } + releaseSocketLock() + } + + private func acquireSocketLock() throws { + guard lockFD < 0 else { return } + lockFD = open(lockPath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard lockFD >= 0 else { + throw CLIServiceError.lockFailed + } + guard flock(lockFD, LOCK_EX | LOCK_NB) == 0 else { + releaseSocketLock() + throw CLIServiceError.socketAlreadyOwned + } + } + + private func releaseSocketLock() { + guard lockFD >= 0 else { return } + flock(lockFD, LOCK_UN) + close(lockFD) + lockFD = -1 } // MARK: - Accept loop (runs on acceptQueue, NOT in Swift concurrency) @@ -166,20 +205,56 @@ final class CLISocketServer { private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { var offset = 0 while offset < buffer.count { - let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + let written = Darwin.write( + fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) guard written > 0 else { throw CLIServiceError.writeFailed } offset += written } } + + private static func canConnect(to socketPath: String) -> Bool { + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { return false } + defer { close(socketFD) } + + guard let addr = try? socketAddress(for: socketPath) else { + return false + } + let result = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(socketFD, sockPtr, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func socketAddress(for socketPath: String) throws -> sockaddr_un { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard pathBytes.count <= maxLen else { + throw CLIServiceError.socketPathTooLong + } + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in + for idx in 0.. String { + URL(fileURLWithPath: "/tmp", isDirectory: true) + .appending( + path: "prowl-\(suffix)-\(UUID().uuidString.prefix(8)).sock", directoryHint: .notDirectory + ) + .path(percentEncoded: false) + } + + private func createStaleSocket(at socketPath: String) throws { + unlink(socketPath) + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw CLIServiceError.socketCreationFailed + } + defer { close(socketFD) } + try bindSocket(socketFD, to: socketPath) + } + + private func canConnect(to socketPath: String) -> Bool { + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { return false } + defer { close(socketFD) } + return withSocketAddress(socketPath) { address in + withUnsafePointer(to: address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in + connect(socketFD, socketPointer, socklen_t(MemoryLayout.size)) + } + } + } == 0 + } + + private func bindSocket(_ socketFD: Int32, to socketPath: String) throws { + let result = withSocketAddress(socketPath) { address in + withUnsafePointer(to: address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in + bind(socketFD, socketPointer, socklen_t(MemoryLayout.size)) + } + } + } + guard result == 0 else { + throw CLIServiceError.bindFailed + } + } + + private func withSocketAddress( + _ socketPath: String, _ body: (sockaddr_un) throws -> Result + ) rethrows + -> Result + { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let maxLength = MemoryLayout.size(ofValue: address.sun_path) - 1 + precondition(pathBytes.count <= maxLength) + let copyLength = min(pathBytes.count, maxLength) + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + for index in 0.. Date: Thu, 4 Jun 2026 22:46:27 +0900 Subject: [PATCH 283/528] Set close-on-exec on CLI socket descriptors --- supacode/CLIService/CLISocketServer.swift | 31 +++++++++++++++++++++++ supacodeTests/CLISocketServerTests.swift | 16 ++++++++++++ 2 files changed, 47 insertions(+) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index efbe4aed..b82ab3a1 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -61,6 +61,14 @@ final class CLISocketServer { releaseSocketLock() throw CLIServiceError.socketCreationFailed } + do { + try Self.setCloseOnExec(serverFD) + } catch { + close(serverFD) + serverFD = -1 + releaseSocketLock() + throw CLIServiceError.socketCreationFailed + } // Bind let bindResult = withUnsafePointer(to: &addr) { ptr in @@ -117,6 +125,12 @@ final class CLISocketServer { guard lockFD >= 0 else { throw CLIServiceError.lockFailed } + do { + try Self.setCloseOnExec(lockFD) + } catch { + releaseSocketLock() + throw CLIServiceError.lockFailed + } guard flock(lockFD, LOCK_EX | LOCK_NB) == 0 else { releaseSocketLock() throw CLIServiceError.socketAlreadyOwned @@ -214,6 +228,16 @@ final class CLISocketServer { } } + private static func setCloseOnExec(_ fileDescriptor: Int32) throws { + let flags = fcntl(fileDescriptor, F_GETFD) + guard flags >= 0 else { + throw CLIServiceError.closeOnExecFailed + } + guard fcntl(fileDescriptor, F_SETFD, flags | FD_CLOEXEC) == 0 else { + throw CLIServiceError.closeOnExecFailed + } + } + private static func canConnect(to socketPath: String) -> Bool { let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) guard socketFD >= 0 else { return false } @@ -246,6 +270,12 @@ final class CLISocketServer { } return addr } + + #if DEBUG + var debugFileDescriptors: (server: Int32, lock: Int32) { + (serverFD, lockFD) + } + #endif } // MARK: - Errors @@ -255,6 +285,7 @@ enum CLIServiceError: Error, Equatable { case socketPathTooLong case socketAlreadyOwned case lockFailed + case closeOnExecFailed case bindFailed case listenFailed case readFailed diff --git a/supacodeTests/CLISocketServerTests.swift b/supacodeTests/CLISocketServerTests.swift index c6e1893f..35980555 100644 --- a/supacodeTests/CLISocketServerTests.swift +++ b/supacodeTests/CLISocketServerTests.swift @@ -55,6 +55,17 @@ struct CLISocketServerTests { #expect(canConnect(to: socketPath)) } + @Test func ownedDescriptorsAreClosedOnExec() throws { + let socketPath = temporarySocketPath(suffix: "cloexec") + let server = CLISocketServer(router: CLICommandRouter(), socketPath: socketPath) + try server.start() + defer { server.stop() } + + let descriptors = server.debugFileDescriptors + #expect(isCloseOnExec(descriptors.server)) + #expect(isCloseOnExec(descriptors.lock)) + } + private func temporarySocketPath(suffix: String) -> String { URL(fileURLWithPath: "/tmp", isDirectory: true) .appending( @@ -99,6 +110,11 @@ struct CLISocketServerTests { } } + private func isCloseOnExec(_ fileDescriptor: Int32) -> Bool { + let flags = fcntl(fileDescriptor, F_GETFD) + return flags >= 0 && (flags & FD_CLOEXEC) == FD_CLOEXEC + } + private func withSocketAddress( _ socketPath: String, _ body: (sockaddr_un) throws -> Result ) rethrows -- 2.51.2 From a2f4bd4d756ff388dd773c6b2d40397849144c1c Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 23:12:01 +0900 Subject: [PATCH 284/528] Refine prowl CLI skill guide --- skills/prowl-cli/SKILL.md | 245 ++++++++++++++++---------------------- 1 file changed, 103 insertions(+), 142 deletions(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 1977f58f..f647badb 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -5,106 +5,114 @@ description: Use the Prowl CLI to inspect or control a running Prowl app, especi # Prowl CLI -Use `prowl` when the user explicitly wants to inspect or control Prowl: reading another pane, checking sibling agent progress, focusing a tab/pane, opening a repo/path in Prowl, or sending text/keys to a Prowl terminal pane. +Use `prowl` only when the task is to inspect or control the running Prowl GUI app: read panes, check sibling agents, focus a pane, open a repo/path in Prowl, send text, or send keys. Do not use it merely because the current shell is inside the Prowl repo. -Do not use it just because the current shell happens to be inside a Prowl repository. It is a remote-control interface for the running Prowl GUI app. +## Safe Default Workflow -## ⚠️ Resolve the target by UUID — and know which pane is *you* +Always resolve a concrete pane UUID before `read`, `send`, `key`, or `focus`. -Targeting is the #1 source of mistakes. Two rules apply to **every** command: +```bash +prowl list --json +``` -**1. Resolve the target from `prowl list --json` by UUID — never from the tab title.** -A pane's tab *title* is free-form and will lie: a tab titled "MyGreateProject" can actually have `cwd` `/some/other/repo`. Decide the target from `pane.id` (UUID), `worktree.path`, `cwd`, and the `focused` flag — not only the title — then pass the explicit `--pane `. +Pick the target by `pane.id`, `worktree.path`, `pane.cwd`, and `pane.focused`. Do not trust tab titles: they are free-form and can lag or lie. -**2. Know which pane is yourself, because one of them usually is.** -If your session was launched from a Prowl terminal, `prowl list` includes your own pane (it does not exclude the caller). Your own pane is the `focused` one, with `cwd` matching your `$PWD`: +If your session was launched from a Prowl pane, the focused pane is often you. Treat focused pane IDs as something to identify and avoid unless you intentionally want to operate on yourself. ```bash -echo "$PWD" -prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id' +self_pane="$(prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id')" ``` -Operating on yourself is **not forbidden** — plenty of self-actions are legitimate: `read` (grab your own scrollback), `focus` (raise the window when a long job finishes), `key cmd-k` (clear), or `send --no-enter` (pre-fill a command for the user to review). What you must avoid is *accidentally* hitting yourself with an **interrupting** action: +Use explicit `--pane`: -- `key esc` / `ctrl-c` on yourself aborts your own current request. -- `send` *with* a trailing Enter on yourself submits a prompt to yourself — this is how runaway self-recursion starts. -- **Omitting `--target`/`--pane` defaults to the focused pane — usually you.** So a bare `prowl send 'x'` or `prowl key ctrl-c` lands on yourself. Always pass an explicit `--pane`. +```bash +prowl read --pane "$pane" --last 80 --wait-stable --json +prowl send --pane "$pane" 'printf "PWD:%s\n" "$PWD"' --capture --timeout 30 --json +prowl focus --pane "$pane" --json +prowl key --pane "$pane" enter --json +``` -Danger signs that a pane you're reading **is yourself**: its content is your own transcript, or a prompt identical to the one you're currently running. +## Common Recipes -To run an agent "over in project X", don't reuse a same-looking existing pane — open a fresh one and confirm its id differs from your own: +Open a fresh pane for a project, then verify it is not yourself: ```bash -prowl open /path/to/project-X --json # returns a brand-new pane id -# verify: .data.target.pane.id != your own focused pane id +pane="$(prowl open /path/to/project --json | jq -r '.data.target.pane.id')" +test "$pane" != "$self_pane" ``` -## Core Workflow +`open` always creates a new tab/pane for a path and brings Prowl forward. To select an already-open pane, use `focus`, not `open`. -Discover panes first — and immediately mark which pane is yourself (see the warning above) so you never send/key/focus it: +Run a command and capture its result: ```bash -prowl list --json +prowl send --pane "$pane" 'git status --short' --capture --timeout 30 --json ``` -Select explicit UUIDs from the JSON. Prefer `--pane ` for `read`, `send`, `key`, and `focus`. - -Read a pane: +Deliver input without waiting: ```bash -prowl read --pane --last 80 --json +prowl send --pane "$pane" 'long-running command' --no-wait --json ``` -Send text without waiting: +Pre-fill text, then submit it later: ```bash -prowl send --pane 'command here' --no-wait --json +prowl send --pane "$pane" 'echo ready' --no-enter --no-wait --json +prowl key --pane "$pane" enter --json ``` -Send and capture command output when machine verification matters: +Use this pattern only for a pane you have positively identified. If `$pane` is your own pane, `key enter` submits text into your current session. + +Send multiline input from stdin: ```bash -prowl send --pane 'command here' --capture --timeout 30 --json +printf '%s\n' 'echo first' 'echo second' | prowl send --pane "$pane" --capture --timeout 30 --json ``` -Send a key (double-check `` is **not your own** — `esc`/`ctrl-c` sent to yourself aborts your current request): +Close a temporary tab/pane when done. There is no `prowl close` command: ```bash -prowl key --pane enter --json -prowl key --pane ctrl-c --json +prowl key --pane "$pane" cmd-w --json ``` -Focus a pane: +## Reading Agent Output + +`task.status` is useful for coordination but is not enough to prove the screen finished rendering. `idle` can arrive before a TUI has painted its final response. + +Prefer `read --wait-stable` for screen snapshots: ```bash -prowl focus --pane --json +prowl read --pane "$pane" --last 200 --wait-stable --json ``` -Open a path in Prowl. **Side effect:** this *always creates a new tab + pane* for the path — even a non-git path like `/tmp` (`created_tab: true`) — and brings the app to front. To re-focus an already-open worktree without spawning a tab, use `focus`, not `open`: +For polling, avoid zsh's readonly `status` variable: ```bash -prowl open /path/to/repo --json +for i in 1 2 3 4 5 6; do + task_state="$(prowl list --json | jq -r --arg p "$pane" '.data.items[] | select(.pane.id == $p) | .task.status')" + [ "$task_state" = idle ] && break + sleep 1 +done +prowl read --pane "$pane" --last 200 --wait-stable --json ``` -## `send` / `key` argument shapes +When you need complete output from an agent, prefer writing or redirecting to a file over reading rendered TUI output. Screen capture can be truncated or miss folded content. -Positional arguments are **position-sensitive** — the count changes their meaning: +For non-interactive agent CLIs, redirect stdout from the shell instead of asking the agent's tool layer to write outside its sandbox: -| command | 0 args | 1 arg | 2 args | -|---|---|---|---| -| `send` | text from **stdin** | text → **current** pane | ` ` | -| `key` | error (`INVALID_ARGUMENT`) | token → **current** pane | ` ` | - -"current pane" = the focused pane = **usually yourself**, so always prefer the explicit `--pane ` form over a positional target. +```bash +prowl send --pane "$pane" \ + 'opencode run "Reply exactly: PROWL_OK" > /tmp/prowl-agent-out.txt' \ + --capture --timeout 120 --json +cat /tmp/prowl-agent-out.txt +``` -- `key --repeat <1-100>` repeats the token, e.g. `prowl key --pane down --repeat 10` (out-of-range → `INVALID_REPEAT`). -- `send --no-enter` sends text without a trailing Enter (key Enter yourself later). -- `send --capture` requires waiting *and* a trailing Enter; it diffs the screen before/after the command, so it **cannot** combine with `--no-wait` or `--no-enter` (either → `INVALID_ARGUMENT`). -- Don't mix stdin piping and a positional text arg (→ `INVALID_ARGUMENT`). +Asking `opencode` or another agent to create `/tmp/...` itself may trigger permission prompts and fail. Shell redirection is usually simpler and more deterministic. -## Finding Pane IDs +## Targeting Shortcuts -By worktree path or repository directory name: +Find by worktree path: ```bash prowl list --json | jq -r ' @@ -114,128 +122,81 @@ prowl list --json | jq -r ' ' ``` -By selected/focused pane (this is almost always **yourself** — use it to exclude, not to target): +Find focused pane, usually to exclude it: ```bash prowl list --json | jq -r '.data.items[] | select(.pane.focused == true) | .pane.id' ``` -By tab or pane title substring: - -```bash -prowl list --json | jq -r ' - .data.items[] - | select((.tab.title + " " + .pane.title) | contains("ProwlCLI")) - | .pane.id -' -``` - -For a compact human scan: +Human scan: ```bash prowl list --no-color ``` -## Waiting and Completion - -`prowl send` waits for shell integration by default. If the target pane does not report command completion, it can return `WAIT_TIMEOUT`. - -Default to `--no-wait` for simple input delivery. Use `--capture --timeout ` when you need an exit code, duration, and captured output. - -`task.status` from `prowl list --json` is useful for coordinating sibling sessions: - -- `running`: the pane/worktree is still busy. -- `idle`: it is likely ready for the next step. - -Polling pattern: - -```bash -prowl list --json | jq -r ' - .data.items[] - | select(.pane.id == "") - | .task.status -' -``` +`-t/--target` can auto-resolve pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane ` is safer for automation. -### `idle` does not mean the output finished rendering +## Argument Rules -`task.status: idle` means the agent's model generation ended — **not** that the -terminal has finished painting its reply. Claude Code's TUI repaints a long -markdown answer line by line, and that rendering lags well behind `idle`. So -reading a pane the instant it goes `idle` (or after a fixed `sleep`) routinely -catches a half-drawn screen: the visible buffer stops mid-answer with the input -prompt `❯` right under it. (Measured: at `idle` only the first ~2 of 6 sections -had rendered; 10s later it still had not finished.) `--last` size does not fix -this — the rest of the answer is not in the buffer yet. +`send` and `key` positional arguments are count-sensitive: -Two reliable ways to get the **final** output: - -1. **`prowl read --wait-stable`** — re-reads the pane on an interval until its - content stops changing, then returns the settled snapshot: - - ```bash - # bare: 200ms sampling / settle after 800ms quiet / 10s cap - prowl read --pane --last 200 --wait-stable --json - - # tuned - prowl read --pane --wait-stable \ - --stable-interval 200 \ # sample every 200ms - --stable-period 800 \ # content must hold steady 800ms to count as stable - --wait-timeout 10 --json # give up after 10s, return latest anyway - ``` - - The JSON gains `stabilized` (true = settled, false = hit the timeout), - `waited_ms`, and `samples`. Prefer this over manual `sleep`-then-`read`. The - `--stable-*` options only work **with** `--wait-stable` — passing them alone - returns `INVALID_ARGUMENT`. +| command | 0 args | 1 arg | 2 args | +|---|---|---|---| +| `send` | text from stdin | text to focused pane | ` ` | +| `key` | error | token to focused pane | ` ` | - Note this still only sees the rendered buffer, so content Claude Code has - **folded** (`⎿ … +N lines (ctrl+o to expand)`) is never captured no matter - how stable it is. +Avoid positional targeting in automation. The focused pane changes after `open` and `focus`. -2. **Have the agent write its result to a file**, then read the file. This - bypasses both async rendering and folding, so it is the most robust when you - need the complete answer: +Important combinations: - ```bash - prowl send --pane 'summarize … and write it to /tmp/out.md' --no-wait - # … wait for idle … then: - cat /tmp/out.md - ``` +- `send --capture` waits for completion and sends a trailing Enter. It cannot combine with `--no-wait` or `--no-enter`. +- `send --no-enter` only pre-fills text. Use `key enter` to submit later. +- `key --repeat <1-100>` repeats a token, for example `prowl key --pane "$pane" down --repeat 10`. +- Do not mix stdin input with a positional text argument. ## Quoting -Protect commands from the local shell when they should expand inside the target pane: +Use outer single quotes when variables should expand in the target pane: ```bash -prowl send --pane 'printf "PWD:%s\n" "$PWD"' --no-wait +prowl send --pane "$pane" 'printf "PWD:%s\n" "$PWD"' --capture --timeout 30 --json ``` Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, or command substitutions unless local expansion is intended. -For multiline or generated input, pipe stdin: +## Pitfalls -```bash -printf '%s\n' 'echo first' 'echo second' | prowl send --pane --no-wait --json -``` +- Never target by tab title alone; use `pane.id` plus path/cwd. +- Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. +- `open /path` creates a new tab/pane. It is not a refocus command. +- Focused pane is not stable; `open` and `focus` change it. +- `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. +- `send --capture` captures a screen diff; multiline input may include command echo. +- `prowl list --json | jq ...` snippets should pass shell values with `--arg`. +- In zsh, do not name variables `status`; it is readonly. +- Parser errors are not JSON even if `--json` is present, because parsing happens before command execution. +- `cmd-w` can close a temporary tab, but double-check the pane first. ## Error Handling -In `--json` mode, command-level errors come back as `{ "ok": false, "error": { "code", "message" } }`. Common codes: +In `--json` mode, command-level failures look like: + +```json +{ "ok": false, "error": { "code": "INVALID_ARGUMENT", "message": "..." } } +``` + +Common codes and recovery: -- `APP_NOT_RUNNING`: Prowl is not running, or its CLI service is unavailable. Ask the user before restarting Prowl. -- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: re-run `prowl list --json` and resolve an explicit pane UUID (the error does **not** enumerate the candidates). -- `EMPTY_INPUT`: `send` got no text (no argument and no stdin). -- `INVALID_ARGUMENT`: bad flag value or illegal combo (e.g. `--capture` with `--no-wait`, `--stable-*` without `--wait-stable`, `--last 0`). -- `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED`: `open` path problems. -- `UNSUPPORTED_KEY` / `INVALID_REPEAT`: bad key token or out-of-range `--repeat`; see `prowl key --help`. Canonical tokens: `enter`, `esc`, `tab`, `up`, `down`, `ctrl-c`, `cmd-k`, `f1`, … -- `WAIT_TIMEOUT`: `send` waited but the command never reported completion — retry with `--no-wait`, or use a shell-integrated pane for `--capture`. +- `APP_NOT_RUNNING`: Prowl is not reachable. Ask before restarting the app. +- `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID. +- `EMPTY_INPUT`: `send` got neither argv text nor stdin. +- `INVALID_ARGUMENT`: illegal flag or flag combination, such as `--capture --no-wait`. +- `UNSUPPORTED_KEY` / `INVALID_REPEAT`: check `prowl key --help`. +- `WAIT_TIMEOUT`: retry with `--no-wait`, or use `--capture` only in a shell-integrated pane. +- `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED`: fix the path passed to `open`. -> ⚠️ **Not every failure is JSON.** Argument-*parsing* errors (unknown flag, wrong type such as `read --last abc`) are caught by the parser *before* `--json` takes effect: they print a plaintext usage error to **stderr** and exit non-zero. A `| jq …` pipeline will choke on these. Get flag names/types right, and always check the exit code rather than assuming stdout is JSON. +Always check the exit code before piping output into `jq`; parser-level errors print plaintext usage to stderr. -## Notes +## Command Set -- JSON output is the automation surface; text output is for humans. JSON keys are snake_case (`line_count`, `waited_ms`, `created_tab`, `trailing_enter_sent`, …) — match them exactly in `jq` or you'll silently get `null`. -- `--no-color` is global (works on every command) and safe in automation. -- `-t/--target` auto-resolves pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane` is safer for automation. -- The current commands are `list`, `read`, `send`, `key`, `focus`, and `open` (the default). There is **no close/quit command** — you cannot close a tab/pane via the CLI (send `cmd-w` as a key if you must). Run `prowl --help` to confirm the command set before automating. +Current commands: `list`, `read`, `send`, `key`, `focus`, and `open` (default). There is no CLI `close` or `quit`; use key shortcuts such as `cmd-w` only after explicit target verification. -- 2.51.2 From 7eb31f0f5ab23db8953c2afd08e3e45f9e433bcf Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 23:57:28 +0900 Subject: [PATCH 285/528] Fix prowl read `truncated` to mean "may be incomplete", not "fewer than requested" `prowl read --last N` set `truncated: true` whenever it returned fewer lines than requested, even when the full screen+scrollback buffer was retrievable and simply held less history than N. That is the opposite of useful: the response actually contained everything the pane retains, yet the flag implied data loss and invited needless retries. Reserve `truncated` for genuine incompleteness: it is now true only on the viewport-only fallbacks, where the full buffer could not be read and the visible viewport had fewer lines than requested, so older content may exist beyond reach. When the full buffer is available, the result is complete regardless of how it compares to N -> `truncated: false`. This also aligns read's `truncated` with `send --capture`'s, where true already means "captured text may be missing content". Updates the read contract doc and the handler test that pinned the old behavior. --- doc-onevcat/contracts/cli/read.md | 10 +++++++++- supacode/CLIService/ReadCommandHandler.swift | 13 ++++++++++++- supacodeTests/CLIReadCommandHandlerTests.swift | 7 +++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/doc-onevcat/contracts/cli/read.md b/doc-onevcat/contracts/cli/read.md index 2324de95..0832a86a 100644 --- a/doc-onevcat/contracts/cli/read.md +++ b/doc-onevcat/contracts/cli/read.md @@ -101,7 +101,15 @@ This file defines the **JSON output contract** for: - `"scrollback"`: satisfied from scrollback/history - `"mixed"`: combined view when the runtime had to stitch sources together - `truncated`: boolean - - `true` when the runtime could not return the full requested text volume + - `true` only when the returned `text` may be **incomplete** — i.e. the runtime could not + retrieve the pane's full screen+scrollback buffer and the visible viewport alone held fewer + lines than requested, so older content may exist beyond reach. + - `false` when `text` holds every line the pane retains for the request. In particular, + receiving fewer lines than `--last N` because the pane simply has less history than `N` is + **not** truncation — you still got everything available. + - This matches the meaning of `truncated` in the `send --capture` response (`prowl.cli.send`): + in both, `true` signals "the captured text may be missing content", never "you got less than + you asked for but it is all there is". - `line_count`: integer - number of newline-delimited lines present in `text` - `text`: string diff --git a/supacode/CLIService/ReadCommandHandler.swift b/supacode/CLIService/ReadCommandHandler.swift index 6ed91363..3fdc7d8a 100644 --- a/supacode/CLIService/ReadCommandHandler.swift +++ b/supacode/CLIService/ReadCommandHandler.swift @@ -227,6 +227,9 @@ final class ReadCommandHandler: CommandHandler { } guard let screenText else { + // The full screen+scrollback buffer could not be read, so we only have the visible + // viewport. If it holds fewer lines than requested, older history may exist beyond our + // reach — the result may be incomplete, which is exactly what `truncated` should signal. return ReadCapture( text: joinLines(viewportLines.suffix(min(requestedLineCount, viewportLines.count))), source: .mixed, @@ -236,6 +239,9 @@ final class ReadCommandHandler: CommandHandler { let screenLines = splitLines(screenText) if screenLines.count < viewportLines.count { + // The full-buffer read returned fewer lines than the viewport itself — an unreliable + // capture. We fall back to the viewport and, as above, cannot vouch for completeness + // when it is shorter than requested. return ReadCapture( text: joinLines(viewportLines.suffix(min(requestedLineCount, viewportLines.count))), source: .mixed, @@ -246,10 +252,15 @@ final class ReadCommandHandler: CommandHandler { let source: ReadSource = screenLines.count > viewportLines.count ? .scrollback : .screen let text = joinLines(screenLines.suffix(min(requestedLineCount, screenLines.count))) + // The full screen+scrollback buffer was retrievable, so `text` already holds every line + // the pane retains. Returning fewer lines than `--last` requested here only means the + // pane has less history than asked for — not that content was lost — so it is complete, + // not truncated. `truncated` stays reserved for cases where content may be unreachable + // (see the viewport-only fallbacks above). return ReadCapture( text: text, source: source, - truncated: screenLines.count < requestedLineCount + truncated: false ) } diff --git a/supacodeTests/CLIReadCommandHandlerTests.swift b/supacodeTests/CLIReadCommandHandlerTests.swift index aa53089b..431ceeed 100644 --- a/supacodeTests/CLIReadCommandHandlerTests.swift +++ b/supacodeTests/CLIReadCommandHandlerTests.swift @@ -124,7 +124,7 @@ struct CLIReadCommandHandlerTests { #expect(payload.text == "b\nc\nd") } - @Test func lastMarksTruncatedWhenScreenInsufficient() async throws { + @Test func lastReturnsFullBufferWithoutTruncationWhenHistoryShorterThanRequest() async throws { let handler = ReadCommandHandler( resolveProvider: { _ in .success(Self.makeTarget()) }, captureProvider: { _ in @@ -140,7 +140,10 @@ struct CLIReadCommandHandlerTests { #expect(response.ok) let payload = try #require(try response.data?.decode(as: ReadCommandPayload.self)) #expect(payload.source == .scrollback) - #expect(payload.truncated == true) + // The full screen+scrollback buffer was retrievable and holds only 4 lines, so the + // response contains everything the pane retains. Fewer lines than `--last 10` requested + // does not mean content was lost, so it must not be flagged truncated. + #expect(payload.truncated == false) #expect(payload.lineCount == 4) #expect(payload.text == "one\ntwo\nthree\nfour") } -- 2.51.2 From 510672a13d7f34a43e8079198ef51dce1fca4f55 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 4 Jun 2026 23:57:35 +0900 Subject: [PATCH 286/528] Improve prowl-cli skill guide From a skill-evaluation pass on prowl-cli: - Broaden the description to trigger on parallel/side-by-side agents, splits, and colloquial framings ("the agent in my left split", "are my agents idle?"), while excluding settings/keybindings how-to questions. - Document the real error codes CAPTURE_UNSUPPORTED and NO_ACTIVE_PANE, and correct the WAIT_TIMEOUT recovery advice. - Reframe the polling recipe: `--wait-stable` is usually enough; poll task.status only to wait for working->idle. - Clarify the `read` `truncated` pitfall to match the corrected semantics (true = result may be incomplete, not "fewer lines than requested"). --- skills/prowl-cli/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index f647badb..bef6542d 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: prowl-cli -description: Use the Prowl CLI to inspect or control a running Prowl app, especially when a user asks to read from, coordinate, focus, send text to, or send keys to Prowl worktrees, tabs, panes, or sibling agent sessions. +description: Use the Prowl CLI (`prowl`) to inspect or control a running Prowl GUI app and the agent sessions it hosts. Prowl runs several coding agents in parallel, each in its own pane/tab/worktree, so reach for this whenever the user wants to act on a pane other than the current one — check on, coordinate, read from, focus, send text or keys to, open, or close another pane, tab, worktree, split, window, or sibling/neighboring agent. Covers colloquial framings that never say "prowl": "check what the agent in my other window is doing", "are any of my agents running side by side still working or idle?", "tell the agent in my left split to rerun the tests", "send npm run build to the build tab and grab the output", "open ~/proj in a fresh tab", "close that scratch tab I left open". Not for ordinary editing or building inside the Prowl source repo, and not for how-to questions about Prowl's settings, preferences, or keybindings — only when the task is to actually drive panes in the live Prowl app. --- # Prowl CLI @@ -86,7 +86,7 @@ Prefer `read --wait-stable` for screen snapshots: prowl read --pane "$pane" --last 200 --wait-stable --json ``` -For polling, avoid zsh's readonly `status` variable: +Most of the time `--wait-stable` alone is enough — it blocks until the screen stops changing. Only poll `task.status` first when you specifically need to wait for an agent to go from `working` back to `idle` (status flips before the TUI finishes painting, so still finish with `--wait-stable`). When polling in zsh, do not name the variable `status` — it is readonly there: ```bash for i in 1 2 3 4 5 6; do @@ -171,6 +171,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - `open /path` creates a new tab/pane. It is not a refocus command. - Focused pane is not stable; `open` and `focus` change it. - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. +- `read` sets `truncated: true` only when the returned text may be incomplete (the full scrollback could not be read and the viewport had fewer lines than `--last` asked for). Getting fewer lines than `--last` simply because the pane has less history is `truncated: false` — you already have everything; do not retry expecting more. - `send --capture` captures a screen diff; multiline input may include command echo. - `prowl list --json | jq ...` snippets should pass shell values with `--arg`. - In zsh, do not name variables `status`; it is readonly. @@ -190,9 +191,11 @@ Common codes and recovery: - `APP_NOT_RUNNING`: Prowl is not reachable. Ask before restarting the app. - `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID. - `EMPTY_INPUT`: `send` got neither argv text nor stdin. +- `NO_ACTIVE_PANE`: no pane resolved for positional (focused-pane) targeting; pass an explicit `--pane`. - `INVALID_ARGUMENT`: illegal flag or flag combination, such as `--capture --no-wait`. - `UNSUPPORTED_KEY` / `INVALID_REPEAT`: check `prowl key --help`. -- `WAIT_TIMEOUT`: retry with `--no-wait`, or use `--capture` only in a shell-integrated pane. +- `CAPTURE_UNSUPPORTED`: `--capture` needs shell integration (OSC 133) on the target pane. Drop `--capture` and `read --wait-stable` instead, or redirect the command's output to a file (see "Reading Agent Output"). +- `WAIT_TIMEOUT`: command did not finish in time; retry with `--no-wait`, or raise `--timeout`. - `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED`: fix the path passed to `open`. Always check the exit code before piping output into `jq`; parser-level errors print plaintext usage to stderr. -- 2.51.2 From 778fe915ced1d250efa11791008eec10657a0cf2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 00:10:48 +0900 Subject: [PATCH 287/528] Trim prowl-cli read truncated pitfall to a lean note The read truncated semantics are fixed and intuitive now, so the long two-value explanation is no longer pulling its weight. Keep only the part that still prevents wrong agent behavior: fewer lines than --last is normally truncated:false (you have everything, do not retry); truncated:true flags a possibly-incomplete result. --- skills/prowl-cli/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index bef6542d..6bd7d0b9 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -171,7 +171,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - `open /path` creates a new tab/pane. It is not a refocus command. - Focused pane is not stable; `open` and `focus` change it. - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. -- `read` sets `truncated: true` only when the returned text may be incomplete (the full scrollback could not be read and the viewport had fewer lines than `--last` asked for). Getting fewer lines than `--last` simply because the pane has less history is `truncated: false` — you already have everything; do not retry expecting more. +- `read` returning fewer lines than `--last` requested is normally `truncated: false` — the pane simply has less history and you already have it all, so do not retry for more. `truncated: true` flags a possibly-incomplete result (the full scrollback could not be read). - `send --capture` captures a screen diff; multiline input may include command echo. - `prowl list --json | jq ...` snippets should pass shell values with `--arg`. - In zsh, do not name variables `status`; it is readonly. -- 2.51.2 From 499430c8057d4869fb1027154f9355854bbf46fd Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 00:19:06 +0900 Subject: [PATCH 288/528] bump v2026.6.5 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index b9db43bd..dfaf6e29 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260601; + CURRENT_PROJECT_VERSION = 20260605; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.1; + MARKETING_VERSION = 2026.6.5; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260601; + CURRENT_PROJECT_VERSION = 20260605; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.1; + MARKETING_VERSION = 2026.6.5; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260601; + CURRENT_PROJECT_VERSION = 20260605; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.1; + MARKETING_VERSION = 2026.6.5; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260601; + CURRENT_PROJECT_VERSION = 20260605; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.1; + MARKETING_VERSION = 2026.6.5; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 18f06480..5dcd9c25 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.1" + public static let current = "2026.6.5" } -- 2.51.2 From b46e0d2e9afb1e4c552f6ea43ab0578d7f453a42 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 00:19:08 +0900 Subject: [PATCH 289/528] Update CHANGELOG for 2026.6.5 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c393ed79..251d5f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2026.6.5](https://github.com/onevcat/Prowl/releases/tag/v2026.6.5) + +This release focuses on CLI reliability for agent workflows and several startup and window-management fixes. + +### New + +- `prowl read` gains a `--wait-stable` flag that polls the pane buffer until output has stopped changing before returning, avoiding truncated reads at the moment an agent finishes generating. Use `prowl read --pane --last 200 --wait-stable --json`; tuning flags `--stable-interval`, `--stable-period`, and `--wait-timeout` are available. +- New "Show tab titles in Active Agents" setting (Appearance preferences): when enabled, each agent row shows the terminal tab title as its subtitle instead of the branch name. + +### Fixed + +- `prowl read` no longer reports `truncated: true` when the full scrollback buffer is available but simply holds fewer lines than requested. The flag now correctly means "the returned text may be incomplete." +- Fixed a launch race and restoration hang where the default view (Shelf or Canvas) could fail to apply or stall indefinitely at startup. +- Hardened main window surfacing so Prowl reliably comes to the front when clicked in the Dock or reopened, and no longer raises unrelated helper windows. +- Worktree cleanup on a failed creation no longer requests branch deletion; directory removal is now gated on an exact path match and the presence of `.git` metadata. +- Fixed a CLI socket ownership bug where a secondary app instance could unlink or replace a live socket, breaking `prowl` CLI connectivity. + ## [2026.6.1](https://github.com/onevcat/Prowl/releases/tag/v2026.6.1) ### Fixed -- 2.51.2 From 5c930eb5657f4cc78b15e4b0776050e9978fbcf4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 00:44:10 +0900 Subject: [PATCH 290/528] Fix prowl-cli SKILL.md YAML frontmatter so strict parsers discover it The description was an unquoted YAML plain scalar containing ": " (e.g. never say "prowl": "check ..."), which is illegal in YAML plain scalars. Claude Code's lenient loader accepted it, but strict YAML parsers such as the one used by `npx skills add` failed to read the description and silently dropped the skill, so the skill could not be installed from the repo. Wrap the description in a folded block scalar (>-) where colons are allowed, keeping the text byte-identical. `npx skills add onevcat/Prowl` now discovers all three skills including prowl-cli. --- skills/prowl-cli/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 6bd7d0b9..fb84f5ec 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -1,6 +1,7 @@ --- name: prowl-cli -description: Use the Prowl CLI (`prowl`) to inspect or control a running Prowl GUI app and the agent sessions it hosts. Prowl runs several coding agents in parallel, each in its own pane/tab/worktree, so reach for this whenever the user wants to act on a pane other than the current one — check on, coordinate, read from, focus, send text or keys to, open, or close another pane, tab, worktree, split, window, or sibling/neighboring agent. Covers colloquial framings that never say "prowl": "check what the agent in my other window is doing", "are any of my agents running side by side still working or idle?", "tell the agent in my left split to rerun the tests", "send npm run build to the build tab and grab the output", "open ~/proj in a fresh tab", "close that scratch tab I left open". Not for ordinary editing or building inside the Prowl source repo, and not for how-to questions about Prowl's settings, preferences, or keybindings — only when the task is to actually drive panes in the live Prowl app. +description: >- + Use the Prowl CLI (`prowl`) to inspect or control a running Prowl GUI app and the agent sessions it hosts. Prowl runs several coding agents in parallel, each in its own pane/tab/worktree, so reach for this whenever the user wants to act on a pane other than the current one — check on, coordinate, read from, focus, send text or keys to, open, or close another pane, tab, worktree, split, window, or sibling/neighboring agent. Covers colloquial framings that never say "prowl": "check what the agent in my other window is doing", "are any of my agents running side by side still working or idle?", "tell the agent in my left split to rerun the tests", "send npm run build to the build tab and grab the output", "open ~/proj in a fresh tab", "close that scratch tab I left open". Not for ordinary editing or building inside the Prowl source repo, and not for how-to questions about Prowl's settings, preferences, or keybindings — only when the task is to actually drive panes in the live Prowl app. --- # Prowl CLI -- 2.51.2 From bdf84b6b6466eed83db1b8da5e6b52d6d5906d9f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 00:45:22 +0900 Subject: [PATCH 291/528] Document installing the prowl-cli skill via npx skills --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8bec03f1..159d8229 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ prowl read # read screen content on demand prowl key # send keystrokes programmatically ``` +Teach your agent when and how to drive Prowl by installing the bundled `prowl-cli` skill with [`skills`](https://github.com/vercel-labs/skills): `npx skills add onevcat/Prowl --skill prowl-cli`. + ### And the stuff you'd expect, done right - **Full Native** — powered by libghostty. No Electron, no web views. CJK-safe out of the box. -- 2.51.2 From 134d96466fbcbe7b0302e64dea4169dc59aca844 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 01:02:53 +0900 Subject: [PATCH 292/528] Stabilize sidebar shortcut hint layout --- .../Repositories/Views/WorktreeRow.swift | 9 +++- .../Repositories/Views/WorktreeRowsView.swift | 52 +++++++++++++------ supacode/Support/ShortcutHintView.swift | 2 + 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 89807af8..4e9fb6b8 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -16,6 +16,7 @@ struct WorktreeRow: View { let notifications: [WorktreeTerminalNotification] let onFocusNotification: (WorktreeTerminalNotification) -> Void let shortcutHint: String? + let showsShortcutHint: Bool let pinAction: (() -> Void)? let isSelected: Bool let archiveAction: (() -> Void)? @@ -128,7 +129,8 @@ struct WorktreeRow: View { pullRequestNumber: display.pullRequest?.number, pullRequestState: display.pullRequestState, mergeReadiness: mergeReadiness, - shortcutHint: shortcutHint + shortcutHint: shortcutHint, + showsShortcutHint: showsShortcutHint ) .padding(.leading, 22) } @@ -178,6 +180,7 @@ private struct WorktreeRowInfoView: View { let pullRequestState: String? let mergeReadiness: PullRequestMergeReadiness? let shortcutHint: String? + let showsShortcutHint: Bool var body: some View { HStack(spacing: 4) { @@ -188,10 +191,13 @@ private struct WorktreeRowInfoView: View { Spacer(minLength: 0) if let shortcutHint { ShortcutHintView(text: shortcutHint, color: .secondary) + .opacity(showsShortcutHint ? 1 : 0) + .accessibilityHidden(!showsShortcutHint) } } .font(.caption) .frame(minHeight: 14) + .animation(.easeInOut(duration: 0.15), value: showsShortcutHint) } private var summaryText: Text { @@ -299,6 +305,7 @@ private struct WorktreeRowPreview: View { notifications: [], onFocusNotification: { _ in }, shortcutHint: shortcutHint, + showsShortcutHint: shortcutHint != nil, pinAction: {}, isSelected: isSelected, archiveAction: {}, diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 3c0cc2e4..e548b79d 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -29,16 +29,21 @@ struct WorktreeRowsView: View { let sections = state.worktreeRowSections(in: repository) let isRepositoryRemoving = state.isRemovingRepository(repository) let isSidebarDragActive = state.isSidebarDragActive - let showShortcutHints = commandKeyObserver.isPressed - let allRows = showShortcutHints ? hotkeyRows : [] - let shortcutIndexByID = Dictionary( - uniqueKeysWithValues: allRows.enumerated().map { ($0.element.id, $0.offset) } + let shortcutHintTextByID = Dictionary( + uniqueKeysWithValues: hotkeyRows.enumerated().compactMap { index, row -> (Worktree.ID, String)? in + guard let text = worktreeShortcutHint(for: index) else { return nil } + return (row.id, text) + } + ) + let shortcutHints = WorktreeShortcutHints( + textByID: shortcutHintTextByID, + isVisible: commandKeyObserver.isPressed ) let rowIDs = sections.allRows.map(\.id) return rowsGroup( sections: sections, isRepositoryRemoving: isRepositoryRemoving, - shortcutIndexByID: shortcutIndexByID + shortcutHints: shortcutHints ) .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) .onReceive(NotificationCenter.default.publisher(for: NSMenu.didEndTrackingNotification)) { _ in @@ -50,14 +55,14 @@ struct WorktreeRowsView: View { private func rowsGroup( sections: WorktreeRowSections, isRepositoryRemoving: Bool, - shortcutIndexByID: [Worktree.ID: Int] + shortcutHints: WorktreeShortcutHints ) -> some View { if let row = sections.main { rowView( row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: true, - shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) + shortcutHint: shortcutHints.display(for: row.id) ) } movableRowsGroup( @@ -65,14 +70,14 @@ struct WorktreeRowsView: View { section: .pinned, targetedDestination: $targetedPinnedDropDestination, isRepositoryRemoving: isRepositoryRemoving, - shortcutIndexByID: shortcutIndexByID + shortcutHints: shortcutHints ) ForEach(sections.pending) { row in rowView( row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: true, - shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) + shortcutHint: shortcutHints.display(for: row.id) ) } movableRowsGroup( @@ -80,7 +85,7 @@ struct WorktreeRowsView: View { section: .unpinned, targetedDestination: $targetedUnpinnedDropDestination, isRepositoryRemoving: isRepositoryRemoving, - shortcutIndexByID: shortcutIndexByID + shortcutHints: shortcutHints ) } @@ -90,7 +95,7 @@ struct WorktreeRowsView: View { section: SidebarWorktreeSection, targetedDestination: Binding, isRepositoryRemoving: Bool, - shortcutIndexByID: [Worktree.ID: Int] + shortcutHints: WorktreeShortcutHints ) -> some View { let rowIDs = rows.map(\.id) let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty @@ -99,7 +104,7 @@ struct WorktreeRowsView: View { row, isRepositoryRemoving: isRepositoryRemoving, moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, - shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) + shortcutHint: shortcutHints.display(for: row.id) ) .worktreeDropTarget( index: index, @@ -122,7 +127,7 @@ struct WorktreeRowsView: View { _ row: WorktreeRowModel, isRepositoryRemoving: Bool, moveDisabled: Bool, - shortcutHint: String? + shortcutHint: WorktreeShortcutHintDisplay ) -> some View { let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty let config = rowConfig( @@ -194,7 +199,7 @@ struct WorktreeRowsView: View { isRepositoryRemoving: Bool, isWorktreeDragActive: Bool, moveDisabled: Bool, - shortcutHint: String? + shortcutHint: WorktreeShortcutHintDisplay ) -> WorktreeRowViewConfig { let displayName = if row.isDeleting { @@ -214,7 +219,8 @@ struct WorktreeRowsView: View { showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, notifications: isWorktreeDragActive ? [] : notifications, onFocusNotification: focusNotificationHandler(for: row.id), - shortcutHint: shortcutHint, + shortcutHint: shortcutHint.text, + showsShortcutHint: shortcutHint.isVisible, pinAction: canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } : nil, archiveAction: canShowRowActions && !row.isMainWorktree ? { archiveWorktree(row.id) } : nil, onDiffTap: diffTapHandler(for: row.id), @@ -326,6 +332,7 @@ struct WorktreeRowsView: View { let notifications: [WorktreeTerminalNotification] let onFocusNotification: (WorktreeTerminalNotification) -> Void let shortcutHint: String? + let showsShortcutHint: Bool let pinAction: (() -> Void)? let archiveAction: (() -> Void)? let onDiffTap: (() -> Void)? @@ -333,6 +340,20 @@ struct WorktreeRowsView: View { let moveDisabled: Bool } + private struct WorktreeShortcutHints { + let textByID: [Worktree.ID: String] + let isVisible: Bool + + func display(for worktreeID: Worktree.ID) -> WorktreeShortcutHintDisplay { + WorktreeShortcutHintDisplay(text: textByID[worktreeID], isVisible: isVisible) + } + } + + private struct WorktreeShortcutHintDisplay { + let text: String? + let isVisible: Bool + } + private func worktreeRowView(_ row: WorktreeRowModel, config: WorktreeRowViewConfig) -> some View { let isSelected = selectedWorktreeIDs.contains(row.id) let showsContextMenuHighlight = contextMenuHighlightedWorktreeID == row.id && !isSelected @@ -354,6 +375,7 @@ struct WorktreeRowsView: View { notifications: config.notifications, onFocusNotification: config.onFocusNotification, shortcutHint: config.shortcutHint, + showsShortcutHint: config.showsShortcutHint, pinAction: config.pinAction, isSelected: isSelected, archiveAction: config.archiveAction, diff --git a/supacode/Support/ShortcutHintView.swift b/supacode/Support/ShortcutHintView.swift index ee486038..0c9f42ce 100644 --- a/supacode/Support/ShortcutHintView.swift +++ b/supacode/Support/ShortcutHintView.swift @@ -7,6 +7,8 @@ struct ShortcutHintView: View { var body: some View { Text(text) .font(.caption2) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .foregroundStyle(color) } } -- 2.51.2 From 5c6ba439d492894dc932eef69f447effa11b1960 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 01:04:07 +0900 Subject: [PATCH 293/528] Allow debug run with existing Prowl instance --- Makefile | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Makefile b/Makefile index 38dba586..419f64e9 100644 --- a/Makefile +++ b/Makefile @@ -135,14 +135,6 @@ run-app: build-app # Build then launch (Debug) with log streaming exit 1; \ fi; \ app_path="$$build_dir/$$product/Contents/MacOS/$$exec_name"; \ - if [ "$${ALLOW_MULTIPLE_PROWL:-0}" != "1" ]; then \ - existing_pid="$$(ps -axww -o pid= -o command= | awk '$$2 ~ /\/Prowl\.app\/Contents\/MacOS\/ProwlApp$$/ { print $$1; exit }')"; \ - if [ -n "$$existing_pid" ]; then \ - echo "error: another Prowl app instance is already running (pid $$existing_pid). Quit it before make run-app."; \ - echo " Direct debug launches share app state and the CLI socket. Set ALLOW_MULTIPLE_PROWL=1 only if you need a second GUI instance."; \ - exit 1; \ - fi; \ - fi; \ "$$app_path" install-dev-build: build-app # install dev build to /Applications -- 2.51.2 From e01d45ee1431e93923843cdcb78bf0936eeda24d Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 21:28:53 +0900 Subject: [PATCH 294/528] Add Canvas layout shortcuts (Arrange/Organize) Bind Cmd+Option+R to "Arrange cards preserving sizes" and Cmd+Option+G to "Organize cards in a uniform grid", joining the existing Canvas Cmd+Option shortcut family (toggleCanvas, selectAllCanvasCards). The shortcuts are registered in AppShortcuts.bindings with the .localInteraction scope so they are user-overridable via the keybinding schema, and handled through CanvasView's .onKeyPress. Because the handler lives inside CanvasView's body, it is only mounted while the Canvas is visible, keeping the bindings scoped to Canvas and preventing them from leaking into other panels. Toolbar buttons now share the animated arrange/organize helpers and surface the shortcut hint via AppShortcuts.helpText. Closes #392 --- supacode/App/AppShortcuts.swift | 16 +++++ .../Features/Canvas/Views/CanvasView.swift | 68 ++++++++++++++++--- supacodeTests/AppShortcutsTests.swift | 17 +++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index ee7a7d9d..a802ab4f 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -140,6 +140,8 @@ enum AppShortcuts { static let selectTerminalTab9 = "select_terminal_tab_9" static let renameBranch = "rename_branch" static let selectAllCanvasCards = "select_all_canvas_cards" + static let arrangeCanvasCards = "arrange_canvas_cards" + static let organizeCanvasCards = "organize_canvas_cards" static let selectPreviousTerminalTab = "select_previous_terminal_tab" static let selectNextTerminalTab = "select_next_terminal_tab" static let selectPreviousTerminalPane = "select_previous_terminal_pane" @@ -286,6 +288,8 @@ enum AppShortcuts { ) static let renameBranch = AppShortcut(key: "m", modifiers: [.command, .shift]) static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) + static let arrangeCanvasCards = AppShortcut(key: "r", modifiers: [.command, .option]) + static let organizeCanvasCards = AppShortcut(key: "g", modifiers: [.command, .option]) static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, @@ -751,6 +755,18 @@ enum AppShortcuts { scope: .localInteraction, shortcut: selectAllCanvasCards ), + .init( + id: CommandID.arrangeCanvasCards, + title: "Arrange Canvas Cards", + scope: .localInteraction, + shortcut: arrangeCanvasCards + ), + .init( + id: CommandID.organizeCanvasCards, + title: "Organize Canvas Cards", + scope: .localInteraction, + shortcut: organizeCanvasCards + ), ] static func userOverrideConflicts( diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index e7b336c2..5fbf96d6 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -46,6 +46,14 @@ struct CanvasView: View { for: AppShortcuts.CommandID.selectAllCanvasCards, in: resolvedKeybindings ) + let arrangeCanvasShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.arrangeCanvasCards, + in: resolvedKeybindings + ) + let organizeCanvasShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.organizeCanvasCards, + in: resolvedKeybindings + ) let _ = configReloadCounter CanvasScrollContainer( offset: $canvasOffset, @@ -135,6 +143,24 @@ struct CanvasView: View { selectAllCards() return .handled } + .onKeyPress( + arrangeCanvasShortcut?.keyEquivalent ?? AppShortcuts.arrangeCanvasCards.keyEquivalent, + phases: .down + ) { keyPress in + let shortcutModifiers = arrangeCanvasShortcut?.modifiers ?? AppShortcuts.arrangeCanvasCards.modifiers + guard keyPress.modifiers == shortcutModifiers else { return .ignored } + arrangeCardsWithFit() + return .handled + } + .onKeyPress( + organizeCanvasShortcut?.keyEquivalent ?? AppShortcuts.organizeCanvasCards.keyEquivalent, + phases: .down + ) { keyPress in + let shortcutModifiers = organizeCanvasShortcut?.modifiers ?? AppShortcuts.organizeCanvasCards.modifiers + guard keyPress.modifiers == shortcutModifiers else { return .ignored } + organizeCardsWithFit() + return .handled + } .task { activateCanvas() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in configReloadCounter &+= 1 @@ -434,6 +460,24 @@ struct CanvasView: View { layoutStore.setCardLayouts(result.layouts, zOrder: keys) } + /// Arrange cards (preserving sizes) and refit the viewport, animated. + /// Shared by the toolbar button and the keyboard shortcut. + private func arrangeCardsWithFit() { + withAnimation(.easeInOut(duration: 0.2)) { + arrangeCards() + fitToView(canvasSize: viewportSize) + } + } + + /// Organize cards into a uniform grid and refit the viewport, animated. + /// Shared by the toolbar button and the keyboard shortcut. + private func organizeCardsWithFit() { + withAnimation(.easeInOut(duration: 0.2)) { + organizeCards() + fitToView(canvasSize: viewportSize) + } + } + /// Adjust scale and offset so all cards fit within the viewport. private func fitToView(canvasSize: CGSize) { guard canvasSize.width > 0, canvasSize.height > 0 else { return } @@ -566,30 +610,34 @@ struct CanvasView: View { )) Button { - withAnimation(.easeInOut(duration: 0.2)) { - arrangeCards() - fitToView(canvasSize: viewportSize) - } + arrangeCardsWithFit() } label: { Image(systemName: "rectangle.3.group") .font(.body) .accessibilityLabel("Arrange") } .buttonStyle(.bordered) - .help("Arrange cards preserving sizes") + .help( + AppShortcuts.helpText( + title: "Arrange cards preserving sizes", + commandID: AppShortcuts.CommandID.arrangeCanvasCards, + in: resolvedKeybindings + )) Button { - withAnimation(.easeInOut(duration: 0.2)) { - organizeCards() - fitToView(canvasSize: viewportSize) - } + organizeCardsWithFit() } label: { Image(systemName: "square.grid.2x2") .font(.body) .accessibilityLabel("Organize") } .buttonStyle(.bordered) - .help("Organize cards in a uniform grid") + .help( + AppShortcuts.helpText( + title: "Organize cards in a uniform grid", + commandID: AppShortcuts.CommandID.organizeCanvasCards, + in: resolvedKeybindings + )) } .padding() } diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index be8e9cdf..5f12b0c3 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -172,12 +172,29 @@ struct AppShortcutsTests { idToDisplay["select_all_canvas_cards"], AppShortcuts.selectAllCanvasCards.display ) + expectNoDifference( + idToDisplay["arrange_canvas_cards"], + AppShortcuts.arrangeCanvasCards.display + ) + expectNoDifference( + idToDisplay["organize_canvas_cards"], + AppShortcuts.organizeCanvasCards.display + ) #expect(idToScope["command_palette"] == .configurableAppAction) #expect(idToScope["toggle_active_agents_panel"] == .configurableAppAction) #expect(idToScope["quit_application"] == .systemFixedAppAction) #expect(idToScope["rename_branch"] == .localInteraction) #expect(idToScope["select_all_canvas_cards"] == .localInteraction) + #expect(idToScope["arrange_canvas_cards"] == .localInteraction) + #expect(idToScope["organize_canvas_cards"] == .localInteraction) + } + + @Test func canvasLayoutShortcutsUseCommandOptionFamily() { + expectNoDifference(AppShortcuts.arrangeCanvasCards.display, "⌘⌥R") + expectNoDifference(AppShortcuts.organizeCanvasCards.display, "⌘⌥G") + #expect(AppShortcuts.arrangeCanvasCards.modifiers == [.command, .option]) + #expect(AppShortcuts.organizeCanvasCards.modifiers == [.command, .option]) } @Test func userOverrideConflictsDetectsReservedAppShortcuts() { -- 2.51.2 From db6166a3cebed4f9e31288b599dde9a9c8680eaf Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 21:52:39 +0900 Subject: [PATCH 295/528] Group Canvas layout shortcuts under Scripts & Panels Map arrangeCanvasCards/organizeCanvasCards in ShortcutGroup.resolve so they appear next to "Select All Canvas Cards" in the Settings shortcut recorder, instead of falling back to the General group. --- supacode/Features/Settings/Views/ShortcutsSettingsView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 8b7e4745..319e89f6 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -937,6 +937,8 @@ private enum ShortcutGroup: String, CaseIterable, Identifiable { AppShortcuts.CommandID.selectShelfBook8, AppShortcuts.CommandID.selectShelfBook9, AppShortcuts.CommandID.selectAllCanvasCards, + AppShortcuts.CommandID.arrangeCanvasCards, + AppShortcuts.CommandID.organizeCanvasCards, AppShortcuts.CommandID.archivedWorktrees: return .scripts -- 2.51.2 From 547956271af3c57c0fcb208dde0c7beea84ab845 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 22:15:31 +0900 Subject: [PATCH 296/528] Fix Canvas new tab target --- .../Features/App/Reducer/AppFeature.swift | 4 +-- .../Reducer/CommandPaletteFeature.swift | 2 ++ .../AppFeatureCommandPaletteTests.swift | 36 +++++++++++++++++++ supacodeTests/AppFeatureRunScriptTests.swift | 25 +++++++++++++ .../CommandPaletteFeatureTests.swift | 31 ++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index f64d11de..5bd7347a 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -820,7 +820,7 @@ struct AppFeature { return .none case .newTerminal: - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } analyticsClient.capture("terminal_tab_created", nil) @@ -1268,7 +1268,7 @@ struct AppFeature { return .send(.runCustomCommand(index)) case .commandPalette(.delegate(.ghosttyCommand(let action))): - guard let worktree = state.repositories.selectedTerminalWorktree else { + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } return .run { _ in diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index dcc9ac89..4cc069dd 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -266,6 +266,8 @@ struct CommandPaletteFeature { ) ) items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) + } else if worktreeActionTargetID != nil { + items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) } if let repository = activeRepository(in: repositories) { items.append( diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index ce8363e1..bf789fb4 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -159,6 +159,42 @@ struct AppFeatureCommandPaletteTests { #expect(sent.value == [.focusSelectedTab(worktree)]) } + @Test(.dependencies) func ghosttyCommandDelegateInCanvasUsesCanvasFocusedWorktree() async { + let worktree = makeWorktree( + id: "/tmp/repo-canvas-ghostty/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-canvas-ghostty" + ) + let repository = makeRepository(id: "/tmp/repo-canvas-ghostty", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .canvas + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.commandPalette(.delegate(.ghosttyCommand("new_tab")))) + await store.finish() + + #expect( + sent.value == [ + .performBindingAction(worktree, action: "new_tab"), + .focusSelectedTab(worktree), + ] + ) + } + @Test(.dependencies) func passiveCommandPaletteCommandRestoresSelectedTerminalFocus() async { let worktree = makeWorktree( id: "/tmp/repo-passive/wt-1", diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 5bd396a5..35f2b1f1 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -123,6 +123,31 @@ struct AppFeatureRunScriptTests { #expect(sent.value == [.runScript(worktree, script: "npm test")]) } + @Test(.dependencies) func newTerminalUsesCanvasFocusedWorktree() async { + let worktree = makeWorktree() + var repositories = makeRepositoriesState(worktree: worktree) + repositories.selection = .canvas + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositories, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.newTerminal) + await store.finish() + + #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false)]) + } + private func makeWorktree() -> Worktree { Worktree( id: "/tmp/repo/wt-1", diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index d0d0a0f5..1d6255ce 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -384,6 +384,37 @@ struct CommandPaletteFeatureTests { #expect(ghosttyItem?.subtitle == "Focus the split to the right.") } + @Test func commandPaletteItems_includeGhosttyCommandsForCanvasActionTarget() { + let rootPath = "/tmp/repo-canvas-new-tab" + let worktree = makeWorktree(id: "\(rootPath)/wt-1", name: "wt-1", repoRoot: rootPath) + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) + var state = RepositoriesFeature.State(repositories: [repository]) + state.selection = .canvas + + let items = CommandPaletteFeature.commandPaletteItems( + from: state, + actionTargetWorktreeID: worktree.id, + ghosttyCommands: [ + GhosttyCommand( + title: "New Tab", + description: "Open a new tab.", + action: "new_tab", + actionKey: "new_tab" + ) + ] + ) + + let ghosttyItem = items.first { + if case .ghosttyCommand(let action) = $0.kind { + return action == "new_tab" + } + return false + } + + #expect(ghosttyItem?.title == "New Tab") + #expect(ghosttyItem?.subtitle == "Open a new tab.") + } + @Test func commandPaletteItems_filtersUnsupportedGhosttyCommands() { let rootPath = "/tmp/repo" let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) -- 2.51.2 From d60d70ef0f380b68dda7426da0947bc5d9057b2a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 22:51:35 +0900 Subject: [PATCH 297/528] Respect disabled Canvas layout shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Canvas layout binding is disabled in Settings, resolvedShortcut returns nil. The onKeyPress handlers previously fell back to the app-default modifiers (?? AppShortcuts.xxx), so the default key (⌘⌥R / ⌘⌥G) kept firing despite being unbound. Guard on the resolved shortcut and bail when nil instead. Apply the same fix to the existing selectAllCanvasCards handler for consistency, and add a resolver-layer test asserting disabled overrides resolve to nil. --- .../Features/Canvas/Views/CanvasView.swift | 14 ++++++----- supacodeTests/AppShortcutsTests.swift | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 5fbf96d6..6cc826d6 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -138,8 +138,10 @@ struct CanvasView: View { selectAllCanvasShortcut?.keyEquivalent ?? AppShortcuts.selectAllCanvasCards.keyEquivalent, phases: .down ) { keyPress in - let shortcutModifiers = selectAllCanvasShortcut?.modifiers ?? AppShortcuts.selectAllCanvasCards.modifiers - guard keyPress.modifiers == shortcutModifiers else { return .ignored } + // Bail when the binding is disabled in Settings (resolved shortcut is nil); + // otherwise the app-default key would still fire despite being unbound. + guard let shortcut = selectAllCanvasShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } selectAllCards() return .handled } @@ -147,8 +149,8 @@ struct CanvasView: View { arrangeCanvasShortcut?.keyEquivalent ?? AppShortcuts.arrangeCanvasCards.keyEquivalent, phases: .down ) { keyPress in - let shortcutModifiers = arrangeCanvasShortcut?.modifiers ?? AppShortcuts.arrangeCanvasCards.modifiers - guard keyPress.modifiers == shortcutModifiers else { return .ignored } + guard let shortcut = arrangeCanvasShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } arrangeCardsWithFit() return .handled } @@ -156,8 +158,8 @@ struct CanvasView: View { organizeCanvasShortcut?.keyEquivalent ?? AppShortcuts.organizeCanvasCards.keyEquivalent, phases: .down ) { keyPress in - let shortcutModifiers = organizeCanvasShortcut?.modifiers ?? AppShortcuts.organizeCanvasCards.modifiers - guard keyPress.modifiers == shortcutModifiers else { return .ignored } + guard let shortcut = organizeCanvasShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } organizeCardsWithFit() return .handled } diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 5f12b0c3..750b7e98 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -441,6 +441,30 @@ struct AppShortcutsTests { #expect(arguments.contains("--keybind=super+;=unbind") == false) } + @Test func disabledCanvasLayoutOverridesResolveToNil() { + let overrides = KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.arrangeCanvasCards: KeybindingUserOverride( + binding: Keybinding(key: "r", modifiers: .init(command: true, option: true)), + isEnabled: false + ), + AppShortcuts.CommandID.organizeCanvasCards: KeybindingUserOverride( + binding: Keybinding(key: "g", modifiers: .init(command: true, option: true)), + isEnabled: false + ), + ] + ) + let resolved = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: overrides + ) + + // When disabled the resolved shortcut must be nil so CanvasView's handler bails + // instead of falling back to the app-default ⌘⌥R / ⌘⌥G key. + #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.arrangeCanvasCards, in: resolved) == nil) + #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.organizeCanvasCards, in: resolved) == nil) + } + @Test func resolvedShortcutFallsBackToDefaultWhenCommandMissingInResolvedMap() { let resolved = ResolvedKeybindingMap(bindingsByCommandID: [:]) -- 2.51.2 From 0ef15365f261c1e2a8982a08e6abf85c03fc9547 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 23:35:31 +0900 Subject: [PATCH 298/528] Keep sidebar navigation in canvas mode --- .../Canvas/Models/CanvasFocusRequest.swift | 41 ++++++ .../Features/Canvas/Views/CanvasView.swift | 69 +++++++++- .../Reducer/RepositoriesFeature.swift | 59 +++++++++ .../Repositories/Views/SidebarListView.swift | 7 +- .../Views/WorktreeDetailView.swift | 4 + .../Repositories/Views/WorktreeRowsView.swift | 4 +- supacodeTests/CanvasFocusResolverTests.swift | 86 ++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 123 ++++++++++++++++++ 8 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 supacode/Features/Canvas/Models/CanvasFocusRequest.swift create mode 100644 supacodeTests/CanvasFocusResolverTests.swift diff --git a/supacode/Features/Canvas/Models/CanvasFocusRequest.swift b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift new file mode 100644 index 00000000..ade498c1 --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift @@ -0,0 +1,41 @@ +import Foundation + +struct CanvasFocusRequest: Equatable, Sendable { + enum Target: Equatable, Sendable { + case worktree(Worktree.ID) + case tab(TerminalTabID) + } + + let id: Int + let target: Target +} + +struct CanvasFocusCandidate: Equatable, Sendable { + let worktreeID: Worktree.ID + let tabID: TerminalTabID +} + +enum CanvasFocusResolver { + static func resolve( + request: CanvasFocusRequest, + candidates: [CanvasFocusCandidate], + currentPrimaryTabID: TerminalTabID? + ) -> TerminalTabID? { + switch request.target { + case .tab(let tabID): + return candidates.contains { $0.tabID == tabID } ? tabID : nil + + case .worktree(let worktreeID): + let matchingTabIDs = candidates.compactMap { candidate in + candidate.worktreeID == worktreeID ? candidate.tabID : nil + } + guard !matchingTabIDs.isEmpty else { return nil } + guard let currentPrimaryTabID, + let currentIndex = matchingTabIDs.firstIndex(of: currentPrimaryTabID) + else { + return matchingTabIDs[0] + } + return matchingTabIDs[(currentIndex + 1) % matchingTabIDs.count] + } + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 6cc826d6..132cdc16 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -12,8 +12,10 @@ struct CanvasView: View { /// bar without subscribing to per-repo settings files on the /// per-frame canvas hot path. var repositoryCustomTitles: [Repository.ID: String] = [:] + var focusRequest: CanvasFocusRequest? var onExitToTab: () -> Void = {} var onFocusedWorktreeChanged: (Worktree.ID?) -> Void = { _ in } + var onFocusRequestConsumed: (Int) -> Void = { _ in } @State private var layoutStore = CanvasLayoutStore() @Shared(.repositoryAppearances) private var repositoryAppearances @@ -78,6 +80,7 @@ struct CanvasView: View { } pruneSelection(previousOrder: [], currentOrder: allTabIDs, states: activeStates) syncBroadcastCallbacks(states: activeStates) + fulfillPendingFocusRequest(focusRequest, states: activeStates) } .onChange(of: allCardKeys) { _, newKeys in if newKeys.isEmpty { @@ -93,9 +96,14 @@ struct CanvasView: View { layoutStore.ensureZOrder(for: newKeys) } syncBroadcastCallbacks(states: activeStates) + fulfillPendingFocusRequest(focusRequest, states: activeStates) } .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in pruneSelection(previousOrder: oldTabIDs, currentOrder: newTabIDs, states: activeStates) + fulfillPendingFocusRequest(focusRequest, states: activeStates) + } + .onChange(of: focusRequest) { _, newRequest in + fulfillPendingFocusRequest(newRequest, states: activeStates) } .contentShape(.rect) .accessibilityAddTraits(.isButton) @@ -231,7 +239,7 @@ struct CanvasView: View { if cmdHeld { handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) } else { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) + focusSingleCard(tab.id, states: activeStates) } }, onSelectionTap: { @@ -258,7 +266,7 @@ struct CanvasView: View { let wasAlreadyFocused = selectionState.primaryTabID == tab.id && selectionState.selectedTabIDs.count <= 1 - focusSingleCard(tab.id, surfaceState: state, states: activeStates) + focusSingleCard(tab.id, states: activeStates) let now = Date() if wasAlreadyFocused, now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval @@ -268,7 +276,7 @@ struct CanvasView: View { lastTitleBarTapDate = now }, onExpand: { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) + focusSingleCard(tab.id, states: activeStates) onExitToTab() }, onClose: { @@ -429,6 +437,16 @@ struct CanvasView: View { } } + private func collectFocusCandidates(from states: [WorktreeTerminalState]) -> [CanvasFocusCandidate] { + states.flatMap { state in + state.tabManager.tabs.compactMap { tab in + state.surfaceView(for: tab.id) != nil + ? CanvasFocusCandidate(worktreeID: state.worktreeID, tabID: tab.id) + : nil + } + } + } + /// Reset all card positions to a clean grid layout (uniform sizes). private func organizeCards() { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) @@ -695,7 +713,6 @@ struct CanvasView: View { private func focusSingleCard( _ tabID: TerminalTabID, - surfaceState _: WorktreeTerminalState, states: [WorktreeTerminalState] ) { layoutStore.moveToFront(tabID.rawValue.uuidString) @@ -704,6 +721,50 @@ struct CanvasView: View { } } + private func fulfillPendingFocusRequest( + _ request: CanvasFocusRequest?, + states: [WorktreeTerminalState] + ) { + guard let request else { return } + let tabID = CanvasFocusResolver.resolve( + request: request, + candidates: collectFocusCandidates(from: states), + currentPrimaryTabID: selectionState.primaryTabID + ) + guard let tabID else { return } + focusSingleCard(tabID, states: states) + focusViewport(on: tabID) + onFocusRequestConsumed(request.id) + } + + private func focusViewport(on tabID: TerminalTabID) { + guard viewportSize.width > 0, viewportSize.height > 0 else { return } + let cardKey = tabID.rawValue.uuidString + guard let layout = layoutStore.cardLayouts[cardKey] else { return } + + let horizontalPadding: CGFloat = 80 + let verticalPadding: CGFloat = 80 + bottomToolbarReserve + let availableWidth = max(1, viewportSize.width - horizontalPadding) + let availableHeight = max(1, viewportSize.height - verticalPadding) + let targetScale = max( + 0.25, + min( + 1.25, + min( + availableWidth / layout.size.width, + availableHeight / (layout.size.height + titleBarHeight) + ) + ) + ) + canvasScale = targetScale + canvasOffset = CGSize( + width: viewportSize.width / 2 - layout.position.x * targetScale, + height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale + ) + lastCanvasScale = targetScale + lastCanvasOffset = canvasOffset + } + private func handleSelectionShieldTap( _ tabID: TerminalTabID, surfaceState _: WorktreeTerminalState, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 8abd3b88..ed7641d6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -275,6 +275,8 @@ struct RepositoriesFeature { var isSidebarDragActive = false var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] var showActiveAgentTabTitles = false + var nextCanvasFocusRequestID = 0 + var pendingCanvasFocusRequest: CanvasFocusRequest? var activeAgents = ActiveAgentsFeature.State() @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @@ -347,6 +349,7 @@ struct RepositoriesFeature { case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) case selectNextWorktree case selectPreviousWorktree + case consumeCanvasFocusRequest(Int) case worktreeHistoryBack case worktreeHistoryForward case revealSelectedWorktreeInSidebar @@ -446,6 +449,12 @@ struct RepositoriesFeature { case .activeAgents(.entryTapped(let id)): guard let entry = state.activeAgents.entries[id: id] else { return .none } + if state.isShowingCanvas { + requestCanvasFocus(.tab(entry.tabID), openedWorktreeID: entry.worktreeID, state: &state) + return .run { _ in + _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) + } + } let isPlainFolder = state.repositories[id: entry.worktreeID]?.kind == .plain if isPlainFolder { @@ -960,6 +969,13 @@ struct RepositoriesFeature { let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") defer { repositoriesLogger.endInterval(selectRepoToken) } guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } + if state.isShowingCanvas { + guard let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) else { return .none } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + } recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .repository(repositoryID) state.sidebarSelectedWorktreeIDs = [] @@ -972,6 +988,13 @@ struct RepositoriesFeature { case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } + if state.isShowingCanvas { + guard let worktree = state.worktree(for: worktreeID) else { return .none } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + } setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: recordHistory) if focusTerminal, let worktreeID { state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) @@ -1003,6 +1026,12 @@ struct RepositoriesFeature { guard let id = state.worktreeID(byOffset: -1) else { return .none } return .send(.selectWorktree(id)) + case .consumeCanvasFocusRequest(let id): + if state.pendingCanvasFocusRequest?.id == id { + state.pendingCanvasFocusRequest = nil + } + return .none + case .worktreeHistoryBack: return navigateWorktreeHistory(direction: .backward, state: &state) @@ -1808,6 +1837,23 @@ extension RepositoriesFeature.State { return nil } + func canvasNavigationWorktree(forRepositoryID repositoryID: Repository.ID) -> Worktree? { + guard let repository = repositories[id: repositoryID] else { return nil } + if repository.capabilities.supportsWorktrees { + return worktreeRows(in: repository) + .compactMap { worktree(for: $0.id) } + .first + } + guard repository.capabilities.supportsRunnableFolderActions else { return nil } + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { guard let id else { return nil } return pendingWorktrees.first(where: { $0.id == id }) @@ -2132,6 +2178,19 @@ func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) state.pendingWorktrees.removeAll { $0.id == id } } +func requestCanvasFocus( + _ target: CanvasFocusRequest.Target, + openedWorktreeID: Worktree.ID, + state: inout RepositoriesFeature.State +) { + state.nextCanvasFocusRequestID += 1 + state.pendingCanvasFocusRequest = CanvasFocusRequest( + id: state.nextCanvasFocusRequestID, + target: target + ) + state.openedWorktreeIDs.insert(openedWorktreeID) +} + func updatePendingWorktreeProgress( _ id: String, progress: WorktreeCreationProgress, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 5c32d58f..4f8f5b91 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -448,10 +448,15 @@ struct SidebarListView: View { } } sidebarSelections = [] + if store.state.isShowingCanvas { + store.send(.selectRepository(repository.id)) + } } else { sidebarSelections = [.repository(repository.id)] store.send(.selectRepository(repository.id)) - focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) + if !store.state.isShowingCanvas { + focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) + } } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 6df8d36a..9db02d14 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -384,11 +384,15 @@ struct WorktreeDetailView: View { CanvasView( terminalManager: terminalManager, repositoryCustomTitles: repositories.repositoryCustomTitles, + focusRequest: repositories.pendingCanvasFocusRequest, onExitToTab: { store.send(.repositories(.toggleCanvas)) }, onFocusedWorktreeChanged: { worktreeID in store.send(.canvasFocusedWorktreeChanged(worktreeID)) + }, + onFocusRequestConsumed: { requestID in + store.send(.repositories(.consumeCanvasFocusRequest(requestID))) } ) // Canvas tints the nav (leading) only; the toolbar is left untinted so diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index e548b79d..6713b845 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -289,7 +289,9 @@ struct WorktreeRowsView: View { } store.send(.selectWorktree(worktreeID, focusTerminal: true)) - focusTerminalAfterSelection(worktreeID: worktreeID) + if !store.state.isShowingCanvas { + focusTerminalAfterSelection(worktreeID: worktreeID) + } } private func focusTerminalAfterSelection(worktreeID: Worktree.ID) { diff --git a/supacodeTests/CanvasFocusResolverTests.swift b/supacodeTests/CanvasFocusResolverTests.swift new file mode 100644 index 00000000..d9b2a8cb --- /dev/null +++ b/supacodeTests/CanvasFocusResolverTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import supacode + +struct CanvasFocusResolverTests { + @Test func worktreeTargetSelectsFirstCardWhenNothingIsFocused() { + let worktreeID = "/tmp/repo/main" + let first = TerminalTabID(rawValue: UUID()) + let second = TerminalTabID(rawValue: UUID()) + + let resolved = CanvasFocusResolver.resolve( + request: CanvasFocusRequest(id: 1, target: .worktree(worktreeID)), + candidates: [ + CanvasFocusCandidate(worktreeID: worktreeID, tabID: first), + CanvasFocusCandidate(worktreeID: worktreeID, tabID: second), + ], + currentPrimaryTabID: nil + ) + + #expect(resolved == first) + } + + @Test func worktreeTargetCyclesFromFocusedCardToNextMatch() { + let worktreeID = "/tmp/repo/main" + let first = TerminalTabID(rawValue: UUID()) + let second = TerminalTabID(rawValue: UUID()) + let other = TerminalTabID(rawValue: UUID()) + + let resolved = CanvasFocusResolver.resolve( + request: CanvasFocusRequest(id: 1, target: .worktree(worktreeID)), + candidates: [ + CanvasFocusCandidate(worktreeID: worktreeID, tabID: first), + CanvasFocusCandidate(worktreeID: "/tmp/other/main", tabID: other), + CanvasFocusCandidate(worktreeID: worktreeID, tabID: second), + ], + currentPrimaryTabID: first + ) + + #expect(resolved == second) + } + + @Test func worktreeTargetWrapsFromLastFocusedCardToFirstMatch() { + let worktreeID = "/tmp/repo/main" + let first = TerminalTabID(rawValue: UUID()) + let second = TerminalTabID(rawValue: UUID()) + + let resolved = CanvasFocusResolver.resolve( + request: CanvasFocusRequest(id: 1, target: .worktree(worktreeID)), + candidates: [ + CanvasFocusCandidate(worktreeID: worktreeID, tabID: first), + CanvasFocusCandidate(worktreeID: worktreeID, tabID: second), + ], + currentPrimaryTabID: second + ) + + #expect(resolved == first) + } + + @Test func tabTargetSelectsExactVisibleCard() { + let requested = TerminalTabID(rawValue: UUID()) + + let resolved = CanvasFocusResolver.resolve( + request: CanvasFocusRequest(id: 1, target: .tab(requested)), + candidates: [ + CanvasFocusCandidate(worktreeID: "/tmp/repo/main", tabID: TerminalTabID(rawValue: UUID())), + CanvasFocusCandidate(worktreeID: "/tmp/repo/main", tabID: requested), + ], + currentPrimaryTabID: nil + ) + + #expect(resolved == requested) + } + + @Test func tabTargetWaitsWhenExactCardIsNotVisibleYet() { + let resolved = CanvasFocusResolver.resolve( + request: CanvasFocusRequest(id: 1, target: .tab(TerminalTabID(rawValue: UUID()))), + candidates: [ + CanvasFocusCandidate(worktreeID: "/tmp/repo/main", tabID: TerminalTabID(rawValue: UUID())) + ], + currentPrimaryTabID: nil + ) + + #expect(resolved == nil) + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 964a3318..1e021e1d 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1119,6 +1119,129 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test func selectRepositoryInCanvasRequestsCanvasFocusWithoutLeavingCanvas() async { + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.selectRepository(repository.id)) { + $0.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(worktree.id) + ) + $0.openedWorktreeIDs = [worktree.id] + } + await store.finish() + + #expect(store.state.selection == .canvas) + #expect( + sentCommands.value == [ + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) + ] + ) + } + + @Test func selectPlainRepositoryInCanvasRequestsCanvasFocusWithoutLeavingCanvas() async { + let repository = makeRepository( + id: "/tmp/folder", + name: "folder", + kind: .plain, + worktrees: [] + ) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let expectedWorktree = Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.selectRepository(repository.id)) { + $0.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(repository.id) + ) + $0.openedWorktreeIDs = [repository.id] + } + await store.finish() + + #expect(store.state.selection == .canvas) + #expect( + sentCommands.value == [ + .ensureInitialTab(expectedWorktree, runSetupScriptIfNew: false, focusing: false) + ] + ) + } + + @Test func activeAgentEntryTappedInCanvasRequestsExactCardFocusWithoutLeavingCanvas() async { + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + state.selection = .canvas + let surfaceID = UUID() + let tabID = TerminalTabID(rawValue: UUID()) + let entry = ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + workingDirectory: nil, + tabID: tabID, + tabTitle: "agent", + surfaceID: surfaceID, + paneIndex: 0, + agent: .codex, + rawState: .working, + displayState: .working, + lastChangedAt: Date(timeIntervalSince1970: 0) + ) + state.activeAgents.entries = [entry] + + let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.focusSurface = { worktreeID, surface in + focusedSurface.setValue((worktreeID, surface)) + return true + } + } + + await store.send(.activeAgents(.entryTapped(entry.id))) { + $0.activeAgents.focusedSurfaceID = surfaceID + $0.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .tab(tabID) + ) + $0.openedWorktreeIDs = [worktree.id] + } + await store.finish() + + #expect(store.state.selection == .canvas) + #expect(focusedSurface.value?.0 == worktree.id) + #expect(focusedSurface.value?.1 == surfaceID) + } + @Test func selectPlainRepositorySendsPlainFolderTerminalTargetDelegate() async { let repository = makeRepository( id: "/tmp/folder", -- 2.51.2 From d4b96c351bcf22c06a4c8c1582c5dbbd29090e05 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 23:40:41 +0900 Subject: [PATCH 299/528] Animate canvas card focus --- supacode/Features/Canvas/Views/CanvasView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 132cdc16..22a5228c 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -756,13 +756,16 @@ struct CanvasView: View { ) ) ) - canvasScale = targetScale - canvasOffset = CGSize( + let targetOffset = CGSize( width: viewportSize.width / 2 - layout.position.x * targetScale, height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale ) - lastCanvasScale = targetScale - lastCanvasOffset = canvasOffset + withAnimation(.easeInOut(duration: 0.22)) { + canvasScale = targetScale + canvasOffset = targetOffset + lastCanvasScale = targetScale + lastCanvasOffset = targetOffset + } } private func handleSelectionShieldTap( -- 2.51.2 From 76684f05c50d99b27aa8587841e5ad163652fc21 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 5 Jun 2026 23:46:27 +0900 Subject: [PATCH 300/528] Drive canvas focus animation from view state --- supacode/Features/Canvas/Views/CanvasView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 22a5228c..cdd8fff2 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -31,6 +31,7 @@ struct CanvasView: View { @State private var viewportSize: CGSize = .zero @State private var showsCanvasHelp = false @State private var configReloadCounter = 0 + @State private var focusViewportAnimationID = 0 private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -114,6 +115,7 @@ struct CanvasView: View { } .contentShape(.rect) .simultaneousGesture(canvasZoomGesture) + .animation(.easeInOut(duration: 0.22), value: focusViewportAnimationID) .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in @@ -760,12 +762,11 @@ struct CanvasView: View { width: viewportSize.width / 2 - layout.position.x * targetScale, height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale ) - withAnimation(.easeInOut(duration: 0.22)) { - canvasScale = targetScale - canvasOffset = targetOffset - lastCanvasScale = targetScale - lastCanvasOffset = targetOffset - } + canvasScale = targetScale + canvasOffset = targetOffset + lastCanvasScale = targetScale + lastCanvasOffset = targetOffset + focusViewportAnimationID &+= 1 } private func handleSelectionShieldTap( -- 2.51.2 From de77fb91e5bcd2f82cb500fccc133c6b4bbb9595 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:03:38 +0900 Subject: [PATCH 301/528] Keep canvas focus navigation separate --- .../Reducer/RepositoriesFeature.swift | 38 +++++--- .../Repositories/Views/SidebarListView.swift | 8 +- .../Repositories/Views/WorktreeRowsView.swift | 6 +- supacodeTests/RepositoriesFeatureTests.swift | 97 ++++++++++++++++++- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ed7641d6..13ff2ce0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -347,6 +347,8 @@ struct RepositoriesFeature { case setSidebarSelectedWorktreeIDs(Set) case selectRepository(Repository.ID?) case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) + case focusCanvasRepository(Repository.ID) + case focusCanvasWorktree(Worktree.ID) case selectNextWorktree case selectPreviousWorktree case consumeCanvasFocusRequest(Int) @@ -969,13 +971,6 @@ struct RepositoriesFeature { let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") defer { repositoriesLogger.endInterval(selectRepoToken) } guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } - if state.isShowingCanvas { - guard let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) else { return .none } - requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) - return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) - } - } recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .repository(repositoryID) state.sidebarSelectedWorktreeIDs = [] @@ -988,13 +983,6 @@ struct RepositoriesFeature { case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } - if state.isShowingCanvas { - guard let worktree = state.worktree(for: worktreeID) else { return .none } - requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) - return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) - } - } setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: recordHistory) if focusTerminal, let worktreeID { state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) @@ -1005,6 +993,28 @@ struct RepositoriesFeature { let selectedWorktree = state.worktree(for: worktreeID) return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) + case .focusCanvasRepository(let repositoryID): + guard state.isShowingCanvas, + let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) + else { + return .none + } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + + case .focusCanvasWorktree(let worktreeID): + guard state.isShowingCanvas, + let worktree = state.worktree(for: worktreeID) + else { + return .none + } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + case .selectNextWorktree: // In Shelf, the vertical arrow pair maps to tab navigation // within the open book — horizontal (← / →) is already book diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 4f8f5b91..56253dbc 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -449,12 +449,14 @@ struct SidebarListView: View { } sidebarSelections = [] if store.state.isShowingCanvas { - store.send(.selectRepository(repository.id)) + store.send(.focusCanvasRepository(repository.id)) } } else { sidebarSelections = [.repository(repository.id)] - store.send(.selectRepository(repository.id)) - if !store.state.isShowingCanvas { + if store.state.isShowingCanvas { + store.send(.focusCanvasRepository(repository.id)) + } else { + store.send(.selectRepository(repository.id)) focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) } } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 6713b845..fda93b2a 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -288,8 +288,10 @@ struct WorktreeRowsView: View { return } - store.send(.selectWorktree(worktreeID, focusTerminal: true)) - if !store.state.isShowingCanvas { + if store.state.isShowingCanvas { + store.send(.focusCanvasWorktree(worktreeID)) + } else { + store.send(.selectWorktree(worktreeID, focusTerminal: true)) focusTerminalAfterSelection(worktreeID: worktreeID) } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 1e021e1d..f287452a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1119,7 +1119,7 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } - @Test func selectRepositoryInCanvasRequestsCanvasFocusWithoutLeavingCanvas() async { + @Test func focusCanvasRepositoryRequestsCanvasFocusWithoutLeavingCanvas() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) @@ -1134,7 +1134,8 @@ struct RepositoriesFeatureTests { } } - await store.send(.selectRepository(repository.id)) { + await store.send(.focusCanvasRepository(repository.id)) { + $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .worktree(worktree.id) @@ -1151,7 +1152,7 @@ struct RepositoriesFeatureTests { ) } - @Test func selectPlainRepositoryInCanvasRequestsCanvasFocusWithoutLeavingCanvas() async { + @Test func focusCanvasPlainRepositoryRequestsCanvasFocusWithoutLeavingCanvas() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", @@ -1177,7 +1178,8 @@ struct RepositoriesFeatureTests { } } - await store.send(.selectRepository(repository.id)) { + await store.send(.focusCanvasRepository(repository.id)) { + $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .worktree(repository.id) @@ -1194,6 +1196,39 @@ struct RepositoriesFeatureTests { ) } + @Test func focusCanvasWorktreeRequestsCanvasFocusWithoutLeavingCanvas() async { + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + + await store.send(.focusCanvasWorktree(worktree.id)) { + $0.nextCanvasFocusRequestID = 1 + $0.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(worktree.id) + ) + $0.openedWorktreeIDs = [worktree.id] + } + await store.finish() + + #expect(store.state.selection == .canvas) + #expect( + sentCommands.value == [ + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) + ] + ) + } + @Test func activeAgentEntryTappedInCanvasRequestsExactCardFocusWithoutLeavingCanvas() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -1229,6 +1264,7 @@ struct RepositoriesFeatureTests { await store.send(.activeAgents(.entryTapped(entry.id))) { $0.activeAgents.focusedSurfaceID = surfaceID + $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .tab(tabID) @@ -1324,6 +1360,59 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test func selectTabbedFromCanvasExitsCanvasInsteadOfFocusingCard() async { + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + } + + await store.send(.setTopSegment(.tabbed)) + await store.receive(\.selectTabbed) + await store.receive(\.toggleCanvas) + await store.receive(\.selectWorktree) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] + $0.openedWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + #expect(store.state.pendingCanvasFocusRequest == nil) + } + + @Test func selectShelfFromCanvasExitsCanvasInsteadOfFocusingCard() async { + let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var initialState = makeState(repositories: [repository]) + initialState.selection = .canvas + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } + } + + await store.send(.setTopSegment(.shelf)) + await store.receive(\.selectShelf) + await store.receive(\.toggleShelf) { + $0.isShelfActive = true + } + await store.receive(\.selectWorktree) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] + $0.openedWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + #expect(store.state.pendingCanvasFocusRequest == nil) + #expect(store.state.isShowingShelf) + } + @Test func selectCanvasSeedsInitialTabForSelectedWorktree() async { // Canvas only renders cards for worktrees with a live terminal surface. // Entering Canvas must seed the selected worktree's tab so launching -- 2.51.2 From c9f539b460b0f61f59f2515019cab4ef99e74230 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:14:07 +0900 Subject: [PATCH 302/528] Keep repository headers as expand controls --- .../Repositories/Views/SidebarListView.swift | 9 +++++---- .../RepositorySectionViewTests.swift | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 56253dbc..4251a542 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -448,12 +448,9 @@ struct SidebarListView: View { } } sidebarSelections = [] - if store.state.isShowingCanvas { - store.send(.focusCanvasRepository(repository.id)) - } } else { sidebarSelections = [.repository(repository.id)] - if store.state.isShowingCanvas { + if store.state.isShowingCanvas, Self.repositoryHeaderOpensCanvasTarget(repository) { store.send(.focusCanvasRepository(repository.id)) } else { store.send(.selectRepository(repository.id)) @@ -504,6 +501,10 @@ struct SidebarListView: View { return selectedWorktreeIDs } + static func repositoryHeaderOpensCanvasTarget(_ repository: Repository) -> Bool { + repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees + } + static func activeAgentWorktreeMetadata( repositories: IdentifiedArrayOf, customTitles: [Repository.ID: String], diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index 9057da76..b9c75d73 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -85,6 +85,26 @@ struct RepositorySectionViewTests { #expect(SidebarListView.selectedWorktreeIDs(in: state) == [worktree.id]) } + @Test func repositoryHeaderDoesNotOpenCanvasTargetForExpandableGitRepositories() { + let gitRepository = Repository( + id: "/tmp/git", + rootURL: URL(fileURLWithPath: "/tmp/git"), + name: "git", + kind: .git, + worktrees: [] + ) + let plainRepository = Repository( + id: "/tmp/plain", + rootURL: URL(fileURLWithPath: "/tmp/plain"), + name: "plain", + kind: .plain, + worktrees: [] + ) + + #expect(!SidebarListView.repositoryHeaderOpensCanvasTarget(gitRepository)) + #expect(SidebarListView.repositoryHeaderOpensCanvasTarget(plainRepository)) + } + @Test func activeAgentWorktreeMetadataUsesCustomRepositoryTitleAndCurrentBranchName() { let repositoryRootURL = URL(fileURLWithPath: "/tmp/prowl") let worktree = Worktree( -- 2.51.2 From c5ddb45af430c827b5da2dac44dedd951b04fa79 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:36:59 +0900 Subject: [PATCH 303/528] Focus canvas cards from command palette --- .../Features/App/Reducer/AppFeature.swift | 8 +++ .../AppFeatureCommandPaletteTests.swift | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 5bd7347a..797e1bd2 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1157,6 +1157,14 @@ struct AppFeature { return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) case .commandPalette(.delegate(.selectWorktree(let worktreeID))): + if state.repositories.isShowingCanvas { + if state.repositories.worktree(for: worktreeID) == nil, + state.repositories.repositories[id: worktreeID]?.kind == .plain + { + return .send(.repositories(.focusCanvasRepository(worktreeID))) + } + return .send(.repositories(.focusCanvasWorktree(worktreeID))) + } return .send(.repositories(.selectWorktree(worktreeID))) case .commandPalette(.delegate(.checkForUpdates)): diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index bf789fb4..3053fcf4 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -256,6 +256,67 @@ struct AppFeatureCommandPaletteTests { #expect(!sent.value.contains(.focusSelectedTab(worktree))) } + @Test(.dependencies) func selectingWorktreeInCanvasFocusesCanvasCard() async { + let worktree = makeWorktree( + id: "/tmp/repo-select-canvas/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-select-canvas" + ) + let repository = makeRepository(id: "/tmp/repo-select-canvas", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .canvas + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.selectWorktree(worktree.id)))) + await store.receive(\.repositories.focusCanvasWorktree) { + $0.repositories.nextCanvasFocusRequestID = 1 + $0.repositories.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(worktree.id) + ) + $0.repositories.openedWorktreeIDs = [worktree.id] + } + } + + @Test(.dependencies) func selectingPlainFolderInCanvasFocusesCanvasCard() async { + let repository = Repository( + id: "/tmp/folder-select-canvas", + rootURL: URL(fileURLWithPath: "/tmp/folder-select-canvas"), + name: "folder-select-canvas", + kind: .plain, + worktrees: [] + ) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .canvas + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.selectWorktree(repository.id)))) + await store.receive(\.repositories.focusCanvasRepository) { + $0.repositories.nextCanvasFocusRequestID = 1 + $0.repositories.pendingCanvasFocusRequest = CanvasFocusRequest( + id: 1, + target: .worktree(repository.id) + ) + $0.repositories.openedWorktreeIDs = [repository.id] + } + } + @Test(.dependencies) func openSettingsShowsWindow() async { let shown = LockIsolated(false) var state = AppFeature.State() -- 2.51.2 From ed741f67ca73f4524991176d5f77e8081fcf06ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:44:14 +0900 Subject: [PATCH 304/528] Clean up test warnings --- .../BatchedPullRequestRefreshReducerTests.swift | 10 +++++----- supacodeTests/RepositoriesFeatureTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 0a3e3918..175e030d 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -49,11 +49,11 @@ struct BatchedPullRequestRefreshReducerTests { let snapshot = enqueued.value #expect(snapshot.count == 1) - let request = try? #require(snapshot.first) - #expect(request?.host == "github.com") - #expect(request?.owner == "khoi") - #expect(request?.repo == "alpha") - #expect(request?.branches == ["main", "feature"]) + let request = snapshot[0] + #expect(request.host == "github.com") + #expect(request.owner == "khoi") + #expect(request.repo == "alpha") + #expect(request.branches == ["main", "feature"]) } @Test func refreshResolvesAndCachesRemoteInfoOnFirstRun() async { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index f287452a..21981b49 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2521,7 +2521,7 @@ struct RepositoriesFeatureTests { @Test(.dependencies) func requestDeleteProwlCreatedWorktreeCanPreselectBranchDeletion() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - var state = makeState(repositories: [repository]) + let state = makeState(repositories: [repository]) state.$prowlCreatedWorktreeIDs.withLock { $0 = [worktree.id] } @@ -2601,7 +2601,7 @@ struct RepositoriesFeatureTests { #expect(forceDeleteAttempts.value == [false, true]) } - @Test(.dependencies, .serialized) func worktreeDeletedPresentsQueuedForceDeleteBranchPromptsInOrder() async { + @Test(.dependencies) func worktreeDeletedPresentsQueuedForceDeleteBranchPromptsInOrder() async { let repoRoot = "/tmp/repo" let firstRequest = ForceDeleteBranchRequest( branchName: "first", -- 2.51.2 From 110d934fe21245ff725838540fad1df273fd7bc1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:54:35 +0900 Subject: [PATCH 305/528] bump v2026.6.6 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index dfaf6e29..cc0933ef 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260605; + CURRENT_PROJECT_VERSION = 20260606; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -489,7 +489,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.5; + MARKETING_VERSION = 2026.6.6; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -524,7 +524,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260605; + CURRENT_PROJECT_VERSION = 20260606; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -547,7 +547,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.5; + MARKETING_VERSION = 2026.6.6; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -577,12 +577,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260605; + CURRENT_PROJECT_VERSION = 20260606; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.5; + MARKETING_VERSION = 2026.6.6; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -599,12 +599,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260605; + CURRENT_PROJECT_VERSION = 20260606; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.5; + MARKETING_VERSION = 2026.6.6; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 5dcd9c25..be1f97fc 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.5" + public static let current = "2026.6.6" } -- 2.51.2 From f42d7d12894f2d8cddd5ad15afb0a18d8610dad4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 00:54:36 +0900 Subject: [PATCH 306/528] Update CHANGELOG for 2026.6.6 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251d5f1f..fbad63a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2026.6.6](https://github.com/onevcat/Prowl/releases/tag/v2026.6.6) + +This release focuses on Canvas mode: keyboard shortcuts, smoother focus navigation, and consistent behavior across sidebar, command palette, and new-tab actions. + +### New + +- Added keyboard shortcuts for Canvas layout actions: press `⌘⌥R` to rearrange cards preserving their sizes, or `⌘⌥G` to organize them into a uniform grid. Both shortcuts can be rebound in Settings → Shortcuts. + +### Improved + +- Clicking a repository, worktree, or Active Agents entry in the sidebar while in Canvas mode now focuses the matching card and centers it, rather than switching away from Canvas. +- Selecting a worktree or folder from the Command Palette in Canvas mode now focuses the corresponding card instead of leaving Canvas. +- Sidebar worktree shortcut hints (visible when holding Cmd) no longer cause layout reflow; they toggle with opacity so row heights remain stable. + +### Fixed + +- Opening a new tab or terminal in Canvas mode now targets the focused card's worktree, rather than falling back to an unrelated worktree. + ## [2026.6.5](https://github.com/onevcat/Prowl/releases/tag/v2026.6.5) This release focuses on CLI reliability for agent workflows and several startup and window-management fixes. -- 2.51.2 From 8de35d36d7813608d5d987f41fa601c978b52225 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 01:53:23 +0900 Subject: [PATCH 307/528] Enable background update downloads --- supacode/Clients/Updates/UpdaterClient.swift | 52 +++++++-- .../Features/App/Reducer/AppFeature.swift | 3 +- .../Views/ToolbarUpdateButton.swift | 13 ++- .../Views/WorktreeDetailView.swift | 23 +++- .../Settings/Views/UpdatesSettingsView.swift | 2 +- .../Updates/Reducer/UpdatesFeature.swift | 29 ++++- supacodeTests/UpdatesFeatureTests.swift | 106 ++++++++++++++++++ 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 supacodeTests/UpdatesFeatureTests.swift diff --git a/supacode/Clients/Updates/UpdaterClient.swift b/supacode/Clients/Updates/UpdaterClient.swift index ef8cf140..420e904e 100644 --- a/supacode/Clients/Updates/UpdaterClient.swift +++ b/supacode/Clients/Updates/UpdaterClient.swift @@ -4,26 +4,49 @@ import Sparkle private let updaterLogger = SupaLogger("Updater") struct UpdaterClient { - var configure: @MainActor @Sendable (_ checks: Bool, _ downloads: Bool, _ checkInBackground: Bool) -> Void + var configure: @MainActor @Sendable (_ checks: Bool, _ checkInBackground: Bool) -> Void var setUpdateChannel: @MainActor @Sendable (UpdateChannel) -> Void var checkForUpdates: @MainActor @Sendable () -> Void + var installDownloadedUpdate: @MainActor @Sendable () -> Void var events: @MainActor @Sendable () -> AsyncStream } extension UpdaterClient { enum Event: Equatable, Sendable { case silentUpdateFound(version: String?) + case downloadedUpdateReadyToInstall(version: String?) } } @MainActor final class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { var updateChannel: UpdateChannel = .stable + private var continuation: AsyncStream.Continuation? + private var immediateInstallHandler: (() -> Void)? nonisolated func allowedChannels(for updater: SPUUpdater) -> Set { // Tip channel is no longer published separately; treat it the same as stable. [] } + + func setContinuation(_ continuation: AsyncStream.Continuation) { + self.continuation?.finish() + self.continuation = continuation + } + + func installDownloadedUpdate() { + immediateInstallHandler?() + } + + func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock immediateInstallHandler: @escaping () -> Void + ) -> Bool { + self.immediateInstallHandler = immediateInstallHandler + continuation?.yield(.downloadedUpdateReadyToInstall(version: item.displayVersionString)) + return true + } } /// Custom Sparkle user driver that turns background "update found" prompts into a silent signal, @@ -32,12 +55,17 @@ final class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { final class SilentUpdateDriver: NSObject, SPUUserDriver { private let standard: SPUStandardUserDriver private var continuation: AsyncStream.Continuation? + private var automaticallyChecksForUpdates = GlobalSettings.default.updatesAutomaticallyCheckForUpdates init(hostBundle: Bundle) { self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() } + func setAutomaticUpdatePreferences(checks: Bool) { + automaticallyChecksForUpdates = checks + } + func setContinuation(_ continuation: AsyncStream.Continuation) { self.continuation?.finish() self.continuation = continuation @@ -51,7 +79,13 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { _ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void ) { - standard.show(request, reply: reply) + reply( + SUUpdatePermissionResponse( + automaticUpdateChecks: automaticallyChecksForUpdates, + automaticUpdateDownloading: nil, + sendSystemProfile: false + ) + ) } func showUserInitiatedUpdateCheck(cancellation: @escaping @Sendable () -> Void) { @@ -162,12 +196,9 @@ extension UpdaterClient: DependencyKey { updaterLogger.warning("SPUUpdater start failed: \(String(describing: error))") } return UpdaterClient( - configure: { checks, _, checkInBackground in + configure: { checks, checkInBackground in + driver.setAutomaticUpdatePreferences(checks: checks) updater.automaticallyChecksForUpdates = checks - // Silent update flow requires Sparkle to always prompt us via `showUpdateFound` - // so we can decide whether to surface the toolbar button. Auto-download would - // bypass that callback, so we force it off regardless of user preference. - updater.automaticallyDownloadsUpdates = false if checkInBackground, checks { updater.checkForUpdatesInBackground() } @@ -182,18 +213,23 @@ extension UpdaterClient: DependencyKey { checkForUpdates: { updater.checkForUpdates() }, + installDownloadedUpdate: { + delegate.installDownloadedUpdate() + }, events: { let (stream, continuation) = AsyncStream.makeStream(of: Event.self) driver.setContinuation(continuation) + delegate.setContinuation(continuation) return stream } ) }() static let testValue = UpdaterClient( - configure: { _, _, _ in }, + configure: { _, _ in }, setUpdateChannel: { _ in }, checkForUpdates: {}, + installDownloadedUpdate: {}, events: { AsyncStream { _ in } } ) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 797e1bd2..565b5976 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -673,8 +673,7 @@ struct AppFeature { .updates( .applySettings( updateChannel: settings.updateChannel, - automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates, - automaticallyDownloads: settings.updatesAutomaticallyDownloadUpdates + automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates ) ) ), diff --git a/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift b/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift index e9f34a16..6a10ac19 100644 --- a/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift +++ b/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift @@ -2,9 +2,16 @@ import SwiftUI struct ToolbarUpdateButton: View { let availableVersion: String? - let onCheckForUpdates: () -> Void + let isReadyToInstall: Bool + let onActivate: () -> Void private var tooltip: String { + if isReadyToInstall { + if let availableVersion, !availableVersion.isEmpty { + return "Version \(availableVersion) has been downloaded. Click to relaunch and install." + } + return "An update has been downloaded. Click to relaunch and install." + } if let availableVersion, !availableVersion.isEmpty { return "Version \(availableVersion) is available. Click to review and install." } @@ -13,13 +20,13 @@ struct ToolbarUpdateButton: View { var body: some View { Button { - onCheckForUpdates() + onActivate() } label: { Image(systemName: "arrow.down.circle.fill") .foregroundStyle(Color("ProwlAccent")) .accessibilityHidden(true) } .help(tooltip) - .accessibilityLabel("Install update") + .accessibilityLabel(isReadyToInstall ? "Relaunch to install update" : "Install update") } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 9db02d14..f19424b2 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -15,6 +15,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool let showDefaultEditorInToolbar: Bool @@ -27,6 +28,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool } @@ -93,6 +95,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, + isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, availableUpdateVersion: state.updates.availableVersion, showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar ) @@ -110,6 +113,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, + isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, availableUpdateVersion: state.updates.availableVersion, showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar, showDefaultEditorInToolbar: settingsFile.global.showDefaultEditorInToolbar @@ -174,7 +178,7 @@ struct WorktreeDetailView: View { onRunCustomCommand: { index in store.send(.runCustomCommand(index)) }, - onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + onActivateUpdateButton: { store.send(.updates(.activateUpdateButton)) } ) } @@ -190,8 +194,11 @@ struct WorktreeDetailView: View { onDismissAll: { dismissAllToolbarNotifications(in: state.notificationGroups) } ) if state.isUpdateAvailable { - ToolbarUpdateButton(availableVersion: state.availableUpdateVersion) { - store.send(.updates(.checkForUpdates)) + ToolbarUpdateButton( + availableVersion: state.availableUpdateVersion, + isReadyToInstall: state.isUpdateReadyToInstall + ) { + store.send(.updates(.activateUpdateButton)) } } } @@ -310,6 +317,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: input.runScriptIsRunning, customCommands: input.customCommands, isUpdateAvailable: input.isUpdateAvailable, + isUpdateReadyToInstall: input.isUpdateReadyToInstall, availableUpdateVersion: input.availableUpdateVersion, showRunButtonInToolbar: input.showRunButtonInToolbar, showDefaultEditorInToolbar: input.showDefaultEditorInToolbar @@ -708,6 +716,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool let showDefaultEditorInToolbar: Bool @@ -726,7 +735,7 @@ struct WorktreeDetailView: View { let onRunScript: () -> Void let onStopRunScript: () -> Void let onRunCustomCommand: (Int) -> Void - let onCheckForUpdates: () -> Void + let onActivateUpdateButton: () -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { @@ -758,7 +767,8 @@ struct WorktreeDetailView: View { if toolbarState.isUpdateAvailable { ToolbarUpdateButton( availableVersion: toolbarState.availableUpdateVersion, - onCheckForUpdates: onCheckForUpdates + isReadyToInstall: toolbarState.isUpdateReadyToInstall, + onActivate: onActivateUpdateButton ) } } @@ -1210,6 +1220,7 @@ private struct WorktreeToolbarPreview: View { ) ], isUpdateAvailable: true, + isUpdateReadyToInstall: false, availableUpdateVersion: "2026.5.1", showRunButtonInToolbar: true, showDefaultEditorInToolbar: true @@ -1238,7 +1249,7 @@ private struct WorktreeToolbarPreview: View { onRunScript: {}, onStopRunScript: {}, onRunCustomCommand: { _ in }, - onCheckForUpdates: {} + onActivateUpdateButton: {} ) } .environment(commandKeyObserver) diff --git a/supacode/Features/Settings/Views/UpdatesSettingsView.swift b/supacode/Features/Settings/Views/UpdatesSettingsView.swift index 662c5ba2..2f2cf096 100644 --- a/supacode/Features/Settings/Views/UpdatesSettingsView.swift +++ b/supacode/Features/Settings/Views/UpdatesSettingsView.swift @@ -24,7 +24,7 @@ struct UpdatesSettingsView: View { } footer: { Text( "When a new version is available, a small badge appears next to the notifications bell. " - + "Click it to review and install the update." + + "Click it to review, install, and choose future background downloads." ) .font(.callout) .foregroundStyle(.secondary) diff --git a/supacode/Features/Updates/Reducer/UpdatesFeature.swift b/supacode/Features/Updates/Reducer/UpdatesFeature.swift index 7833ced8..d2d27cd2 100644 --- a/supacode/Features/Updates/Reducer/UpdatesFeature.swift +++ b/supacode/Features/Updates/Reducer/UpdatesFeature.swift @@ -7,6 +7,7 @@ struct UpdatesFeature { struct State: Equatable { var didConfigureUpdates = false var isUpdateAvailable = false + var isUpdateReadyToInstall = false var availableVersion: String? } @@ -14,9 +15,9 @@ struct UpdatesFeature { case task case applySettings( updateChannel: UpdateChannel, - automaticallyChecks: Bool, - automaticallyDownloads: Bool + automaticallyChecks: Bool ) + case activateUpdateButton case checkForUpdates case updaterEvent(UpdaterClient.Event) #if DEBUG @@ -37,20 +38,32 @@ struct UpdatesFeature { } } - case .applySettings(let channel, let checks, let downloads): + case .applySettings(let channel, let checks): let checkInBackground = !state.didConfigureUpdates state.didConfigureUpdates = true return .run { _ in await updaterClient.setUpdateChannel(channel) - await updaterClient.configure(checks, downloads, checkInBackground) + await updaterClient.configure(checks, checkInBackground) } + case .activateUpdateButton: + if state.isUpdateReadyToInstall { + state.isUpdateAvailable = false + state.isUpdateReadyToInstall = false + state.availableVersion = nil + return .run { _ in + await updaterClient.installDownloadedUpdate() + } + } + return .send(.checkForUpdates) + case .checkForUpdates: analyticsClient.capture("update_checked", nil) // Clear the badge so a fresh user-initiated check drives the standard dialog. // If the update is still available, Sparkle re-triggers `showUpdateFound` and // the standard driver takes over. state.isUpdateAvailable = false + state.isUpdateReadyToInstall = false state.availableVersion = nil return .run { _ in await updaterClient.checkForUpdates() @@ -58,12 +71,20 @@ struct UpdatesFeature { case .updaterEvent(.silentUpdateFound(let version)): state.isUpdateAvailable = true + state.isUpdateReadyToInstall = false + state.availableVersion = version + return .none + + case .updaterEvent(.downloadedUpdateReadyToInstall(let version)): + state.isUpdateAvailable = true + state.isUpdateReadyToInstall = true state.availableVersion = version return .none #if DEBUG case .debugSimulateUpdateFound: state.isUpdateAvailable = true + state.isUpdateReadyToInstall = false state.availableVersion = "9999.1.1" return .none #endif diff --git a/supacodeTests/UpdatesFeatureTests.swift b/supacodeTests/UpdatesFeatureTests.swift new file mode 100644 index 00000000..85e443d9 --- /dev/null +++ b/supacodeTests/UpdatesFeatureTests.swift @@ -0,0 +1,106 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Testing + +@testable import supacode + +@MainActor +@Suite(.serialized) +struct UpdatesFeatureTests { + private struct Configuration: Equatable { + var channel: UpdateChannel + var checks: Bool + var checkInBackground: Bool + } + + @Test(.dependencies) func applySettingsConfiguresAutomaticChecks() async { + let configuredChannel = LockIsolated(nil) + let configuration = LockIsolated(nil) + let store = TestStore(initialState: UpdatesFeature.State()) { + UpdatesFeature() + } withDependencies: { + $0.updaterClient.setUpdateChannel = { channel in + configuredChannel.withValue { $0 = channel } + } + $0.updaterClient.configure = { checks, checkInBackground in + configuration.withValue { + $0 = Configuration( + channel: configuredChannel.value ?? .stable, + checks: checks, + checkInBackground: checkInBackground + ) + } + } + } + + await store.send( + .applySettings( + updateChannel: .stable, + automaticallyChecks: true + ) + ) { + $0.didConfigureUpdates = true + } + + #expect( + configuration.value == Configuration(channel: .stable, checks: true, checkInBackground: true)) + } + + @Test(.dependencies) func downloadedUpdateEventMarksUpdateReadyToInstall() async { + let store = TestStore(initialState: UpdatesFeature.State()) { + UpdatesFeature() + } + + await store.send(.updaterEvent(.downloadedUpdateReadyToInstall(version: "2026.6.6"))) { + $0.isUpdateAvailable = true + $0.isUpdateReadyToInstall = true + $0.availableVersion = "2026.6.6" + } + } + + @Test(.dependencies) func updateButtonInstallsDownloadedUpdateWhenReady() async { + let installCount = LockIsolated(0) + var state = UpdatesFeature.State() + state.isUpdateAvailable = true + state.isUpdateReadyToInstall = true + state.availableVersion = "2026.6.6" + let store = TestStore(initialState: state) { + UpdatesFeature() + } withDependencies: { + $0.updaterClient.installDownloadedUpdate = { + installCount.withValue { $0 += 1 } + } + } + + await store.send(.activateUpdateButton) { + $0.isUpdateAvailable = false + $0.isUpdateReadyToInstall = false + $0.availableVersion = nil + } + + #expect(installCount.value == 1) + } + + @Test(.dependencies) func updateButtonChecksForUpdatesWhenOnlyAvailable() async { + let checkCount = LockIsolated(0) + var state = UpdatesFeature.State() + state.isUpdateAvailable = true + state.availableVersion = "2026.6.6" + let store = TestStore(initialState: state) { + UpdatesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + $0.updaterClient.checkForUpdates = { + checkCount.withValue { $0 += 1 } + } + } + + await store.send(.activateUpdateButton) + await store.receive(\.checkForUpdates) { + $0.isUpdateAvailable = false + $0.availableVersion = nil + } + + #expect(checkCount.value == 1) + } +} -- 2.51.2 From 51d164ab703bd75d4eff79ebd958292809d66660 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 10:16:25 +0900 Subject: [PATCH 308/528] Avoid lazy sidebar layout spin --- supacode/Features/Repositories/Views/SidebarListView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 4251a542..be5a1ac0 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -90,7 +90,9 @@ struct SidebarListView: View { ScrollViewReader { scrollProxy in ScrollView { - LazyVStack(spacing: 0) { + // Avoid LazyVStack here: after collapsing and expanding large sections, + // SwiftUI's lazy placement cache can spin on the main thread while scrolling. + VStack(spacing: 0) { // When there are no repositories the sidebar stays empty — the // detail pane's `EmptyStateView` ("Open a repository or folder") // carries the prompt and the Add Repository button instead. -- 2.51.2 From 31bcb289b005e26c2ff04cca875274568bcaaa6f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 13:38:23 +0900 Subject: [PATCH 309/528] Preserve downloaded update install flow --- .../Updates/Reducer/UpdatesFeature.swift | 19 ++++++++---- supacodeTests/UpdatesFeatureTests.swift | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Updates/Reducer/UpdatesFeature.swift b/supacode/Features/Updates/Reducer/UpdatesFeature.swift index d2d27cd2..488fbb3d 100644 --- a/supacode/Features/Updates/Reducer/UpdatesFeature.swift +++ b/supacode/Features/Updates/Reducer/UpdatesFeature.swift @@ -48,16 +48,14 @@ struct UpdatesFeature { case .activateUpdateButton: if state.isUpdateReadyToInstall { - state.isUpdateAvailable = false - state.isUpdateReadyToInstall = false - state.availableVersion = nil - return .run { _ in - await updaterClient.installDownloadedUpdate() - } + return installDownloadedUpdate(&state) } return .send(.checkForUpdates) case .checkForUpdates: + if state.isUpdateReadyToInstall { + return installDownloadedUpdate(&state) + } analyticsClient.capture("update_checked", nil) // Clear the badge so a fresh user-initiated check drives the standard dialog. // If the update is still available, Sparkle re-triggers `showUpdateFound` and @@ -91,4 +89,13 @@ struct UpdatesFeature { } } } + + private func installDownloadedUpdate(_ state: inout State) -> Effect { + state.isUpdateAvailable = false + state.isUpdateReadyToInstall = false + state.availableVersion = nil + return .run { _ in + await updaterClient.installDownloadedUpdate() + } + } } diff --git a/supacodeTests/UpdatesFeatureTests.swift b/supacodeTests/UpdatesFeatureTests.swift index 85e443d9..c0fbbcd8 100644 --- a/supacodeTests/UpdatesFeatureTests.swift +++ b/supacodeTests/UpdatesFeatureTests.swift @@ -103,4 +103,33 @@ struct UpdatesFeatureTests { #expect(checkCount.value == 1) } + + @Test(.dependencies) func checkForUpdatesInstallsDownloadedUpdateWhenReady() async { + let checkCount = LockIsolated(0) + let installCount = LockIsolated(0) + var state = UpdatesFeature.State() + state.isUpdateAvailable = true + state.isUpdateReadyToInstall = true + state.availableVersion = "2026.6.6" + let store = TestStore(initialState: state) { + UpdatesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + $0.updaterClient.checkForUpdates = { + checkCount.withValue { $0 += 1 } + } + $0.updaterClient.installDownloadedUpdate = { + installCount.withValue { $0 += 1 } + } + } + + await store.send(.checkForUpdates) { + $0.isUpdateAvailable = false + $0.isUpdateReadyToInstall = false + $0.availableVersion = nil + } + + #expect(checkCount.value == 0) + #expect(installCount.value == 1) + } } -- 2.51.2 From 8024a025470ee7e4ae8b3ad79e90f4d2e9cf2619 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 13:54:00 +0900 Subject: [PATCH 310/528] Fix canvas terminal resize animation --- .../Canvas/Views/CanvasCardView.swift | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 17349daf..5e733d03 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -228,16 +228,15 @@ struct CanvasCardView: View { } private var terminalContent: some View { - TerminalSplitTreeView( + AnimatedTerminalSplitTreeView( tree: tree, - pinnedSize: cardSize, + size: cardSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, splitDivider: splitDivider, hasNotification: { _ in false }, action: onSplitOperation ) - .frame(width: cardSize.width, height: cardSize.height) .allowsHitTesting(isFocused && !showsSelectionShield) } @@ -370,6 +369,36 @@ struct CanvasCardView: View { } } +private struct AnimatedTerminalSplitTreeView: View, Animatable { + let tree: SplitTree + var size: CGSize + let activeSurfaceID: UUID? + let unfocusedSplitOverlay: (fill: Color?, opacity: Double) + var splitDivider: (color: Color?, width: CGFloat?) + let hasNotification: (UUID) -> Bool + let action: (TerminalSplitTreeView.Operation) -> Void + + var animatableData: AnimatablePair { + get { AnimatablePair(size.width, size.height) } + set { + size = CGSize(width: newValue.first, height: newValue.second) + } + } + + var body: some View { + TerminalSplitTreeView( + tree: tree, + pinnedSize: size, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: hasNotification, + action: action + ) + .frame(width: size.width, height: size.height) + } +} + private struct ResizeCursorView: View { let cursor: NSCursor @ViewBuilder let content: Content -- 2.51.2 From d9c6dd45f9d86e5f26a4ee46c9a4df3ba3afb423 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 14:36:17 +0900 Subject: [PATCH 311/528] Animate canvas terminal size refits --- supacode/Features/Canvas/Views/CanvasCardView.swift | 2 ++ supacode/Features/Canvas/Views/CanvasView.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 5e733d03..e36c2a6c 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -23,6 +23,7 @@ struct CanvasCardView: View { let isSelected: Bool let hasUnseenNotification: Bool let cardSize: CGSize + let animatesSizeChanges: Bool let canvasScale: CGFloat let showsSelectionShield: Bool let onTap: () -> Void @@ -237,6 +238,7 @@ struct CanvasCardView: View { hasNotification: { _ in false }, action: onSplitOperation ) + .animation(animatesSizeChanges ? .easeInOut(duration: 0.2) : nil, value: cardSize) .allowsHitTesting(isFocused && !showsSelectionShield) } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index cdd8fff2..ce48af08 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -234,6 +234,7 @@ struct CanvasView: View { isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: resized.size, + animatesSizeChanges: activeResize[tab.id] == nil, canvasScale: canvasScale, showsSelectionShield: showsSelectionShield(for: tab.id), onTap: { -- 2.51.2 From 6ecad15ef21fc47e2eac67a5cdeaf68f77615550 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 20:31:09 +0900 Subject: [PATCH 312/528] Scale canvas card default size to screen width Canvas cards used a fixed default size, which felt great on a 27" display but forced heavy zoom-out (and tiny text) when arranging a multi-card grid on a 14" MacBook Pro, since fitToView caps at 1.0. Interpolate the default card size linearly between a small-screen size (800x550 at 1512pt) and a large-screen size (1000x680 at 2560pt), clamped at both ends. New cards (ensureLayouts) and the uniform grid (organizeCards) now pick the size from the host screen width; gridPosition strides by that size. Add CanvasCardSizingTests covering the endpoints, clamping, midpoint interpolation, and monotonicity. --- .../Canvas/Models/CanvasCardLayout.swift | 29 +++++++++- .../Features/Canvas/Views/CanvasView.swift | 30 ++++++++--- supacodeTests/CanvasCardSizingTests.swift | 53 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 supacodeTests/CanvasCardSizingTests.swift diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index 51b7d00d..dfcac4e6 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -23,7 +23,34 @@ struct CanvasCardLayout: Codable, Equatable, Hashable, Sendable { } } - static let defaultSize = CGSize(width: 800, height: 550) + /// Card size used on small screens (≈14" MacBook Pro). On a small viewport + /// the canvas must zoom out to fit a multi-card grid, which shrinks rendered + /// text; smaller cards keep the fit-to-view scale — and thus text — larger. + static let minDefaultSize = CGSize(width: 800, height: 550) + /// Card size used on large screens (≈27" display and up), where fit-to-view + /// caps at 1.0 so a larger card simply shows more content at native size. + static let maxDefaultSize = CGSize(width: 1000, height: 680) + /// Reference screen widths (logical points, default scaling) mapped to the + /// interpolation endpoints above. + static let minDefaultScreenWidth: CGFloat = 1512 // 14" MacBook Pro + static let maxDefaultScreenWidth: CGFloat = 2560 // 27" / Studio Display + + /// Size new cards adopt when no explicit size is given. Equal to + /// `maxDefaultSize`, used only for transient fallbacks; creation paths pass + /// an explicit `adaptiveDefaultSize(forScreenWidth:)` instead. + static let defaultSize = maxDefaultSize + + /// Linearly interpolate the default card size between `minDefaultSize` + /// (14"-class screens) and `maxDefaultSize` (27"-class and larger) by screen + /// width, clamped at both ends. + static func adaptiveDefaultSize(forScreenWidth screenWidth: CGFloat) -> CGSize { + let span = maxDefaultScreenWidth - minDefaultScreenWidth + let fraction = span > 0 ? min(max((screenWidth - minDefaultScreenWidth) / span, 0), 1) : 1 + return CGSize( + width: minDefaultSize.width + (maxDefaultSize.width - minDefaultSize.width) * fraction, + height: minDefaultSize.height + (maxDefaultSize.height - minDefaultSize.height) * fraction + ) + } init(position: CGPoint, size: CGSize = Self.defaultSize) { self.positionX = position.x diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index ce48af08..4c4c2210 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -44,6 +44,20 @@ struct CanvasView: View { /// Cards end up shifted upward by half of this amount. private let bottomToolbarReserve: CGFloat = 50 + /// Width of the screen hosting the canvas window, used to scale the default + /// card size. Falls back to the large-screen reference when unknown. + private var hostScreenWidth: CGFloat { + (NSApp.keyWindow?.screen ?? NSScreen.main)?.frame.width + ?? CanvasCardLayout.maxDefaultScreenWidth + } + + /// Default size for newly created and uniformly arranged cards, scaled to the + /// host screen so small screens (14") don't zoom out into tiny text while + /// large screens still get the roomier card. + private var adaptiveDefaultCardSize: CGSize { + CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: hostScreenWidth) + } + var body: some View { let selectAllCanvasShortcut = AppShortcuts.resolvedShortcut( for: AppShortcuts.CommandID.selectAllCanvasCards, @@ -353,10 +367,12 @@ struct CanvasView: View { : gridColumns(for: cardKeys.count) // Build locally, assign once to trigger a single save. + let cardSize = adaptiveDefaultCardSize var layouts = layoutStore.cardLayouts for (offset, key) in unpositioned.enumerated() { layouts[key] = CanvasCardLayout( - position: gridPosition(index: positionedCount + offset, columns: columns) + position: gridPosition(index: positionedCount + offset, columns: columns, cardSize: cardSize), + size: cardSize ) } layoutStore.setCardLayouts(layouts) @@ -368,9 +384,9 @@ struct CanvasView: View { max(1, Int(ceil(sqrt(Double(count))))) } - private func gridPosition(index: Int, columns: Int) -> CGPoint { - let cardW = CanvasCardLayout.defaultSize.width - let cardH = CanvasCardLayout.defaultSize.height + titleBarHeight + private func gridPosition(index: Int, columns: Int, cardSize: CGSize) -> CGPoint { + let cardW = cardSize.width + let cardH = cardSize.height + titleBarHeight let row = index / columns let col = index % columns return CGPoint( @@ -454,10 +470,12 @@ struct CanvasView: View { private func organizeCards() { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) let columns = gridColumns(for: keys.count) + let cardSize = adaptiveDefaultCardSize var layouts = layoutStore.cardLayouts for (index, key) in keys.enumerated() { layouts[key] = CanvasCardLayout( - position: gridPosition(index: index, columns: columns) + position: gridPosition(index: index, columns: columns, cardSize: cardSize), + size: cardSize ) } layoutStore.setCardLayouts(layouts, zOrder: keys) @@ -471,7 +489,7 @@ struct CanvasView: View { guard !keys.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } let cards: [CanvasCardPacker.CardInfo] = keys.map { key in - let size = layoutStore.cardLayouts[key]?.size ?? CanvasCardLayout.defaultSize + let size = layoutStore.cardLayouts[key]?.size ?? adaptiveDefaultCardSize return CanvasCardPacker.CardInfo(key: key, size: size) } diff --git a/supacodeTests/CanvasCardSizingTests.swift b/supacodeTests/CanvasCardSizingTests.swift new file mode 100644 index 00000000..fa965d8a --- /dev/null +++ b/supacodeTests/CanvasCardSizingTests.swift @@ -0,0 +1,53 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import supacode + +struct CanvasCardSizingTests { + @Test func smallScreenClampsToMinSize() { + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 1280) + #expect(size == CanvasCardLayout.minDefaultSize) + } + + @Test func atMinReferenceWidthReturnsMinSize() { + let size = CanvasCardLayout.adaptiveDefaultSize( + forScreenWidth: CanvasCardLayout.minDefaultScreenWidth + ) + #expect(size == CanvasCardLayout.minDefaultSize) + } + + @Test func largeScreenClampsToMaxSize() { + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 3840) + #expect(size == CanvasCardLayout.maxDefaultSize) + } + + @Test func atMaxReferenceWidthReturnsMaxSize() { + let size = CanvasCardLayout.adaptiveDefaultSize( + forScreenWidth: CanvasCardLayout.maxDefaultScreenWidth + ) + #expect(size == CanvasCardLayout.maxDefaultSize) + } + + @Test func midpointInterpolatesLinearly() { + let midWidth = + (CanvasCardLayout.minDefaultScreenWidth + CanvasCardLayout.maxDefaultScreenWidth) / 2 + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: midWidth) + let expectedWidth = + (CanvasCardLayout.minDefaultSize.width + CanvasCardLayout.maxDefaultSize.width) / 2 + let expectedHeight = + (CanvasCardLayout.minDefaultSize.height + CanvasCardLayout.maxDefaultSize.height) / 2 + #expect(abs(size.width - expectedWidth) < 0.001) + #expect(abs(size.height - expectedHeight) < 0.001) + } + + @Test func sizeGrowsMonotonicallyWithScreenWidth() { + let small = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 1600) + let medium = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 2000) + let large = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 2400) + #expect(small.width < medium.width) + #expect(medium.width < large.width) + #expect(small.height < medium.height) + #expect(medium.height < large.height) + } +} -- 2.51.2 From 9592414cfa1f079f6e73fd1452e9db222376b1eb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 21:14:25 +0900 Subject: [PATCH 313/528] Replace canvas "expand to tab" with expand-in-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card title-bar expand button used to leave Canvas and switch back to Normal view — a stopgap. Replace it with an in-place expansion that stays on Canvas: - Expand raises the card to the top, snaps canvas scale to 1 (so the terminal renders at the same font size as Normal/Shelf), and grows the card to a near-fullscreen size centered in the viewport with a padding margin. Other cards keep running behind a dimming scrim. - The button toggles to Restore; clicking it, or tapping the scrim / padding area, returns the card to its prior size and the canvas to its prior scale/offset. Double-clicking the title bar also toggles expand/restore. - Triggering Arrange/Organize while expanded cancels expand and lays out with that mode. Scale/offset animate with the same easeInOut 0.2s that CanvasCardView already uses for `cardSize`, so the terminal size refit stays in lock-step. While expanded, the card's resize handles and title-bar drag are disabled. Geometry is factored into CanvasExpandGeometry (unit-tested). The now unused onExitToTab hook is removed; Canvas exit remains available via the toggle-canvas shortcut and command palette. --- .../Canvas/Models/CanvasExpandGeometry.swift | 47 ++++++ .../Canvas/Views/CanvasCardView.swift | 29 ++-- .../Features/Canvas/Views/CanvasView.swift | 140 +++++++++++++++++- .../Views/WorktreeDetailView.swift | 3 - supacodeTests/CanvasExpandGeometryTests.swift | 50 +++++++ 5 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 supacode/Features/Canvas/Models/CanvasExpandGeometry.swift create mode 100644 supacodeTests/CanvasExpandGeometryTests.swift diff --git a/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift b/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift new file mode 100644 index 00000000..3540c7af --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift @@ -0,0 +1,47 @@ +import CoreGraphics + +/// Geometry for the canvas "expand-in-place" interaction: a card is temporarily +/// blown up to a near-fullscreen size at canvas scale 1 (so its terminal renders +/// at the same font size as Normal/Shelf mode), centered in the viewport with a +/// padding margin and the bottom toolbar reserve avoided. +enum CanvasExpandGeometry { + /// Layout metrics for an expanded card. + struct Metrics { + /// Margin kept on every side of the expanded card. + var padding: CGFloat + /// Extra height reserved at the bottom for the help/layout toolbar (matches + /// `fitToView`'s vertical centering). + var bottomReserve: CGFloat + /// Height of the card title bar, added on top of the content height. + var titleBarHeight: CGFloat + /// Lower bound for the content size on tiny viewports. + var minSize: CGSize + } + + /// Compute the expanded card's content size (excluding the title bar) and the + /// canvas offset that centers it, assuming canvas scale is fixed at 1. + /// + /// - Parameters: + /// - viewport: The canvas viewport size. + /// - cardCenter: The card's center in canvas coordinates (its stored layout + /// position, unchanged by expand). + /// - metrics: Padding / reserve / title-bar / min-size metrics. + static func expandedFrame( + viewport: CGSize, + cardCenter: CGPoint, + metrics: Metrics + ) -> (size: CGSize, offset: CGSize) { + let width = max(metrics.minSize.width, viewport.width - metrics.padding * 2) + let totalHeight = viewport.height - metrics.padding * 2 - metrics.bottomReserve + let height = max(metrics.minSize.height, totalHeight - metrics.titleBarHeight) + + // Scale is 1, so screen position == cardCenter + offset. Center the card + // horizontally and vertically within the toolbar-adjusted viewport. + let offset = CGSize( + width: viewport.width / 2 - cardCenter.x, + height: (viewport.height - metrics.bottomReserve) / 2 - cardCenter.y + ) + + return (CGSize(width: width, height: height), offset) + } +} diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index e36c2a6c..82912252 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -24,6 +24,10 @@ struct CanvasCardView: View { let hasUnseenNotification: Bool let cardSize: CGSize let animatesSizeChanges: Bool + /// Whether this card is currently expanded in place (near-fullscreen). When + /// true the title-bar button restores instead of expands, resize handles and + /// title-bar dragging are disabled, and the action buttons stay visible. + let isExpanded: Bool let canvasScale: CGFloat let showsSelectionShield: Bool let onTap: () -> Void @@ -75,7 +79,7 @@ struct CanvasCardView: View { ZStack { RoundedRectangle(cornerRadius: cornerRadius) .stroke(borderColor, lineWidth: borderLineWidth) - if !showsSelectionShield { + if !showsSelectionShield && !isExpanded { resizeHandles } if showsSelectionShield { @@ -162,7 +166,8 @@ struct CanvasCardView: View { width: value.translation.width / canvasScale, height: value.translation.height / canvasScale )) - } + }, + isEnabled: !isExpanded ) } @@ -171,15 +176,19 @@ struct CanvasCardView: View { Button { onExpand() } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.caption2.weight(.semibold)) - .frame(width: 18, height: 18) - .contentShape(.rect) + Image( + systemName: isExpanded + ? "arrow.down.right.and.arrow.up.left" + : "arrow.up.left.and.arrow.down.right" + ) + .font(.caption2.weight(.semibold)) + .frame(width: 18, height: 18) + .contentShape(.rect) } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Expand to tab view") - .accessibilityLabel("Expand card") + .help(isExpanded ? "Restore card size" : "Expand card") + .accessibilityLabel(isExpanded ? "Restore card size" : "Expand card") Button { onClose() @@ -194,8 +203,8 @@ struct CanvasCardView: View { .help("Close card") .accessibilityLabel("Close card") } - .opacity(isHoveringTitleBar ? 1 : 0) - .allowsHitTesting(isHoveringTitleBar) + .opacity(isExpanded || isHoveringTitleBar ? 1 : 0) + .allowsHitTesting(isExpanded || isHoveringTitleBar) .animation(.easeInOut(duration: 0.15), value: isHoveringTitleBar) } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 4c4c2210..e9ccf7e9 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -13,7 +13,6 @@ struct CanvasView: View { /// per-frame canvas hot path. var repositoryCustomTitles: [Repository.ID: String] = [:] var focusRequest: CanvasFocusRequest? - var onExitToTab: () -> Void = {} var onFocusedWorktreeChanged: (Worktree.ID?) -> Void = { _ in } var onFocusRequestConsumed: (Int) -> Void = { _ in } @State private var layoutStore = CanvasLayoutStore() @@ -32,6 +31,12 @@ struct CanvasView: View { @State private var showsCanvasHelp = false @State private var configReloadCounter = 0 @State private var focusViewportAnimationID = 0 + /// The tab currently expanded in place (near-fullscreen overlay) on canvas, + /// or nil when no card is expanded. + @State private var expandedTabID: TerminalTabID? + /// Canvas scale/offset captured when entering expand, restored on collapse. + @State private var preExpandScale: CGFloat? + @State private var preExpandOffset: CGSize? private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -43,6 +48,12 @@ struct CanvasView: View { /// layout toolbar so cards don't sit underneath them after auto-fit. /// Cards end up shifted upward by half of this amount. private let bottomToolbarReserve: CGFloat = 50 + /// Margin kept on every side of a card temporarily expanded to near-fullscreen. + private let expandPadding: CGFloat = 40 + /// Shared animation for expand / restore / relayout. Matches the easeInOut + /// 0.2s that `CanvasCardView` uses to animate `cardSize`, so the canvas + /// scale/offset stays in lock-step with the card's terminal size refit. + private let expandAnimation: Animation = .easeInOut(duration: 0.2) /// Width of the screen hosting the canvas window, used to scale the default /// card size. Falls back to the large-screen reference when unknown. @@ -115,6 +126,9 @@ struct CanvasView: View { } .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in pruneSelection(previousOrder: oldTabIDs, currentOrder: newTabIDs, states: activeStates) + if let expandedTabID, !newTabIDs.contains(expandedTabID) { + collapseExpand() + } fulfillPendingFocusRequest(focusRequest, states: activeStates) } .onChange(of: focusRequest) { _, newRequest in @@ -208,12 +222,28 @@ struct CanvasView: View { /// reaching the NSView, keeping terminal grid stable during zoom. @ViewBuilder private func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { - ForEach(activeStates, id: \.worktreeID) { state in - ForEach(state.tabManager.tabs) { tab in - if state.surfaceView(for: tab.id) != nil { - cardView(for: tab, in: state, activeStates: activeStates) + ZStack { + ForEach(activeStates, id: \.worktreeID) { state in + ForEach(state.tabManager.tabs) { tab in + if state.surfaceView(for: tab.id) != nil { + cardView(for: tab, in: state, activeStates: activeStates) + } } } + + // Dimming scrim behind the expanded card (above all other cards). Tapping + // it — i.e. anywhere outside the expanded card, including the padding — + // restores the layout. + if expandedTabID != nil { + Color.black.opacity(0.3) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(.rect) + .accessibilityAddTraits(.isButton) + .accessibilityLabel("Restore expanded card") + .onTapGesture { collapseExpand() } + .zIndex(5_000) + .transition(.opacity) + } } } @@ -249,6 +279,7 @@ struct CanvasView: View { hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: resized.size, animatesSizeChanges: activeResize[tab.id] == nil, + isExpanded: expandedTabID == tab.id, canvasScale: canvasScale, showsSelectionShield: showsSelectionShield(for: tab.id), onTap: { @@ -288,13 +319,12 @@ struct CanvasView: View { if wasAlreadyFocused, now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval { - onExitToTab() + toggleExpand(tab.id, states: activeStates) } lastTitleBarTapDate = now }, onExpand: { - focusSingleCard(tab.id, states: activeStates) - onExitToTab() + toggleExpand(tab.id, states: activeStates) }, onClose: { state.closeTab(tab.id) @@ -401,6 +431,12 @@ struct CanvasView: View { for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> (center: CGPoint, size: CGSize) { + // An expanded card renders at its near-fullscreen size, centered via the + // canvas offset; resize is disabled while expanded. + if expandedTabID == tabID, let frame = expandLayout(for: tabID) { + return (baseLayout.position, frame.size) + } + var centerX = baseLayout.position.x var centerY = baseLayout.position.y var width = baseLayout.size.width @@ -505,6 +541,7 @@ struct CanvasView: View { /// Shared by the toolbar button and the keyboard shortcut. private func arrangeCardsWithFit() { withAnimation(.easeInOut(duration: 0.2)) { + cancelExpandForRelayout() arrangeCards() fitToView(canvasSize: viewportSize) } @@ -514,6 +551,7 @@ struct CanvasView: View { /// Shared by the toolbar button and the keyboard shortcut. private func organizeCardsWithFit() { withAnimation(.easeInOut(duration: 0.2)) { + cancelExpandForRelayout() organizeCards() fitToView(canvasSize: viewportSize) } @@ -742,6 +780,89 @@ struct CanvasView: View { } } + // MARK: - Expand In Place + + /// Expanded size + centering offset for a card, or nil when the viewport or + /// the card's stored layout isn't available yet. + private var expandMetrics: CanvasExpandGeometry.Metrics { + CanvasExpandGeometry.Metrics( + padding: expandPadding, + bottomReserve: bottomToolbarReserve, + titleBarHeight: titleBarHeight, + minSize: CGSize(width: minCardWidth, height: minCardHeight) + ) + } + + private func expandLayout(for tabID: TerminalTabID) -> (size: CGSize, offset: CGSize)? { + guard viewportSize.width > 0, viewportSize.height > 0, + let layout = layoutStore.cardLayouts[tabID.rawValue.uuidString] + else { return nil } + return CanvasExpandGeometry.expandedFrame( + viewport: viewportSize, + cardCenter: layout.position, + metrics: expandMetrics + ) + } + + /// Toggle expand/restore for a card — used by the title-bar button and the + /// title-bar double-click. + private func toggleExpand(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { + if expandedTabID == tabID { + collapseExpand() + } else { + expandCard(tabID, states: states) + } + } + + /// Expand a card in place: capture the current canvas transform, raise the + /// card to the top, snap scale to 1 (native font size), and grow it to + /// near-fullscreen centered in the viewport. Other cards keep running behind + /// a dimming scrim. + private func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { + guard let frame = expandLayout(for: tabID) else { return } + if expandedTabID == nil { + preExpandScale = canvasScale + preExpandOffset = canvasOffset + } + focusSingleCard(tabID, states: states) + withAnimation(expandAnimation) { + expandedTabID = tabID + canvasScale = 1 + canvasOffset = frame.offset + lastCanvasScale = 1 + lastCanvasOffset = frame.offset + } + } + + /// Restore the expanded card to its prior size and the canvas to its prior + /// scale/offset. + private func collapseExpand() { + guard expandedTabID != nil else { return } + let restoreScale = preExpandScale + let restoreOffset = preExpandOffset + withAnimation(expandAnimation) { + expandedTabID = nil + if let restoreScale { + canvasScale = restoreScale + lastCanvasScale = restoreScale + } + if let restoreOffset { + canvasOffset = restoreOffset + lastCanvasOffset = restoreOffset + } + } + preExpandScale = nil + preExpandOffset = nil + } + + /// Drop expand state without restoring the transform — used right before a + /// relayout (Arrange/Organize) takes over the canvas. + private func cancelExpandForRelayout() { + expandedTabID = nil + preExpandScale = nil + preExpandOffset = nil + } + private func fulfillPendingFocusRequest( _ request: CanvasFocusRequest?, states: [WorktreeTerminalState] @@ -946,6 +1067,9 @@ struct CanvasView: View { } private func deactivateCanvas() { + expandedTabID = nil + preExpandScale = nil + preExpandOffset = nil let activeStates = terminalManager.activeWorktreeStates for state in activeStates { state.isCanvasManaged = false diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f19424b2..34a815dc 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -393,9 +393,6 @@ struct WorktreeDetailView: View { terminalManager: terminalManager, repositoryCustomTitles: repositories.repositoryCustomTitles, focusRequest: repositories.pendingCanvasFocusRequest, - onExitToTab: { - store.send(.repositories(.toggleCanvas)) - }, onFocusedWorktreeChanged: { worktreeID in store.send(.canvasFocusedWorktreeChanged(worktreeID)) }, diff --git a/supacodeTests/CanvasExpandGeometryTests.swift b/supacodeTests/CanvasExpandGeometryTests.swift new file mode 100644 index 00000000..61a63aab --- /dev/null +++ b/supacodeTests/CanvasExpandGeometryTests.swift @@ -0,0 +1,50 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import supacode + +struct CanvasExpandGeometryTests { + private let viewport = CGSize(width: 2000, height: 1400) + private let center = CGPoint(x: 500, y: 300) + private let metrics = CanvasExpandGeometry.Metrics( + padding: 40, + bottomReserve: 50, + titleBarHeight: 28, + minSize: CGSize(width: 300, height: 200) + ) + + @Test func sizeFillsViewportMinusPaddingAndBottomReserve() { + let result = CanvasExpandGeometry.expandedFrame( + viewport: viewport, + cardCenter: center, + metrics: metrics + ) + let expectedWidth: CGFloat = 2000 - 40 * 2 + let expectedHeight: CGFloat = 1400 - 40 * 2 - 50 - 28 + #expect(result.size.width == expectedWidth) + #expect(result.size.height == expectedHeight) + } + + @Test func offsetCentersCardAtScaleOne() { + let result = CanvasExpandGeometry.expandedFrame( + viewport: viewport, + cardCenter: center, + metrics: metrics + ) + // At scale 1, the card's screen center is cardCenter + offset; it should + // land at the horizontal middle and the toolbar-adjusted vertical middle. + #expect(center.x + result.offset.width == viewport.width / 2) + #expect(center.y + result.offset.height == (viewport.height - 50) / 2) + } + + @Test func clampsToMinSizeOnTinyViewport() { + let result = CanvasExpandGeometry.expandedFrame( + viewport: CGSize(width: 200, height: 150), + cardCenter: .zero, + metrics: metrics + ) + #expect(result.size.width == metrics.minSize.width) + #expect(result.size.height == metrics.minSize.height) + } +} -- 2.51.2 From a357114c4c15d5e5c31f716713904ee4f65abd0a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 21:50:10 +0900 Subject: [PATCH 314/528] Expand a card on its own, freezing the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass expanded by mutating the global canvas scale/offset, so every other card scaled and moved along with the expanding one. Switch to an isolated transform for just the expanded card: - Leave canvasScale/canvasOffset untouched on expand/restore, so all other cards stay exactly where they were (frozen background). - The expanded card applies its own scale (canvasScale -> 1, so its terminal font matches Normal/Shelf) and its own screen-space center (viewport center), independent of the canvas pan/zoom, animating from its in-canvas position/size to cover the whole viewport with padding. Because offset, size, and scale share one easeInOut 0.2s curve, the card's center stays fixed (lerp(c) - lerp(s)/2 + lerp(s)/2 = lerp(c)) and the screen size grows smoothly to fullscreen — it reads as the card floating up onto an overlay above the static canvas. Drops the now-needless preExpand scale/offset snapshot and the offset geometry; CanvasExpandGeometry now only computes the expanded size. --- .../Canvas/Models/CanvasExpandGeometry.swift | 37 +++----- .../Features/Canvas/Views/CanvasView.swift | 90 ++++++++----------- supacodeTests/CanvasExpandGeometryTests.swift | 30 ++----- 3 files changed, 53 insertions(+), 104 deletions(-) diff --git a/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift b/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift index 3540c7af..fad61c5e 100644 --- a/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift +++ b/supacode/Features/Canvas/Models/CanvasExpandGeometry.swift @@ -1,16 +1,16 @@ import CoreGraphics /// Geometry for the canvas "expand-in-place" interaction: a card is temporarily -/// blown up to a near-fullscreen size at canvas scale 1 (so its terminal renders -/// at the same font size as Normal/Shelf mode), centered in the viewport with a -/// padding margin and the bottom toolbar reserve avoided. +/// blown up to a near-fullscreen size at scale 1 (so its terminal renders at the +/// same font size as Normal/Shelf mode), covering the viewport with a padding +/// margin and avoiding the bottom toolbar reserve. The expanded card transforms +/// on its own; the canvas pan/zoom is left untouched so the background is frozen. enum CanvasExpandGeometry { /// Layout metrics for an expanded card. struct Metrics { /// Margin kept on every side of the expanded card. var padding: CGFloat - /// Extra height reserved at the bottom for the help/layout toolbar (matches - /// `fitToView`'s vertical centering). + /// Extra height reserved at the bottom for the help/layout toolbar. var bottomReserve: CGFloat /// Height of the card title bar, added on top of the content height. var titleBarHeight: CGFloat @@ -18,30 +18,13 @@ enum CanvasExpandGeometry { var minSize: CGSize } - /// Compute the expanded card's content size (excluding the title bar) and the - /// canvas offset that centers it, assuming canvas scale is fixed at 1. - /// - /// - Parameters: - /// - viewport: The canvas viewport size. - /// - cardCenter: The card's center in canvas coordinates (its stored layout - /// position, unchanged by expand). - /// - metrics: Padding / reserve / title-bar / min-size metrics. - static func expandedFrame( - viewport: CGSize, - cardCenter: CGPoint, - metrics: Metrics - ) -> (size: CGSize, offset: CGSize) { + /// The expanded card's content size (excluding the title bar): the viewport + /// minus the padding margin, the bottom reserve, and the title bar, clamped to + /// `minSize`. + static func expandedSize(viewport: CGSize, metrics: Metrics) -> CGSize { let width = max(metrics.minSize.width, viewport.width - metrics.padding * 2) let totalHeight = viewport.height - metrics.padding * 2 - metrics.bottomReserve let height = max(metrics.minSize.height, totalHeight - metrics.titleBarHeight) - - // Scale is 1, so screen position == cardCenter + offset. Center the card - // horizontally and vertically within the toolbar-adjusted viewport. - let offset = CGSize( - width: viewport.width / 2 - cardCenter.x, - height: (viewport.height - metrics.bottomReserve) / 2 - cardCenter.y - ) - - return (CGSize(width: width, height: height), offset) + return CGSize(width: width, height: height) } } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index e9ccf7e9..5d8a03c3 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -34,9 +34,6 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State private var expandedTabID: TerminalTabID? - /// Canvas scale/offset captured when entering expand, restored on collapse. - @State private var preExpandScale: CGFloat? - @State private var preExpandOffset: CGSize? private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -257,7 +254,12 @@ struct CanvasView: View { let cardKey = tab.id.rawValue.uuidString let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) - let screenCenter = screenPosition(for: resized.center) + // An expanded card transforms on its own — scale 1, centered in the + // viewport — independent of the canvas pan/zoom, so the background is frozen. + let isCardExpanded = expandedTabID == tab.id + let appliedScale = isCardExpanded ? 1 : canvasScale + let screenCenter = + isCardExpanded ? expandedScreenCenter : screenPosition(for: resized.center) let cardTotalHeight = resized.size.height + titleBarHeight let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() let splitDivider = terminalManager.splitDividerAppearance() @@ -279,8 +281,8 @@ struct CanvasView: View { hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: resized.size, animatesSizeChanges: activeResize[tab.id] == nil, - isExpanded: expandedTabID == tab.id, - canvasScale: canvasScale, + isExpanded: isCardExpanded, + canvasScale: appliedScale, showsSelectionShield: showsSelectionShield(for: tab.id), onTap: { let cmdHeld = NSEvent.modifierFlags.contains(.command) @@ -330,7 +332,7 @@ struct CanvasView: View { state.closeTab(tab.id) } ) - .scaleEffect(canvasScale, anchor: .center) + .scaleEffect(appliedScale, anchor: .center) .offset( x: screenCenter.x - resized.size.width / 2, y: screenCenter.y - cardTotalHeight / 2 @@ -431,10 +433,10 @@ struct CanvasView: View { for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> (center: CGPoint, size: CGSize) { - // An expanded card renders at its near-fullscreen size, centered via the - // canvas offset; resize is disabled while expanded. - if expandedTabID == tabID, let frame = expandLayout(for: tabID) { - return (baseLayout.position, frame.size) + // An expanded card renders at its near-fullscreen size, positioned by the + // card view independently of the canvas transform; resize is disabled. + if let size = expandedSize(for: tabID) { + return (baseLayout.position, size) } var centerX = baseLayout.position.x @@ -782,8 +784,6 @@ struct CanvasView: View { // MARK: - Expand In Place - /// Expanded size + centering offset for a card, or nil when the viewport or - /// the card's stored layout isn't available yet. private var expandMetrics: CanvasExpandGeometry.Metrics { CanvasExpandGeometry.Metrics( padding: expandPadding, @@ -793,15 +793,19 @@ struct CanvasView: View { ) } - private func expandLayout(for tabID: TerminalTabID) -> (size: CGSize, offset: CGSize)? { - guard viewportSize.width > 0, viewportSize.height > 0, - let layout = layoutStore.cardLayouts[tabID.rawValue.uuidString] + /// Content size (excluding title bar) for the card currently expanded, or nil + /// when `tabID` isn't expanded or the viewport isn't measured yet. + private func expandedSize(for tabID: TerminalTabID) -> CGSize? { + guard expandedTabID == tabID, viewportSize.width > 0, viewportSize.height > 0 else { return nil } - return CanvasExpandGeometry.expandedFrame( - viewport: viewportSize, - cardCenter: layout.position, - metrics: expandMetrics - ) + return CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics) + } + + /// Screen-space center for an expanded card: horizontally centered and within + /// the toolbar-adjusted viewport. Independent of canvas pan/zoom, so the card + /// covers the whole viewport regardless of the (unchanged) background. + private var expandedScreenCenter: CGPoint { + CGPoint(x: viewportSize.width / 2, y: (viewportSize.height - bottomToolbarReserve) / 2) } /// Toggle expand/restore for a card — used by the title-bar button and the @@ -814,53 +818,33 @@ struct CanvasView: View { } } - /// Expand a card in place: capture the current canvas transform, raise the - /// card to the top, snap scale to 1 (native font size), and grow it to - /// near-fullscreen centered in the viewport. Other cards keep running behind - /// a dimming scrim. + /// Expand a card in place: raise it to the top and let it animate, on its own, + /// from its current canvas position/size to scale 1 covering the whole + /// viewport (with padding). The canvas transform is left untouched, so every + /// other card stays exactly where it was — the expanded card simply floats + /// above a dimming scrim. private func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { - guard let frame = expandLayout(for: tabID) else { return } - if expandedTabID == nil { - preExpandScale = canvasScale - preExpandOffset = canvasOffset - } + guard viewportSize.width > 0, viewportSize.height > 0, + layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil + else { return } focusSingleCard(tabID, states: states) withAnimation(expandAnimation) { expandedTabID = tabID - canvasScale = 1 - canvasOffset = frame.offset - lastCanvasScale = 1 - lastCanvasOffset = frame.offset } } - /// Restore the expanded card to its prior size and the canvas to its prior - /// scale/offset. + /// Restore the expanded card back into the (unchanged) canvas. private func collapseExpand() { guard expandedTabID != nil else { return } - let restoreScale = preExpandScale - let restoreOffset = preExpandOffset withAnimation(expandAnimation) { expandedTabID = nil - if let restoreScale { - canvasScale = restoreScale - lastCanvasScale = restoreScale - } - if let restoreOffset { - canvasOffset = restoreOffset - lastCanvasOffset = restoreOffset - } } - preExpandScale = nil - preExpandOffset = nil } - /// Drop expand state without restoring the transform — used right before a - /// relayout (Arrange/Organize) takes over the canvas. + /// Drop expand state without animation — used right before a relayout + /// (Arrange/Organize) takes over the canvas. private func cancelExpandForRelayout() { expandedTabID = nil - preExpandScale = nil - preExpandOffset = nil } private func fulfillPendingFocusRequest( @@ -1068,8 +1052,6 @@ struct CanvasView: View { private func deactivateCanvas() { expandedTabID = nil - preExpandScale = nil - preExpandOffset = nil let activeStates = terminalManager.activeWorktreeStates for state in activeStates { state.isCanvasManaged = false diff --git a/supacodeTests/CanvasExpandGeometryTests.swift b/supacodeTests/CanvasExpandGeometryTests.swift index 61a63aab..ea7aa1d3 100644 --- a/supacodeTests/CanvasExpandGeometryTests.swift +++ b/supacodeTests/CanvasExpandGeometryTests.swift @@ -5,8 +5,6 @@ import Testing @testable import supacode struct CanvasExpandGeometryTests { - private let viewport = CGSize(width: 2000, height: 1400) - private let center = CGPoint(x: 500, y: 300) private let metrics = CanvasExpandGeometry.Metrics( padding: 40, bottomReserve: 50, @@ -15,36 +13,22 @@ struct CanvasExpandGeometryTests { ) @Test func sizeFillsViewportMinusPaddingAndBottomReserve() { - let result = CanvasExpandGeometry.expandedFrame( - viewport: viewport, - cardCenter: center, + let size = CanvasExpandGeometry.expandedSize( + viewport: CGSize(width: 2000, height: 1400), metrics: metrics ) let expectedWidth: CGFloat = 2000 - 40 * 2 let expectedHeight: CGFloat = 1400 - 40 * 2 - 50 - 28 - #expect(result.size.width == expectedWidth) - #expect(result.size.height == expectedHeight) - } - - @Test func offsetCentersCardAtScaleOne() { - let result = CanvasExpandGeometry.expandedFrame( - viewport: viewport, - cardCenter: center, - metrics: metrics - ) - // At scale 1, the card's screen center is cardCenter + offset; it should - // land at the horizontal middle and the toolbar-adjusted vertical middle. - #expect(center.x + result.offset.width == viewport.width / 2) - #expect(center.y + result.offset.height == (viewport.height - 50) / 2) + #expect(size.width == expectedWidth) + #expect(size.height == expectedHeight) } @Test func clampsToMinSizeOnTinyViewport() { - let result = CanvasExpandGeometry.expandedFrame( + let size = CanvasExpandGeometry.expandedSize( viewport: CGSize(width: 200, height: 150), - cardCenter: .zero, metrics: metrics ) - #expect(result.size.width == metrics.minSize.width) - #expect(result.size.height == metrics.minSize.height) + #expect(size.width == metrics.minSize.width) + #expect(size.height == metrics.minSize.height) } } -- 2.51.2 From d63057d81f9b4180ec8ade426701422264630c78 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 21:56:22 +0900 Subject: [PATCH 315/528] Keep background frozen by pinning the cards stack to topLeading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping the cards in a ZStack changed their `.offset()` origin: a plain ZStack centers its children, and once the expanded card's scrim added a full-size frame, the stack grew to the whole viewport and the center origin jumped — shifting every background card on expand. Pin the stack to `.topLeading` and fill the viewport so each card keeps the same (0,0) origin it had under GeometryReader, independent of the scrim. The background now stays exactly put when a card expands. --- supacode/Features/Canvas/Views/CanvasView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 5d8a03c3..9133ad92 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -219,7 +219,10 @@ struct CanvasView: View { /// reaching the NSView, keeping terminal grid stable during zoom. @ViewBuilder private func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { - ZStack { + // Pin to .topLeading and fill the viewport so each card's `.offset()` keeps + // the same (0,0) origin it had under GeometryReader — otherwise the scrim's + // full-size frame would resize the stack and shift the cards' base position. + ZStack(alignment: .topLeading) { ForEach(activeStates, id: \.worktreeID) { state in ForEach(state.tabManager.tabs) { tab in if state.surfaceView(for: tab.id) != nil { @@ -242,6 +245,7 @@ struct CanvasView: View { .transition(.opacity) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @ViewBuilder -- 2.51.2 From 604f233565cb4ef40f2b4338e45f4955742d9cb7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 22:10:01 +0900 Subject: [PATCH 316/528] Magic-move expand from the card's origin, not the screen center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expansion split its animation across two transactions: offset/scale and the outer frame ran under expandCard's withAnimation, while the card's terminal size refit ran under CanvasCardView's own `.animation(_:value: cardSize)`. Even at the same 0.2s the two didn't commit in lock-step, so the terminal reflow lagged the offset and the card read as growing from the screen center instead of moving from its origin. During the expand/restore transition, disable the card's own size animation (`animatesSizeChanges`) so cardSize is driven by the single expand `withAnimation` — offset, scale, frame, and terminal reflow now interpolate together, yielding a true magic-move from the card's in-canvas position/size. A `collapsingTabID`, cleared via withAnimation's completion, covers the restore half symmetrically. --- .../Features/Canvas/Views/CanvasView.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 9133ad92..44fab82f 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -34,6 +34,10 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State private var expandedTabID: TerminalTabID? + /// The tab playing its restore (collapse) animation. Kept set for the + /// animation's duration so the card's terminal size refit stays driven by the + /// single expand `withAnimation` transaction instead of its own. + @State private var collapsingTabID: TerminalTabID? private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -284,7 +288,11 @@ struct CanvasView: View { isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: resized.size, - animatesSizeChanges: activeResize[tab.id] == nil, + // While expanding/restoring this card, defer its size animation to the + // single expand `withAnimation` so offset/scale/size/terminal stay in + // lock-step (a true magic-move from the card's origin, not the center). + animatesSizeChanges: activeResize[tab.id] == nil && expandedTabID != tab.id + && collapsingTabID != tab.id, isExpanded: isCardExpanded, canvasScale: appliedScale, showsSelectionShield: showsSelectionShield(for: tab.id), @@ -831,17 +839,23 @@ struct CanvasView: View { guard viewportSize.width > 0, viewportSize.height > 0, layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil else { return } + collapsingTabID = nil focusSingleCard(tabID, states: states) withAnimation(expandAnimation) { expandedTabID = tabID } } - /// Restore the expanded card back into the (unchanged) canvas. + /// Restore the expanded card back into the (unchanged) canvas. Marks the tab + /// as collapsing for the animation's duration so its terminal size refit is + /// driven by this single transaction, keeping the magic-move in sync. private func collapseExpand() { - guard expandedTabID != nil else { return } + guard let tabID = expandedTabID else { return } + collapsingTabID = tabID withAnimation(expandAnimation) { expandedTabID = nil + } completion: { + if collapsingTabID == tabID { collapsingTabID = nil } } } @@ -849,6 +863,7 @@ struct CanvasView: View { /// (Arrange/Organize) takes over the canvas. private func cancelExpandForRelayout() { expandedTabID = nil + collapsingTabID = nil } private func fulfillPendingFocusRequest( @@ -1056,6 +1071,7 @@ struct CanvasView: View { private func deactivateCanvas() { expandedTabID = nil + collapsingTabID = nil let activeStates = terminalManager.activeWorktreeStates for state in activeStates { state.isCanvasManaged = false -- 2.51.2 From ca2d3512bca26c7a2ed9c9964a87bf86a195ae8d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 22:21:53 +0900 Subject: [PATCH 317/528] Drive expand with a single progress value for a real magic-move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier attempts split the animation across modifiers that SwiftUI interpolated on their own, with cardSize as a jump-to-end value rather than a continuous one — so it either grew from the screen center (size and offset out of sync) or, once the card's own size animation was disabled, didn't animate at all (`.animation(nil, value:)` actively *disables* the size animation rather than deferring it). Drive the whole thing from one animated value instead: `expandProgress` (0 → 1). The expanded card's size, center, and scale are all derived from it each frame via `cardScreenGeometry`, so they move in perfect lock-step. At progress 0 the geometry is exactly the card's in-canvas frame (origin + size + canvasScale), so the card grows from where it actually sits — a true magic-move — and the terminal reflows along with the per-frame size. The canvas transform is never touched, so every other card stays frozen. Replaces the expanded-card branch in resizedFrame and the collapsingTabID bookkeeping; collapse animates progress back to 0 and clears the marker in the animation's completion. --- .../Features/Canvas/Views/CanvasView.swift | 129 +++++++++++------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 44fab82f..a12780c8 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -34,10 +34,10 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State private var expandedTabID: TerminalTabID? - /// The tab playing its restore (collapse) animation. Kept set for the - /// animation's duration so the card's terminal size refit stays driven by the - /// single expand `withAnimation` transaction instead of its own. - @State private var collapsingTabID: TerminalTabID? + /// Drives the expand/restore magic-move for `expandedTabID`: 0 = collapsed + /// into the canvas, 1 = covering the viewport. Animating this single value + /// keeps the card's size, center, and scale in perfect lock-step. + @State private var expandProgress: CGFloat = 0 private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -128,7 +128,7 @@ struct CanvasView: View { .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in pruneSelection(previousOrder: oldTabIDs, currentOrder: newTabIDs, states: activeStates) if let expandedTabID, !newTabIDs.contains(expandedTabID) { - collapseExpand() + cancelExpandForRelayout() } fulfillPendingFocusRequest(focusRequest, states: activeStates) } @@ -261,14 +261,15 @@ struct CanvasView: View { let tree = state.splitTree(for: tab.id) let cardKey = tab.id.rawValue.uuidString let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) - let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) - // An expanded card transforms on its own — scale 1, centered in the - // viewport — independent of the canvas pan/zoom, so the background is frozen. let isCardExpanded = expandedTabID == tab.id - let appliedScale = isCardExpanded ? 1 : canvasScale - let screenCenter = - isCardExpanded ? expandedScreenCenter : screenPosition(for: resized.center) - let cardTotalHeight = resized.size.height + titleBarHeight + // An expanded card transforms on its own — size/center/scale interpolated by + // expandProgress — independent of the canvas pan/zoom, so the background is + // frozen. Non-expanded cards keep their normal resize-aware geometry. + let geometry = cardScreenGeometry(for: tab.id, baseLayout: baseLayout) + let renderSize = geometry.size + let appliedScale = geometry.scale + let screenCenter = geometry.center + let cardTotalHeight = renderSize.height + titleBarHeight let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() let splitDivider = terminalManager.splitDividerAppearance() let repositoryAppearance = appearance(for: state.repositoryRootURL) @@ -287,12 +288,11 @@ struct CanvasView: View { isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), - cardSize: resized.size, - // While expanding/restoring this card, defer its size animation to the - // single expand `withAnimation` so offset/scale/size/terminal stay in - // lock-step (a true magic-move from the card's origin, not the center). - animatesSizeChanges: activeResize[tab.id] == nil && expandedTabID != tab.id - && collapsingTabID != tab.id, + cardSize: renderSize, + // The expanded card already receives the exact interpolated size each + // frame (driven by expandProgress), so disable its own size animation to + // avoid a second, competing one. + animatesSizeChanges: !isCardExpanded && activeResize[tab.id] == nil, isExpanded: isCardExpanded, canvasScale: appliedScale, showsSelectionShield: showsSelectionShield(for: tab.id), @@ -346,7 +346,7 @@ struct CanvasView: View { ) .scaleEffect(appliedScale, anchor: .center) .offset( - x: screenCenter.x - resized.size.width / 2, + x: screenCenter.x - renderSize.width / 2, y: screenCenter.y - cardTotalHeight / 2 ) .zIndex(zIndex(for: tab.id, cardKey: cardKey)) @@ -445,12 +445,6 @@ struct CanvasView: View { for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> (center: CGPoint, size: CGSize) { - // An expanded card renders at its near-fullscreen size, positioned by the - // card view independently of the canvas transform; resize is disabled. - if let size = expandedSize(for: tabID) { - return (baseLayout.position, size) - } - var centerX = baseLayout.position.x var centerY = baseLayout.position.y var width = baseLayout.size.width @@ -805,21 +799,54 @@ struct CanvasView: View { ) } - /// Content size (excluding title bar) for the card currently expanded, or nil - /// when `tabID` isn't expanded or the viewport isn't measured yet. - private func expandedSize(for tabID: TerminalTabID) -> CGSize? { - guard expandedTabID == tabID, viewportSize.width > 0, viewportSize.height > 0 - else { return nil } - return CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics) - } - - /// Screen-space center for an expanded card: horizontally centered and within - /// the toolbar-adjusted viewport. Independent of canvas pan/zoom, so the card - /// covers the whole viewport regardless of the (unchanged) background. + /// Screen-space center for a fully expanded card: horizontally centered and + /// within the toolbar-adjusted viewport. Independent of canvas pan/zoom. private var expandedScreenCenter: CGPoint { CGPoint(x: viewportSize.width / 2, y: (viewportSize.height - bottomToolbarReserve) / 2) } + /// Screen-space transform for a card on canvas. + private struct CardScreenGeometry { + var size: CGSize + var center: CGPoint + var scale: CGFloat + } + + /// Screen-space size/center/scale for a card, interpolated by `expandProgress` + /// when it's the expanded one. Driving all three from the single progress + /// value keeps them in perfect lock-step — a magic-move from the card's own + /// in-canvas frame (progress 0) to the full viewport (progress 1) — while the + /// canvas transform (and thus every other card) stays frozen. + private func cardScreenGeometry( + for tabID: TerminalTabID, + baseLayout: CanvasCardLayout + ) -> CardScreenGeometry { + guard expandedTabID == tabID, viewportSize.width > 0, viewportSize.height > 0 else { + let resized = resizedFrame(for: tabID, baseLayout: baseLayout) + return CardScreenGeometry( + size: resized.size, + center: screenPosition(for: resized.center), + scale: canvasScale + ) + } + + let progress = expandProgress + let fromSize = baseLayout.size + let toSize = CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics) + let fromCenter = screenPosition(for: baseLayout.position) + let toCenter = expandedScreenCenter + + func lerp(_ start: CGFloat, _ end: CGFloat) -> CGFloat { start + (end - start) * progress } + return CardScreenGeometry( + size: CGSize( + width: lerp(fromSize.width, toSize.width), + height: lerp(fromSize.height, toSize.height) + ), + center: CGPoint(x: lerp(fromCenter.x, toCenter.x), y: lerp(fromCenter.y, toCenter.y)), + scale: lerp(canvasScale, 1) + ) + } + /// Toggle expand/restore for a card — used by the title-bar button and the /// title-bar double-click. private func toggleExpand(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { @@ -830,32 +857,30 @@ struct CanvasView: View { } } - /// Expand a card in place: raise it to the top and let it animate, on its own, - /// from its current canvas position/size to scale 1 covering the whole - /// viewport (with padding). The canvas transform is left untouched, so every - /// other card stays exactly where it was — the expanded card simply floats - /// above a dimming scrim. + /// Expand a card in place: raise it to the top, then animate the single + /// `expandProgress` from 0 → 1 so the card grows, on its own, from its current + /// canvas frame to cover the whole viewport at scale 1. The canvas transform + /// is left untouched, so every other card stays exactly where it was. private func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { guard viewportSize.width > 0, viewportSize.height > 0, layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil else { return } - collapsingTabID = nil focusSingleCard(tabID, states: states) + expandedTabID = tabID + expandProgress = 0 withAnimation(expandAnimation) { - expandedTabID = tabID + expandProgress = 1 } } - /// Restore the expanded card back into the (unchanged) canvas. Marks the tab - /// as collapsing for the animation's duration so its terminal size refit is - /// driven by this single transaction, keeping the magic-move in sync. + /// Restore the expanded card back into the (unchanged) canvas, then drop the + /// expanded marker once the animation completes. private func collapseExpand() { - guard let tabID = expandedTabID else { return } - collapsingTabID = tabID + guard expandedTabID != nil else { return } withAnimation(expandAnimation) { - expandedTabID = nil + expandProgress = 0 } completion: { - if collapsingTabID == tabID { collapsingTabID = nil } + if expandProgress == 0 { expandedTabID = nil } } } @@ -863,7 +888,7 @@ struct CanvasView: View { /// (Arrange/Organize) takes over the canvas. private func cancelExpandForRelayout() { expandedTabID = nil - collapsingTabID = nil + expandProgress = 0 } private func fulfillPendingFocusRequest( @@ -1071,7 +1096,7 @@ struct CanvasView: View { private func deactivateCanvas() { expandedTabID = nil - collapsingTabID = nil + expandProgress = 0 let activeStates = terminalManager.activeWorktreeStates for state in activeStates { state.isCanvasManaged = false -- 2.51.2 From 8cd223349750344196fb7360b3e7bec73d2c31f6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 23:10:17 +0900 Subject: [PATCH 318/528] Animate expand by flipping state inside withAnimation, one transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic logs were decisive: with expandedTabID set *outside* withAnimation, that non-animated state change shared the render with the animated one and the geometry just snapped (offset/scale never interpolated). And progress 0 → 1 jumped with no in-between frames, confirming a value consumed by a plain function isn't driven frame by frame — so the expandProgress approach could never animate; it degraded to a Bool. Fix: - Flip expandedTabID *inside* withAnimation so size, center, and scale all interpolate together (drop expandProgress entirely). - Remove CanvasCardView's own `.animation(_:value: cardSize)`. That was the real "grows from center" culprit: the terminal size ran on its own transaction while offset ran on the outer one, so they desynced — and with it set to nil it actively disabled the animation, giving no motion at all. Now every size change is driven by the caller's single withAnimation, keeping terminal refit in lock-step with offset/scale. - commitResize wraps its size change in withAnimation to replace the animation the card used to do itself; live-drag resize (no wrapping animation) still tracks the gesture 1:1. The canvas transform is never touched, so the background stays frozen. --- .../Canvas/Views/CanvasCardView.swift | 6 +- .../Features/Canvas/Views/CanvasView.swift | 73 +++++++------------ 2 files changed, 30 insertions(+), 49 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 82912252..d77d4289 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -23,7 +23,6 @@ struct CanvasCardView: View { let isSelected: Bool let hasUnseenNotification: Bool let cardSize: CGSize - let animatesSizeChanges: Bool /// Whether this card is currently expanded in place (near-fullscreen). When /// true the title-bar button restores instead of expands, resize handles and /// title-bar dragging are disabled, and the action buttons stay visible. @@ -247,7 +246,10 @@ struct CanvasCardView: View { hasNotification: { _ in false }, action: onSplitOperation ) - .animation(animatesSizeChanges ? .easeInOut(duration: 0.2) : nil, value: cardSize) + // No own size animation: the canvas drives every size change inside a + // withAnimation (expand/restore, resize commit, arrange), so the terminal + // refit stays in lock-step with the card's offset/scale. Without a wrapping + // animation (live resize drag) the size tracks the gesture 1:1. .allowsHitTesting(isFocused && !showsSelectionShield) } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a12780c8..69bdaf38 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -34,10 +34,6 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State private var expandedTabID: TerminalTabID? - /// Drives the expand/restore magic-move for `expandedTabID`: 0 = collapsed - /// into the canvas, 1 = covering the viewport. Animating this single value - /// keeps the card's size, center, and scale in perfect lock-step. - @State private var expandProgress: CGFloat = 0 private let minCardWidth: CGFloat = 300 private let minCardHeight: CGFloat = 200 @@ -262,9 +258,9 @@ struct CanvasView: View { let cardKey = tab.id.rawValue.uuidString let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) let isCardExpanded = expandedTabID == tab.id - // An expanded card transforms on its own — size/center/scale interpolated by - // expandProgress — independent of the canvas pan/zoom, so the background is - // frozen. Non-expanded cards keep their normal resize-aware geometry. + // An expanded card transforms on its own — full-viewport size/center at + // scale 1 — independent of the canvas pan/zoom, so the background is frozen. + // Non-expanded cards keep their normal resize-aware geometry. let geometry = cardScreenGeometry(for: tab.id, baseLayout: baseLayout) let renderSize = geometry.size let appliedScale = geometry.scale @@ -289,10 +285,6 @@ struct CanvasView: View { isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: renderSize, - // The expanded card already receives the exact interpolated size each - // frame (driven by expandProgress), so disable its own size animation to - // avoid a second, competing one. - animatesSizeChanges: !isCardExpanded && activeResize[tab.id] == nil, isExpanded: isCardExpanded, canvasScale: appliedScale, showsSelectionShield: showsSelectionShield(for: tab.id), @@ -756,9 +748,13 @@ struct CanvasView: View { guard activeResize[tabID] != nil else { return } if var layout = layoutStore.cardLayouts[cardKey] { let resized = resizedFrame(for: tabID, baseLayout: layout) - layout.position = resized.center - layout.size = resized.size - layoutStore.cardLayouts[cardKey] = layout + // Settle the card into its committed size with a short animation (cards + // no longer animate size on their own; the canvas drives it explicitly). + withAnimation(.easeInOut(duration: 0.2)) { + layout.position = resized.center + layout.size = resized.size + layoutStore.cardLayouts[cardKey] = layout + } } activeResize[tabID] = nil for surface in surfaces { @@ -812,11 +808,10 @@ struct CanvasView: View { var scale: CGFloat } - /// Screen-space size/center/scale for a card, interpolated by `expandProgress` - /// when it's the expanded one. Driving all three from the single progress - /// value keeps them in perfect lock-step — a magic-move from the card's own - /// in-canvas frame (progress 0) to the full viewport (progress 1) — while the - /// canvas transform (and thus every other card) stays frozen. + /// Screen-space size/center/scale for a card. The expanded card snaps to the + /// full-viewport frame at scale 1; the change is animated by the single + /// `withAnimation` in `expandCard`/`collapseExpand`. Because the canvas + /// transform is never touched, every other card stays frozen. private func cardScreenGeometry( for tabID: TerminalTabID, baseLayout: CanvasCardLayout @@ -829,21 +824,10 @@ struct CanvasView: View { scale: canvasScale ) } - - let progress = expandProgress - let fromSize = baseLayout.size - let toSize = CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics) - let fromCenter = screenPosition(for: baseLayout.position) - let toCenter = expandedScreenCenter - - func lerp(_ start: CGFloat, _ end: CGFloat) -> CGFloat { start + (end - start) * progress } return CardScreenGeometry( - size: CGSize( - width: lerp(fromSize.width, toSize.width), - height: lerp(fromSize.height, toSize.height) - ), - center: CGPoint(x: lerp(fromCenter.x, toCenter.x), y: lerp(fromCenter.y, toCenter.y)), - scale: lerp(canvasScale, 1) + size: CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics), + center: expandedScreenCenter, + scale: 1 ) } @@ -857,30 +841,27 @@ struct CanvasView: View { } } - /// Expand a card in place: raise it to the top, then animate the single - /// `expandProgress` from 0 → 1 so the card grows, on its own, from its current - /// canvas frame to cover the whole viewport at scale 1. The canvas transform - /// is left untouched, so every other card stays exactly where it was. + /// Expand a card in place: raise it to the top, then flip `expandedTabID` + /// inside one `withAnimation` so the card's size, center, and scale all + /// interpolate together from its in-canvas frame to the full viewport at + /// scale 1 — a magic-move from where the card actually sits. The canvas + /// transform is left untouched, so every other card stays exactly where it + /// was, behind a dimming scrim. private func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { guard viewportSize.width > 0, viewportSize.height > 0, layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil else { return } focusSingleCard(tabID, states: states) - expandedTabID = tabID - expandProgress = 0 withAnimation(expandAnimation) { - expandProgress = 1 + expandedTabID = tabID } } - /// Restore the expanded card back into the (unchanged) canvas, then drop the - /// expanded marker once the animation completes. + /// Restore the expanded card back into the (unchanged) canvas. private func collapseExpand() { guard expandedTabID != nil else { return } withAnimation(expandAnimation) { - expandProgress = 0 - } completion: { - if expandProgress == 0 { expandedTabID = nil } + expandedTabID = nil } } @@ -888,7 +869,6 @@ struct CanvasView: View { /// (Arrange/Organize) takes over the canvas. private func cancelExpandForRelayout() { expandedTabID = nil - expandProgress = 0 } private func fulfillPendingFocusRequest( @@ -1096,7 +1076,6 @@ struct CanvasView: View { private func deactivateCanvas() { expandedTabID = nil - expandProgress = 0 let activeStates = terminalManager.activeWorktreeStates for state in activeStates { state.isCanvasManaged = false -- 2.51.2 From 1fe46e72ae16f84d33bf424e26a19e27b0ef2488 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 23:20:23 +0900 Subject: [PATCH 319/528] Animate expand via card-level .animation(value:), not withAnimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After removing the internal animation the 30s stall disappeared but the move still didn't animate — proving the earlier "animation" was only the terminal's own .animation, and that withAnimation around expandedTabID never reached the card's offset/scale at all. The GeometryReader wrapping the cards carries `.animation(_:value: focusViewportAnimationID)`, whose value-scoped transaction swallows the implicit one from withAnimation for everything in that subtree. So stop relying on transaction propagation: attach an explicit `.animation(expandAnimation, value: isCardExpanded)` directly on the card. When a card's expanded state flips, this drives its size, center, scale, and terminal refit together — a synchronized magic-move from the card's in-canvas frame to the full viewport. Only the toggled card's value changes, so the others don't animate and the canvas transform is still never touched (frozen background). withAnimation is kept solely to fade the scrim. --- supacode/Features/Canvas/Views/CanvasView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 69bdaf38..dc01b120 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -341,6 +341,13 @@ struct CanvasView: View { x: screenCenter.x - renderSize.width / 2, y: screenCenter.y - cardTotalHeight / 2 ) + // Explicitly animate the expand/restore transition on this card. A plain + // withAnimation around expandedTabID doesn't reach here (the GeometryReader's + // value-scoped .animation intercepts the implicit transaction), so bind the + // animation directly to this card's expanded state. It drives size, center, + // scale, and the terminal refit together — a magic-move from the card's + // in-canvas frame. Only the toggled card sees the change, so the rest stay put. + .animation(expandAnimation, value: isCardExpanded) .zIndex(zIndex(for: tab.id, cardKey: cardKey)) } -- 2.51.2 From 0d8fa9d3224d47a0b2cbb57522e38078923e493e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 23:44:37 +0900 Subject: [PATCH 320/528] Magic-move expand via an Animatable container (guaranteed per-frame) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prior approach relied on SwiftUI implicitly interpolating separate modifiers, which only ever animated the Animatable terminal — offset and scale just snapped, so expand "grew from the center" and restore didn't move at all. And a progress @State consumed by a plain function isn't re-evaluated frame by frame, so that route degraded to a Bool. Wrap the card in AnimatedExpandableCard, a `View` that is itself `Animatable` with `animatableData = progress`. SwiftUI re-runs its body on every frame of the transition, so size, center, and scale are all lerp'd from the same progress and the terminal re-flows in lock-step with offset/scale — a true magic-move from the card's in-canvas frame (progress 0) to the full viewport (progress 1). Background untouched. cardScreenGeometry splits into nonExpandedGeometry (the progress-0 endpoint, follows pan/zoom/resize) and expandedGeometry (progress-1). CardScreenGeometry moves to file scope so the container can share it. --- .../Features/Canvas/Views/CanvasView.swift | 247 +++++++++++------- 1 file changed, 147 insertions(+), 100 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index dc01b120..cfa3ea0f 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -258,95 +258,94 @@ struct CanvasView: View { let cardKey = tab.id.rawValue.uuidString let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) let isCardExpanded = expandedTabID == tab.id - // An expanded card transforms on its own — full-viewport size/center at - // scale 1 — independent of the canvas pan/zoom, so the background is frozen. - // Non-expanded cards keep their normal resize-aware geometry. - let geometry = cardScreenGeometry(for: tab.id, baseLayout: baseLayout) - let renderSize = geometry.size - let appliedScale = geometry.scale - let screenCenter = geometry.center - let cardTotalHeight = renderSize.height + titleBarHeight + // The expanded card magic-moves between its in-canvas frame and the full + // viewport. AnimatedExpandableCard drives every sub-value (size, center, + // scale) from one animatable progress, so they advance frame by frame in + // lock-step. The canvas transform is never touched → background frozen. + let fromGeometry = nonExpandedGeometry(for: tab.id, baseLayout: baseLayout) + let toGeometry = expandedGeometry() let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() let splitDivider = terminalManager.splitDividerAppearance() let repositoryAppearance = appearance(for: state.repositoryRootURL) let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) - CanvasCardView( - repositoryName: resolvedRepositoryName, - worktreeName: tab.displayTitle, - repositoryIcon: repositoryAppearance.icon, - repositoryColor: repositoryAppearance.color?.color, - repositoryRootURL: state.repositoryRootURL, - tree: tree, - activeSurfaceID: state.activeSurfaceID(for: tab.id), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - isFocused: selectionState.primaryTabID == tab.id, - isSelected: selectionState.selectedTabIDs.contains(tab.id), - hasUnseenNotification: state.hasUnseenNotification(for: tab.id), - cardSize: renderSize, - isExpanded: isCardExpanded, - canvasScale: appliedScale, - showsSelectionShield: showsSelectionShield(for: tab.id), - onTap: { - let cmdHeld = NSEvent.modifierFlags.contains(.command) - if cmdHeld { + AnimatedExpandableCard( + progress: isCardExpanded ? 1 : 0, + collapsed: fromGeometry, + expanded: toGeometry, + titleBarHeight: titleBarHeight + ) { renderSize in + CanvasCardView( + repositoryName: resolvedRepositoryName, + worktreeName: tab.displayTitle, + repositoryIcon: repositoryAppearance.icon, + repositoryColor: repositoryAppearance.color?.color, + repositoryRootURL: state.repositoryRootURL, + tree: tree, + activeSurfaceID: state.activeSurfaceID(for: tab.id), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + isFocused: selectionState.primaryTabID == tab.id, + isSelected: selectionState.selectedTabIDs.contains(tab.id), + hasUnseenNotification: state.hasUnseenNotification(for: tab.id), + cardSize: renderSize, + isExpanded: isCardExpanded, + canvasScale: isCardExpanded ? 1 : canvasScale, + showsSelectionShield: showsSelectionShield(for: tab.id), + onTap: { + let cmdHeld = NSEvent.modifierFlags.contains(.command) + if cmdHeld { + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) + } else { + focusSingleCard(tab.id, states: activeStates) + } + }, + onSelectionTap: { handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - } else { - focusSingleCard(tab.id, states: activeStates) - } - }, - onSelectionTap: { - handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - }, - onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, - onResize: { edge, translation in - activeResize[tab.id] = ActiveResize( - edge: edge, - translation: CGSize( - width: translation.width / canvasScale, - height: translation.height / canvasScale + }, + onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, + onResize: { edge, translation in + activeResize[tab.id] = ActiveResize( + edge: edge, + translation: CGSize( + width: translation.width / canvasScale, + height: translation.height / canvasScale + ) ) - ) - }, - onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, - onSplitOperation: { operation in - state.performSplitOperation(operation, in: tab.id) - if selectionState.isBroadcasting { - syncBroadcastCallbacks(states: activeStates) - } - }, - onTitleBarTap: { - let wasAlreadyFocused = - selectionState.primaryTabID == tab.id - && selectionState.selectedTabIDs.count <= 1 - focusSingleCard(tab.id, states: activeStates) - let now = Date() - if wasAlreadyFocused, - now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval - { + }, + onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, + onSplitOperation: { operation in + state.performSplitOperation(operation, in: tab.id) + if selectionState.isBroadcasting { + syncBroadcastCallbacks(states: activeStates) + } + }, + onTitleBarTap: { + let wasAlreadyFocused = + selectionState.primaryTabID == tab.id + && selectionState.selectedTabIDs.count <= 1 + focusSingleCard(tab.id, states: activeStates) + let now = Date() + if wasAlreadyFocused, + now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval + { + toggleExpand(tab.id, states: activeStates) + } + lastTitleBarTapDate = now + }, + onExpand: { toggleExpand(tab.id, states: activeStates) + }, + onClose: { + state.closeTab(tab.id) } - lastTitleBarTapDate = now - }, - onExpand: { - toggleExpand(tab.id, states: activeStates) - }, - onClose: { - state.closeTab(tab.id) - } - ) - .scaleEffect(appliedScale, anchor: .center) - .offset( - x: screenCenter.x - renderSize.width / 2, - y: screenCenter.y - cardTotalHeight / 2 - ) - // Explicitly animate the expand/restore transition on this card. A plain - // withAnimation around expandedTabID doesn't reach here (the GeometryReader's - // value-scoped .animation intercepts the implicit transaction), so bind the - // animation directly to this card's expanded state. It drives size, center, - // scale, and the terminal refit together — a magic-move from the card's - // in-canvas frame. Only the toggled card sees the change, so the rest stay put. + ) + } + // Animatable progress is interpolated by binding the animation to this + // card's expanded state. A plain withAnimation around expandedTabID doesn't + // reach here (the GeometryReader's value-scoped .animation swallows the + // implicit transaction), so drive it explicitly. Only the toggled card's + // value changes, so the rest stay put. .animation(expandAnimation, value: isCardExpanded) .zIndex(zIndex(for: tab.id, cardKey: cardKey)) } @@ -808,30 +807,26 @@ struct CanvasView: View { CGPoint(x: viewportSize.width / 2, y: (viewportSize.height - bottomToolbarReserve) / 2) } - /// Screen-space transform for a card on canvas. - private struct CardScreenGeometry { - var size: CGSize - var center: CGPoint - var scale: CGFloat - } - - /// Screen-space size/center/scale for a card. The expanded card snaps to the - /// full-viewport frame at scale 1; the change is animated by the single - /// `withAnimation` in `expandCard`/`collapseExpand`. Because the canvas - /// transform is never touched, every other card stays frozen. - private func cardScreenGeometry( + /// A card's normal (non-expanded) on-screen frame, following the canvas + /// pan/zoom and any in-progress resize. This is the `progress = 0` endpoint of + /// the expand magic-move. + private func nonExpandedGeometry( for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> CardScreenGeometry { - guard expandedTabID == tabID, viewportSize.width > 0, viewportSize.height > 0 else { - let resized = resizedFrame(for: tabID, baseLayout: baseLayout) - return CardScreenGeometry( - size: resized.size, - center: screenPosition(for: resized.center), - scale: canvasScale - ) - } + let resized = resizedFrame(for: tabID, baseLayout: baseLayout) return CardScreenGeometry( + size: resized.size, + center: screenPosition(for: resized.center), + scale: canvasScale + ) + } + + /// The full-viewport expanded frame at scale 1 — the `progress = 1` endpoint. + /// Independent of the canvas transform, so it covers the viewport regardless + /// of the (frozen) background. + private func expandedGeometry() -> CardScreenGeometry { + CardScreenGeometry( size: CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics), center: expandedScreenCenter, scale: 1 @@ -1465,3 +1460,55 @@ private class CanvasScrollContainerView: NSView { super.removeFromSuperview() } } + +/// Screen-space transform for a card on canvas. +private struct CardScreenGeometry { + var size: CGSize + var center: CGPoint + var scale: CGFloat +} + +/// An `Animatable` container that interpolates a card between its in-canvas +/// frame (`progress` 0) and the full-viewport expanded frame (`progress` 1). +/// +/// Because `animatableData` is `progress`, SwiftUI re-evaluates `body` on every +/// frame of the transition, so the card's size, center, and scale advance +/// together and the terminal re-flows in lock-step with the offset/scale — a +/// true magic-move from where the card sits. This sidesteps SwiftUI's implicit +/// per-modifier interpolation, which only reached the Animatable terminal and +/// left offset/scale to snap (the "grows from the center" / "no animation" bugs). +private struct AnimatedExpandableCard: View, Animatable { + var progress: CGFloat + var collapsed: CardScreenGeometry + var expanded: CardScreenGeometry + let titleBarHeight: CGFloat + @ViewBuilder let content: (CGSize) -> Content + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + var body: some View { + let fraction = max(0, min(1, progress)) + let size = CGSize( + width: lerp(collapsed.size.width, expanded.size.width, fraction), + height: lerp(collapsed.size.height, expanded.size.height, fraction) + ) + let center = CGPoint( + x: lerp(collapsed.center.x, expanded.center.x, fraction), + y: lerp(collapsed.center.y, expanded.center.y, fraction) + ) + let scale = lerp(collapsed.scale, expanded.scale, fraction) + content(size) + .scaleEffect(scale, anchor: .center) + .offset( + x: center.x - size.width / 2, + y: center.y - (size.height + titleBarHeight) / 2 + ) + } + + private func lerp(_ start: CGFloat, _ end: CGFloat, _ fraction: CGFloat) -> CGFloat { + start + (end - start) * fraction + } +} -- 2.51.2 From 8cabb1c34241a6ebf9e65bc764b9e935a146c5bf Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 6 Jun 2026 23:56:24 +0900 Subject: [PATCH 321/528] Lock the canvas and blur the scrim while a card is expanded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disable canvas pan/zoom during expand so the background can't shift behind the expanded card. Covers every input path: the SwiftUI pan/zoom gestures (isEnabled: expandedTabID == nil) and the AppKit CanvasScrollContainer (scroll-pan, Cmd-scroll zoom, middle-drag pan) via a new isInteractionEnabled flag that also cancels any in-flight middle-pan when it flips off. - Give the dimming scrim a GPU-efficient backdrop blur (.ultraThinMaterial) under the black dim, so background cards stay faintly visible/running but recede behind the expanded one. (The dim can't extend under the toolbar without making the window titlebar transparent app-wide — too invasive — so that's left as-is.) --- .../Features/Canvas/Views/CanvasView.swift | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index cfa3ea0f..a29a6f13 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -84,7 +84,8 @@ struct CanvasView: View { offset: $canvasOffset, lastOffset: $lastCanvasOffset, scale: $canvasScale, - lastScale: $lastCanvasScale + lastScale: $lastCanvasScale, + isInteractionEnabled: expandedTabID == nil ) { GeometryReader { _ in let activeStates = terminalManager.activeWorktreeStates @@ -134,12 +135,12 @@ struct CanvasView: View { .contentShape(.rect) .accessibilityAddTraits(.isButton) .onTapGesture { clearSelection(states: activeStates) } - .gesture(canvasPanGesture) + .gesture(canvasPanGesture, isEnabled: expandedTabID == nil) cardsLayer(activeStates: activeStates) } .contentShape(.rect) - .simultaneousGesture(canvasZoomGesture) + .simultaneousGesture(canvasZoomGesture, isEnabled: expandedTabID == nil) .animation(.easeInOut(duration: 0.22), value: focusViewportAnimationID) .onGeometryChange(for: CGSize.self) { proxy in proxy.size @@ -235,7 +236,11 @@ struct CanvasView: View { // it — i.e. anywhere outside the expanded card, including the padding — // restores the layout. if expandedTabID != nil { - Color.black.opacity(0.3) + // Material gives a GPU-efficient backdrop blur (the background cards stay + // faintly visible / running); the black overlay adds the dim on top. + Rectangle() + .fill(.ultraThinMaterial) + .overlay(Color.black.opacity(0.2)) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(.rect) .accessibilityAddTraits(.isButton) @@ -1136,6 +1141,7 @@ private struct CanvasScrollContainer: NSViewRepresentable { @Binding var lastOffset: CGSize @Binding var scale: CGFloat @Binding var lastScale: CGFloat + var isInteractionEnabled: Bool @ViewBuilder var content: Content func makeCoordinator() -> CanvasScrollCoordinator { @@ -1162,6 +1168,7 @@ private struct CanvasScrollContainer: NSViewRepresentable { context.coordinator.lastOffset = $lastOffset context.coordinator.scale = $scale context.coordinator.lastScale = $lastScale + nsView.isInteractionEnabled = isInteractionEnabled if let hosting = nsView.subviews.first as? NSHostingView { hosting.rootView = content } @@ -1242,6 +1249,15 @@ enum CanvasZoomMath { private class CanvasScrollContainerView: NSView { var scrollCoordinator: CanvasScrollCoordinator? + /// When false (a card is expanded), the container ignores scroll/zoom/ + /// middle-drag so the canvas can't pan or zoom behind the expanded card. + var isInteractionEnabled = true { + didSet { + guard !isInteractionEnabled, oldValue else { return } + if isMiddlePanning { endMiddlePan() } + isPanning = false + } + } /// Whether the container is actively redirecting scroll events to canvas /// panning (as opposed to the brief bounce period after a gesture ends). @@ -1262,6 +1278,10 @@ private class CanvasScrollContainerView: NSView { private var hasPushedPanCursor = false override func scrollWheel(with event: NSEvent) { + guard isInteractionEnabled else { + super.scrollWheel(with: event) + return + } if handleZoomEventIfNeeded(event) { return } if event.phase == .began { startPanning() @@ -1277,6 +1297,7 @@ private class CanvasScrollContainerView: NSView { /// Used by both the direct `scrollWheel` override and the local monitor so /// pressing Cmd mid-gesture switches behavior immediately. fileprivate func handleZoomEventIfNeeded(_ event: NSEvent) -> Bool { + guard isInteractionEnabled else { return false } guard event.modifierFlags.contains(.command), event.scrollingDeltaY != 0 else { return false } let viewLocation = convert(event.locationInWindow, from: nil) let anchor = CGPoint(x: viewLocation.x, y: bounds.height - viewLocation.y) @@ -1303,6 +1324,7 @@ private class CanvasScrollContainerView: NSView { private func installMonitor() { scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in guard let self, event.window === self.window else { return event } + guard self.isInteractionEnabled else { return event } // Cmd toggled mid-gesture — switch to zoom for this event. if self.handleZoomEventIfNeeded(event) { return nil } @@ -1396,6 +1418,7 @@ private class CanvasScrollContainerView: NSView { let mask: NSEvent.EventTypeMask = [.otherMouseDown, .otherMouseDragged, .otherMouseUp] middleButtonMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in guard let self, event.window === self.window, event.buttonNumber == 2 else { return event } + guard self.isInteractionEnabled else { return event } switch event.type { case .otherMouseDown: -- 2.51.2 From f627900c6926422be9364c2e8d3c590c38b126b8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 00:08:03 +0900 Subject: [PATCH 322/528] Scrim the toolbar on expand; make the canvas scrim more transparent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar scrim (issue 1): the Canvas window toolbar has no chrome fill (toolbarChromeFill returns nil in Canvas), so it stayed transparent and the background cards showed through above the expanded card. CanvasView now reports its expanded state up via onExpandedChange; WorktreeDetailView tracks it and, while a card is expanded, applies a `.bar` material to the window toolbar (CanvasExpandedToolbarScrim) — a neutral, theme-adaptive grey that matches the in-canvas dim. This is the only way to cover the native toolbar without making the titlebar transparent app-wide. Canvas scrim (issue 3): the blur was too heavy — drop the black dim to 0.1 and take the whole scrim to opacity 0.7 so the background cards stay clearly visible behind it. --- .../Features/Canvas/Views/CanvasView.swift | 14 ++++++++-- .../Views/WorktreeDetailView.swift | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a29a6f13..d4183929 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -15,6 +15,9 @@ struct CanvasView: View { var focusRequest: CanvasFocusRequest? var onFocusedWorktreeChanged: (Worktree.ID?) -> Void = { _ in } var onFocusRequestConsumed: (Int) -> Void = { _ in } + /// Reports whether a card is currently expanded in place, so the parent can + /// give the window toolbar a matching scrim (it can't be covered from here). + var onExpandedChange: (Bool) -> Void = { _ in } @State private var layoutStore = CanvasLayoutStore() @Shared(.repositoryAppearances) private var repositoryAppearances @@ -199,6 +202,9 @@ struct CanvasView: View { organizeCardsWithFit() return .handled } + .onChange(of: expandedTabID) { _, newValue in + onExpandedChange(newValue != nil) + } .task { activateCanvas() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in configReloadCounter &+= 1 @@ -236,11 +242,13 @@ struct CanvasView: View { // it — i.e. anywhere outside the expanded card, including the padding — // restores the layout. if expandedTabID != nil { - // Material gives a GPU-efficient backdrop blur (the background cards stay - // faintly visible / running); the black overlay adds the dim on top. + // Material gives a GPU-efficient backdrop blur; a small black overlay + // adds the dim. The whole scrim is kept partly transparent so the + // background cards stay clearly visible (still running) behind it. Rectangle() .fill(.ultraThinMaterial) - .overlay(Color.black.opacity(0.2)) + .overlay(Color.black.opacity(0.1)) + .opacity(0.7) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(.rect) .accessibilityAddTraits(.isButton) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 34a815dc..0536820b 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -39,6 +39,9 @@ struct WorktreeDetailView: View { /// Drive the chrome (nav + toolbar) tint for Normal and Canvas modes. @Shared(.repositoryAppearances) private var repositoryAppearances @Shared(.settingsFile) private var settingsFile + /// True while a Canvas card is expanded in place, so the otherwise-transparent + /// Canvas toolbar gets a matching material scrim instead of showing through. + @State private var isCanvasCardExpanded = false var body: some View { detailBody(state: store.state) @@ -130,6 +133,9 @@ struct WorktreeDetailView: View { } } .windowToolbarChromeBackground(toolbarChromeFill(repositories: repositories)) + .modifier( + CanvasExpandedToolbarScrim(isActive: repositories.isShowingCanvas && isCanvasCardExpanded) + ) let actions = makeFocusedActions( repositories: repositories, hasActiveWorktree: hasActiveTerminalTarget, @@ -398,6 +404,9 @@ struct WorktreeDetailView: View { }, onFocusRequestConsumed: { requestID in store.send(.repositories(.consumeCanvasFocusRequest(requestID))) + }, + onExpandedChange: { expanded in + isCanvasCardExpanded = expanded } ) // Canvas tints the nav (leading) only; the toolbar is left untinted so @@ -1286,6 +1295,25 @@ private struct CanvasToolbarPreview: View { } } +/// Gives the window toolbar a neutral material scrim while a Canvas card is +/// expanded in place, so the otherwise-transparent Canvas toolbar doesn't show +/// the (dimmed/blurred) background cards through it. `.bar` adapts to the app +/// theme (light → pale grey, dark → dark grey). +private struct CanvasExpandedToolbarScrim: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isActive { + content + .toolbarBackground(.bar, for: .windowToolbar) + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) + } else { + content + } + } +} + #Preview("Canvas Toolbar") { CanvasToolbarPreview() } -- 2.51.2 From 6dbc9a525b99a0f4cb8b960845be9ea90b1f9af0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 00:18:05 +0900 Subject: [PATCH 323/528] Fix expand regression: toolbar scrim must not restructure the view tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added CanvasExpandedToolbarScrim as a ViewModifier that branched on `if isActive { content.modifier… } else { content }`. Toggling isActive changed the *type* of the wrapped content — which includes the whole CanvasView subtree — so SwiftUI re-created CanvasView and reset its @State. The result: pressing expand set expandedTabID, which fired onExpandedChange → isCanvasCardExpanded → the scrim branch flipped → CanvasView was rebuilt → expandedTabID went back to nil. So the card didn't expand (still showed the expand button), only a second press worked, position was off, and animations were gone. Fix: fold the scrim into the already-present windowToolbarChromeBackground modifier via a `forceMaterialScrim` flag. The same modifier is always applied — only its inputs change — so toggling expand never alters the view structure and CanvasView keeps its state. When set, the toolbar gets a `.bar` material (theme-adaptive grey) and is forced visible; otherwise the existing fill/visibility logic is untouched, so Normal and fullscreen toolbars are unaffected. --- supacode/Domain/WindowChromeTint.swift | 21 +++++++++++++--- .../Views/WorktreeDetailView.swift | 25 +++---------------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index 8c0f68b0..2f2697ca 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -215,8 +215,11 @@ extension View { /// behind full-bleed chrome, while the toolbar itself must also have an /// explicit background because macOS can stop sampling that content when /// a window is zoomed or fullscreen. - func windowToolbarChromeBackground(_ fill: WindowChromeTint.Fill?) -> some View { - modifier(WindowToolbarChromeBackgroundModifier(fill: fill)) + func windowToolbarChromeBackground( + _ fill: WindowChromeTint.Fill?, + forceMaterialScrim: Bool = false + ) -> some View { + modifier(WindowToolbarChromeBackgroundModifier(fill: fill, forceMaterialScrim: forceMaterialScrim)) } } @@ -274,17 +277,27 @@ private struct WindowChromeTintModifier: ViewModifier { private struct WindowToolbarChromeBackgroundModifier: ViewModifier { let fill: WindowChromeTint.Fill? + /// When true (a Canvas card is expanded in place), forces a neutral material + /// scrim onto the otherwise-transparent Canvas toolbar so the background cards + /// don't show through above the expanded card. Crucially this stays the same + /// modifier (only its inputs change), so toggling it never re-creates the + /// detail content / Canvas view. + let forceMaterialScrim: Bool @Environment(\.colorScheme) private var colorScheme @State private var isFullScreen = false @ViewBuilder func body(content: Content) -> some View { - let background = WindowChromeTint.fullscreenToolbarBackgroundComponents( + let colorBackground = WindowChromeTint.fullscreenToolbarBackgroundComponents( fill: fill, colorScheme: colorScheme ).color + // `.bar` is a theme-adaptive material (light → pale grey, dark → dark grey). + let background: AnyShapeStyle = + forceMaterialScrim ? AnyShapeStyle(.bar) : AnyShapeStyle(colorBackground) let visibility: Visibility = - WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: isFullScreen) ? .visible : .hidden + forceMaterialScrim || WindowChromeTint.usesExplicitToolbarBackground(isFullScreen: isFullScreen) + ? .visible : .hidden content .toolbarBackground(background, for: .windowToolbar) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 0536820b..5fffb089 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -132,9 +132,9 @@ struct WorktreeDetailView: View { ) } } - .windowToolbarChromeBackground(toolbarChromeFill(repositories: repositories)) - .modifier( - CanvasExpandedToolbarScrim(isActive: repositories.isShowingCanvas && isCanvasCardExpanded) + .windowToolbarChromeBackground( + toolbarChromeFill(repositories: repositories), + forceMaterialScrim: repositories.isShowingCanvas && isCanvasCardExpanded ) let actions = makeFocusedActions( repositories: repositories, @@ -1295,25 +1295,6 @@ private struct CanvasToolbarPreview: View { } } -/// Gives the window toolbar a neutral material scrim while a Canvas card is -/// expanded in place, so the otherwise-transparent Canvas toolbar doesn't show -/// the (dimmed/blurred) background cards through it. `.bar` adapts to the app -/// theme (light → pale grey, dark → dark grey). -private struct CanvasExpandedToolbarScrim: ViewModifier { - let isActive: Bool - - @ViewBuilder - func body(content: Content) -> some View { - if isActive { - content - .toolbarBackground(.bar, for: .windowToolbar) - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) - } else { - content - } - } -} - #Preview("Canvas Toolbar") { CanvasToolbarPreview() } -- 2.51.2 From 9323fc147a8dda2c853a72bb42981e381c783ad2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 00:51:52 +0900 Subject: [PATCH 324/528] =?UTF-8?q?Add=20Expand/Restore=20shortcut=20(?= =?UTF-8?q?=E2=8C=A5=E2=8C=98E)=20and=20palette=20commands=20for=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard: bind Expand / Restore Canvas Card to ⌥⌘E (a single toggle — there's only ever one expanded card). ⌥⌘E joins the existing canvas shortcuts ⌥⌘A/R/G and avoids the system ⌥⌘M (Minimize All). CanvasView handles the key and toggles the focused (primary) card. Command palette: surface Expand/Restore, Arrange, Organize, and Select All in the palette while in Canvas mode (the latter three weren't there before). Since these are CanvasView's view-local actions (not reducer state), route them through a one-shot reducer→view trigger modeled on CanvasFocusRequest: a new CanvasCommandRequest + RepositoriesFeature state (request/consume), AppFeature maps the palette delegates to .requestCanvasCommand, and CanvasView observes pendingCanvasCommandRequest, runs the command, and reports the id back to clear it. Wiring: new Kind/Delegate cases, viewDelegateAction mapping, palette item rows (icon/title/help/category), AppShortcuts CommandID + default shortcut + recordable command entry. Tests cover the palette items (present in Canvas mode, absent otherwise) and the request/consume reducer logic. --- supacode/App/AppShortcuts.swift | 8 +++ .../Features/App/Reducer/AppFeature.swift | 12 ++++ .../Canvas/Models/CanvasFocusRequest.swift | 16 +++++ .../Features/Canvas/Views/CanvasView.swift | 47 ++++++++++++++ .../CommandPalette/CommandPaletteItem.swift | 12 ++++ .../Reducer/CommandPaletteFeature.swift | 64 +++++++++++++++++++ .../Views/CommandPaletteOverlayView.swift | 24 ++++++- .../Reducer/RepositoriesFeature.swift | 18 ++++++ .../Views/WorktreeDetailView.swift | 4 ++ .../CommandPaletteFeatureTests.swift | 27 +++++++- supacodeTests/RepositoriesFeatureTests.swift | 31 +++++++++ 11 files changed, 259 insertions(+), 4 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index a802ab4f..77bb1665 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -142,6 +142,7 @@ enum AppShortcuts { static let selectAllCanvasCards = "select_all_canvas_cards" static let arrangeCanvasCards = "arrange_canvas_cards" static let organizeCanvasCards = "organize_canvas_cards" + static let expandCanvasCard = "expand_canvas_card" static let selectPreviousTerminalTab = "select_previous_terminal_tab" static let selectNextTerminalTab = "select_next_terminal_tab" static let selectPreviousTerminalPane = "select_previous_terminal_pane" @@ -290,6 +291,7 @@ enum AppShortcuts { static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) static let arrangeCanvasCards = AppShortcut(key: "r", modifiers: [.command, .option]) static let organizeCanvasCards = AppShortcut(key: "g", modifiers: [.command, .option]) + static let expandCanvasCard = AppShortcut(key: "e", modifiers: [.command, .option]) static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, @@ -767,6 +769,12 @@ enum AppShortcuts { scope: .localInteraction, shortcut: organizeCanvasCards ), + .init( + id: CommandID.expandCanvasCard, + title: "Expand / Restore Canvas Card", + scope: .localInteraction, + shortcut: expandCanvasCard + ), ] static func userOverrideConflicts( diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 565b5976..5b85ec15 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1207,6 +1207,18 @@ struct AppFeature { case .commandPalette(.delegate(.toggleCanvas)): return .send(.repositories(.toggleCanvas)) + case .commandPalette(.delegate(.expandCanvasCard)): + return .send(.repositories(.requestCanvasCommand(.toggleExpand))) + + case .commandPalette(.delegate(.arrangeCanvasCards)): + return .send(.repositories(.requestCanvasCommand(.arrange))) + + case .commandPalette(.delegate(.organizeCanvasCards)): + return .send(.repositories(.requestCanvasCommand(.organize))) + + case .commandPalette(.delegate(.selectAllCanvasCards)): + return .send(.repositories(.requestCanvasCommand(.selectAll))) + case .commandPalette(.delegate(.toggleShelf)): return .send(.repositories(.toggleShelf)) diff --git a/supacode/Features/Canvas/Models/CanvasFocusRequest.swift b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift index ade498c1..465a60d6 100644 --- a/supacode/Features/Canvas/Models/CanvasFocusRequest.swift +++ b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift @@ -15,6 +15,22 @@ struct CanvasFocusCandidate: Equatable, Sendable { let tabID: TerminalTabID } +/// A reducer-driven request to run one of CanvasView's view-local commands +/// (which live in CanvasView's `@State`, not the reducer) — e.g. triggered from +/// the command palette. CanvasView observes it, runs the command, and reports +/// the id back to clear it (the same one-shot pattern as `CanvasFocusRequest`). +struct CanvasCommandRequest: Equatable, Sendable { + enum Command: Equatable, Sendable { + case toggleExpand + case arrange + case organize + case selectAll + } + + let id: Int + let command: Command +} + enum CanvasFocusResolver { static func resolve( request: CanvasFocusRequest, diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index d4183929..486bffef 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -13,8 +13,12 @@ struct CanvasView: View { /// per-frame canvas hot path. var repositoryCustomTitles: [Repository.ID: String] = [:] var focusRequest: CanvasFocusRequest? + /// A one-shot, reducer-driven request to run a view-local canvas command + /// (expand/arrange/organize/select-all), e.g. from the command palette. + var commandRequest: CanvasCommandRequest? var onFocusedWorktreeChanged: (Worktree.ID?) -> Void = { _ in } var onFocusRequestConsumed: (Int) -> Void = { _ in } + var onCommandConsumed: (Int) -> Void = { _ in } /// Reports whether a card is currently expanded in place, so the parent can /// give the window toolbar a matching scrim (it can't be covered from here). var onExpandedChange: (Bool) -> Void = { _ in } @@ -82,6 +86,10 @@ struct CanvasView: View { for: AppShortcuts.CommandID.organizeCanvasCards, in: resolvedKeybindings ) + let expandCanvasShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.expandCanvasCard, + in: resolvedKeybindings + ) let _ = configReloadCounter CanvasScrollContainer( offset: $canvasOffset, @@ -202,9 +210,21 @@ struct CanvasView: View { organizeCardsWithFit() return .handled } + .onKeyPress( + expandCanvasShortcut?.keyEquivalent ?? AppShortcuts.expandCanvasCard.keyEquivalent, + phases: .down + ) { keyPress in + guard let shortcut = expandCanvasShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } + toggleExpandFocusedCard() + return .handled + } .onChange(of: expandedTabID) { _, newValue in onExpandedChange(newValue != nil) } + .onChange(of: commandRequest) { _, newRequest in + fulfillCommandRequest(newRequest) + } .task { activateCanvas() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in configReloadCounter &+= 1 @@ -856,6 +876,33 @@ struct CanvasView: View { } } + /// Toggle expand/restore for the focused (primary) card. Used by the keyboard + /// shortcut and the command palette, which target whichever card is focused. + private func toggleExpandFocusedCard() { + if expandedTabID != nil { + collapseExpand() + } else if let tabID = selectionState.primaryTabID { + expandCard(tabID, states: terminalManager.activeWorktreeStates) + } + } + + /// Run a reducer-driven canvas command (from the command palette) and clear + /// the one-shot request. + private func fulfillCommandRequest(_ request: CanvasCommandRequest?) { + guard let request else { return } + switch request.command { + case .toggleExpand: + toggleExpandFocusedCard() + case .arrange: + arrangeCardsWithFit() + case .organize: + organizeCardsWithFit() + case .selectAll: + selectAllCards() + } + onCommandConsumed(request.id) + } + /// Expand a card in place: raise it to the top, then flip `expandedTabID` /// inside one `withAnimation` so the card's size, center, and scale all /// interpolate together from its in-canvas frame to the full viewport at diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index dc178ecc..163313d5 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -64,6 +64,10 @@ struct CommandPaletteItem: Identifiable, Equatable { case toggleLeftSidebar case toggleActiveAgentsPanel case toggleCanvas + case expandCanvasCard + case arrangeCanvasCards + case organizeCanvasCards + case selectAllCanvasCards case toggleShelf case showDiff case revealInFinder @@ -120,6 +124,14 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.toggleActiveAgentsPanel case .toggleCanvas: return AppShortcuts.CommandID.toggleCanvas + case .expandCanvasCard: + return AppShortcuts.CommandID.expandCanvasCard + case .arrangeCanvasCards: + return AppShortcuts.CommandID.arrangeCanvasCards + case .organizeCanvasCards: + return AppShortcuts.CommandID.organizeCanvasCards + case .selectAllCanvasCards: + return AppShortcuts.CommandID.selectAllCanvasCards case .toggleShelf: return AppShortcuts.CommandID.toggleShelf case .showDiff: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 4cc069dd..6ff01eb0 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -54,6 +54,10 @@ struct CommandPaletteFeature { case toggleLeftSidebar case toggleActiveAgentsPanel case toggleCanvas + case expandCanvasCard + case arrangeCanvasCards + case organizeCanvasCards + case selectAllCanvasCards case toggleShelf case showDiff case revealInFinder @@ -224,6 +228,9 @@ struct CommandPaletteFeature { repositories.repositories.isEmpty || repositories.repositories.contains { $0.capabilities.supportsWorktrees } var items = globalCommandItems(showsNewWorktreeAction: showsNewWorktreeAction) + if repositories.isShowingCanvas { + items.append(contentsOf: canvasCommandItems()) + } let worktreeActionTargetID = actionTargetWorktreeID ?? repositories.selectedWorktreeID if repositories.selectedWorktreeID != nil { items.append( @@ -580,6 +587,39 @@ private func viewToggleCommandItems() -> [CommandPaletteItem] { ] } +private func canvasCommandItems() -> [CommandPaletteItem] { + [ + .appShortcut( + id: CommandPaletteItemID.globalExpandCanvasCard, + title: "Expand / Restore Canvas Card", + category: .view, + kind: .expandCanvasCard, + keywords: ["canvas", "expand", "restore", "focus", "fullscreen", "card"] + ), + .appShortcut( + id: CommandPaletteItemID.globalArrangeCanvasCards, + title: "Arrange Canvas Cards", + category: .view, + kind: .arrangeCanvasCards, + keywords: ["canvas", "arrange", "layout", "pack", "fit"] + ), + .appShortcut( + id: CommandPaletteItemID.globalOrganizeCanvasCards, + title: "Organize Canvas Cards", + category: .view, + kind: .organizeCanvasCards, + keywords: ["canvas", "organize", "grid", "tidy", "uniform"] + ), + .appShortcut( + id: CommandPaletteItemID.globalSelectAllCanvasCards, + title: "Select All Canvas Cards", + category: .view, + kind: .selectAllCanvasCards, + keywords: ["canvas", "select all", "broadcast"] + ), + ] +} + private func selectedCodeHostItems( from repositories: RepositoriesFeature.State ) -> [CommandPaletteItem] { @@ -858,6 +898,10 @@ private enum CommandPaletteItemID { static let globalToggleLeftSidebar = "global.toggle-left-sidebar" static let globalToggleActiveAgentsPanel = "global.toggle-active-agents-panel" static let globalToggleCanvas = "global.toggle-canvas" + static let globalExpandCanvasCard = "global.expand-canvas-card" + static let globalArrangeCanvasCards = "global.arrange-canvas-cards" + static let globalOrganizeCanvasCards = "global.organize-canvas-cards" + static let globalSelectAllCanvasCards = "global.select-all-canvas-cards" static let globalToggleShelf = "global.toggle-shelf" static let globalShowDiff = "global.show-diff" static let globalRevealInFinder = "global.reveal-in-finder" @@ -890,6 +934,10 @@ private enum CommandPaletteItemID { globalToggleLeftSidebar, globalToggleActiveAgentsPanel, globalToggleCanvas, + globalExpandCanvasCard, + globalArrangeCanvasCards, + globalOrganizeCanvasCards, + globalSelectAllCanvasCards, globalToggleShelf, globalShowDiff, globalRevealInFinder, @@ -1021,6 +1069,10 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, + .arrangeCanvasCards, + .organizeCanvasCards, + .selectAllCanvasCards, .toggleShelf, .showDiff, .revealInFinder, @@ -1089,6 +1141,14 @@ private func viewDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPal return .toggleActiveAgentsPanel case .toggleCanvas: return .toggleCanvas + case .expandCanvasCard: + return .expandCanvasCard + case .arrangeCanvasCards: + return .arrangeCanvasCards + case .organizeCanvasCards: + return .organizeCanvasCards + case .selectAllCanvasCards: + return .selectAllCanvasCards case .toggleShelf: return .toggleShelf case .showDiff: @@ -1133,6 +1193,10 @@ private func pullRequestDelegateAction( .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, + .arrangeCanvasCards, + .organizeCanvasCards, + .selectAllCanvasCards, .toggleShelf, .showDiff, .revealInFinder, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 0e26fdab..d92a9921 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -526,7 +526,9 @@ private struct CommandPaletteRowView: View { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings, .runCustomCommand: @@ -586,6 +588,14 @@ private struct CommandPaletteRowView: View { return "person.crop.rectangle.stack" case .toggleCanvas: return "square.grid.2x2" + case .expandCanvasCard: + return "arrow.up.left.and.arrow.down.right" + case .arrangeCanvasCards: + return "rectangle.3.group" + case .organizeCanvasCards: + return "square.grid.2x2" + case .selectAllCanvasCards: + return "checkmark.rectangle.stack" case .toggleShelf: return "books.vertical" case .showDiff: @@ -629,7 +639,9 @@ private struct CommandPaletteRowView: View { .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings, @@ -754,6 +766,14 @@ private struct CommandPaletteRowView: View { base = "Toggle Active Agents Panel" case .toggleCanvas: base = "Toggle Canvas" + case .expandCanvasCard: + base = "Expand / Restore Canvas Card" + case .arrangeCanvasCards: + base = "Arrange Canvas Cards" + case .organizeCanvasCards: + base = "Organize Canvas Cards" + case .selectAllCanvasCards: + base = "Select All Canvas Cards" case .toggleShelf: base = "Toggle Shelf" case .showDiff: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 13ff2ce0..9644c358 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -277,6 +277,8 @@ struct RepositoriesFeature { var showActiveAgentTabTitles = false var nextCanvasFocusRequestID = 0 var pendingCanvasFocusRequest: CanvasFocusRequest? + var nextCanvasCommandRequestID = 0 + var pendingCanvasCommandRequest: CanvasCommandRequest? var activeAgents = ActiveAgentsFeature.State() @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @@ -352,6 +354,8 @@ struct RepositoriesFeature { case selectNextWorktree case selectPreviousWorktree case consumeCanvasFocusRequest(Int) + case requestCanvasCommand(CanvasCommandRequest.Command) + case consumeCanvasCommandRequest(Int) case worktreeHistoryBack case worktreeHistoryForward case revealSelectedWorktreeInSidebar @@ -1042,6 +1046,20 @@ struct RepositoriesFeature { } return .none + case .requestCanvasCommand(let command): + state.nextCanvasCommandRequestID += 1 + state.pendingCanvasCommandRequest = CanvasCommandRequest( + id: state.nextCanvasCommandRequestID, + command: command + ) + return .none + + case .consumeCanvasCommandRequest(let id): + if state.pendingCanvasCommandRequest?.id == id { + state.pendingCanvasCommandRequest = nil + } + return .none + case .worktreeHistoryBack: return navigateWorktreeHistory(direction: .backward, state: &state) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 5fffb089..e272dd22 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -399,12 +399,16 @@ struct WorktreeDetailView: View { terminalManager: terminalManager, repositoryCustomTitles: repositories.repositoryCustomTitles, focusRequest: repositories.pendingCanvasFocusRequest, + commandRequest: repositories.pendingCanvasCommandRequest, onFocusedWorktreeChanged: { worktreeID in store.send(.canvasFocusedWorktreeChanged(worktreeID)) }, onFocusRequestConsumed: { requestID in store.send(.repositories(.consumeCanvasFocusRequest(requestID))) }, + onCommandConsumed: { requestID in + store.send(.repositories(.consumeCanvasCommandRequest(requestID))) + }, onExpandedChange: { expanded in isCanvasCardExpanded = expanded } diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 1d6255ce..cd3f46f5 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -46,6 +46,25 @@ struct CommandPaletteFeatureTests { #expect(items.contains { $0.id == "global.show-diff" }) } + @Test func commandPaletteItems_includesCanvasCommandsInCanvasMode() { + var state = RepositoriesFeature.State() + state.selection = .canvas + + let ids = CommandPaletteFeature.commandPaletteItems(from: state).map(\.id) + #expect(ids.contains("global.expand-canvas-card")) + #expect(ids.contains("global.arrange-canvas-cards")) + #expect(ids.contains("global.organize-canvas-cards")) + #expect(ids.contains("global.select-all-canvas-cards")) + } + + @Test func commandPaletteItems_omitsCanvasCommandsOutsideCanvas() { + let ids = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()).map(\.id) + #expect(!ids.contains("global.expand-canvas-card")) + #expect(!ids.contains("global.arrange-canvas-cards")) + #expect(!ids.contains("global.organize-canvas-cards")) + #expect(!ids.contains("global.select-all-canvas-cards")) + } + @Test func commandPaletteItems_omitsShowDiffWithoutSelectedWorktree() { let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) #expect(!items.contains { $0.id == "global.show-diff" }) @@ -1701,7 +1720,9 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt return .pullRequest case .ghosttyCommand: return .terminal - case .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff: + case .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .toggleShelf, .showDiff: return .view #if DEBUG case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: @@ -1716,7 +1737,9 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, - .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, .toggleShelf, .showDiff, + .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings: diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 21981b49..6a38aeae 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -11,6 +11,37 @@ import Testing @MainActor struct RepositoriesFeatureTests { + @Test func requestCanvasCommandSetsPendingRequestWithIncrementingID() async { + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + + await store.send(.requestCanvasCommand(.toggleExpand)) { + $0.nextCanvasCommandRequestID = 1 + $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 1, command: .toggleExpand) + } + await store.send(.requestCanvasCommand(.arrange)) { + $0.nextCanvasCommandRequestID = 2 + $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 2, command: .arrange) + } + } + + @Test func consumeCanvasCommandRequestClearsOnlyMatchingID() async { + var initialState = RepositoriesFeature.State() + initialState.nextCanvasCommandRequestID = 5 + initialState.pendingCanvasCommandRequest = CanvasCommandRequest(id: 5, command: .organize) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + // A stale id is ignored. + await store.send(.consumeCanvasCommandRequest(4)) + // The matching id clears the request. + await store.send(.consumeCanvasCommandRequest(5)) { + $0.pendingCanvasCommandRequest = nil + } + } + @Test func refreshWorktreesSetsRefreshingStateUntilLoadCompletes() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) -- 2.51.2 From 12e158c222ff55ef65d5a8dd53eec26fc7a8cc64 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 00:58:20 +0900 Subject: [PATCH 325/528] Show the expand shortcut in the card button tooltip and canvas help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The card title-bar expand/restore button's tooltip now includes the bound shortcut, e.g. "Expand card (⌥⌘E)". CanvasView resolves it via AppShortcuts.helpText (CanvasCardView has no keybindings context) and passes it in as `expandHelp`. - The canvas navigation help popover (bottom-left) gains an "Expand / restore card" row showing the resolved shortcut and the title-bar button as alternatives. --- .../Features/Canvas/Views/CanvasCardView.swift | 5 ++++- .../Features/Canvas/Views/CanvasView.swift | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index d77d4289..69bfd8b6 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -27,6 +27,9 @@ struct CanvasCardView: View { /// true the title-bar button restores instead of expands, resize handles and /// title-bar dragging are disabled, and the action buttons stay visible. let isExpanded: Bool + /// Tooltip for the expand/restore button, including the bound shortcut (the + /// parent resolves it since CanvasCardView has no keybindings context). + let expandHelp: String let canvasScale: CGFloat let showsSelectionShield: Bool let onTap: () -> Void @@ -186,7 +189,7 @@ struct CanvasCardView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help(isExpanded ? "Restore card size" : "Expand card") + .help(expandHelp) .accessibilityLabel(isExpanded ? "Restore card size" : "Expand card") Button { diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 486bffef..bcb8236c 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -291,6 +291,11 @@ struct CanvasView: View { let cardKey = tab.id.rawValue.uuidString let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) let isCardExpanded = expandedTabID == tab.id + let expandHelp = AppShortcuts.helpText( + title: isCardExpanded ? "Restore card size" : "Expand card", + commandID: AppShortcuts.CommandID.expandCanvasCard, + in: resolvedKeybindings + ) // The expanded card magic-moves between its in-canvas frame and the full // viewport. AnimatedExpandableCard drives every sub-value (size, center, // scale) from one animatable progress, so they advance frame by frame in @@ -323,6 +328,7 @@ struct CanvasView: View { hasUnseenNotification: state.hasUnseenNotification(for: tab.id), cardSize: renderSize, isExpanded: isCardExpanded, + expandHelp: expandHelp, canvasScale: isCardExpanded ? 1 : canvasScale, showsSelectionShield: showsSelectionShield(for: tab.id), onTap: { @@ -662,7 +668,11 @@ struct CanvasView: View { } private var canvasHelpContent: some View { - VStack(alignment: .leading, spacing: 14) { + let expandShortcut = AppShortcuts.display( + for: AppShortcuts.CommandID.expandCanvasCard, + in: resolvedKeybindings + ) + return VStack(alignment: .leading, spacing: 14) { Text("Canvas Navigation") .font(.headline) @@ -677,6 +687,12 @@ struct CanvasView: View { title: "Pan canvas", detail: "Drag empty area, middle-click drag, or two-finger swipe" ) + canvasHelpRow( + icon: "arrow.up.left.and.arrow.down.right", + title: "Expand / restore card", + detail: expandShortcut.map { "\($0), or the card's title-bar button" } + ?? "Use the card's title-bar button" + ) } } .padding() -- 2.51.2 From bbec4a672ba019e51d796739d49ff52f5f8177eb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:17:43 +0900 Subject: [PATCH 326/528] Split Git and GitHub client helpers --- supacode/Clients/Git/GitClient.swift | 122 ------------- .../Clients/Git/GitClientShellHelpers.swift | 49 ++++++ supacode/Clients/Git/GitClientTypes.swift | 74 ++++++++ supacode/Clients/Github/GithubCLIClient.swift | 165 ------------------ .../Github/GithubCLIExecutableResolver.swift | 77 ++++++++ supacode/Clients/Github/GithubCLIModels.swift | 90 ++++++++++ 6 files changed, 290 insertions(+), 287 deletions(-) create mode 100644 supacode/Clients/Git/GitClientShellHelpers.swift create mode 100644 supacode/Clients/Git/GitClientTypes.swift create mode 100644 supacode/Clients/Github/GithubCLIExecutableResolver.swift create mode 100644 supacode/Clients/Github/GithubCLIModels.swift diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 7c756685..5bfc132f 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -1,64 +1,4 @@ import Foundation -import Sentry - -enum GitOperation: String { - case repoRoot = "repo_root" - case worktreeList = "worktree_list" - case worktreeCreate = "worktree_create" - case worktreeRemove = "worktree_remove" - case worktreePrune = "worktree_prune" - case repoIsBare = "repo_is_bare" - case branchNames = "branch_names" - case branchNameValidation = "branch_name_validation" - case branchRefs = "branch_refs" - case defaultRemoteBranchRef = "default_remote_branch_ref" - case localHeadRef = "local_head_ref" - case ignoredFileCount = "ignored_file_count" - case untrackedFileCount = "untracked_file_count" - case branchRename = "branch_rename" - case branchDelete = "branch_delete" - case lineChanges = "line_changes" - case diffNameStatus = "diff_name_status" - case untrackedFilePaths = "untracked_file_paths" - case showFile = "show_file" - case remoteInfo = "remote_info" - case remoteList = "remote_list" - case fetchRemote = "fetch_remote" -} - -enum GitClientError: LocalizedError { - case commandFailed(command: String, message: String) - - var errorDescription: String? { - switch self { - case .commandFailed(let command, let message): - if message.isEmpty { - return "Git command failed: \(command)" - } - return "Git command failed: \(command)\n\(message)" - } - } -} - -enum GitWorktreeCreateEvent: Equatable, Sendable { - case outputLine(ShellStreamLine) - case finished(Worktree) -} - -enum LocalBranchDeletionOutcome: Equatable, Sendable { - case deleted - case notFound - case protected - case notRequested -} - -nonisolated enum GitRemoteMatcher { - static func matchingRemote(for ref: String, from remotes: [String]) -> String? { - remotes - .sorted { $0.count > $1.count } - .first { ref.hasPrefix("\($0)/") } - } -} struct GitClient { private struct WorktreeSortEntry { @@ -1018,65 +958,3 @@ struct GitClient { } } - -private nonisolated let gitLogger = SupaLogger("Git") - -nonisolated private func shouldFallbackToLoginShell(_ error: Error) -> Bool { - guard let shellError = error as? ShellClientError else { - return false - } - if shellError.exitCode == 127 { - return true - } - let output = "\(shellError.stderr)\n\(shellError.stdout)".lowercased() - return output.contains("command not found") -} - -nonisolated private func wrapShellError( - _ error: Error, - operation: GitOperation, - command: String -) -> GitClientError { - let gitError: GitClientError - var exitCode: Int32 = -1 - if let shellError = error as? ShellClientError { - exitCode = shellError.exitCode - var messageParts: [String] = [] - if !shellError.stdout.isEmpty { - messageParts.append("stdout:\n\(shellError.stdout)") - } - if !shellError.stderr.isEmpty { - messageParts.append("stderr:\n\(shellError.stderr)") - } - let message = messageParts.joined(separator: "\n") - gitError = .commandFailed(command: command, message: message) - } else { - gitError = .commandFailed(command: command, message: error.localizedDescription) - } - gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=\(exitCode)") - #if !DEBUG - SentrySDK.logger.error( - "git command failed", - attributes: [ - "operation": operation.rawValue, - "exit_code": Int(exitCode), - ] - ) - #endif - return gitError -} - -struct GitWtWorktreeEntry: Decodable, Equatable { - let branch: String - let path: String - let head: String - let isBare: Bool - - enum CodingKeys: String, CodingKey { - case branch - case path - case head - case isBare = "is_bare" - } - -} diff --git a/supacode/Clients/Git/GitClientShellHelpers.swift b/supacode/Clients/Git/GitClientShellHelpers.swift new file mode 100644 index 00000000..8b7ae55e --- /dev/null +++ b/supacode/Clients/Git/GitClientShellHelpers.swift @@ -0,0 +1,49 @@ +import Foundation +import Sentry + +nonisolated let gitLogger = SupaLogger("Git") + +nonisolated func shouldFallbackToLoginShell(_ error: Error) -> Bool { + guard let shellError = error as? ShellClientError else { + return false + } + if shellError.exitCode == 127 { + return true + } + let output = "\(shellError.stderr)\n\(shellError.stdout)".lowercased() + return output.contains("command not found") +} + +nonisolated func wrapShellError( + _ error: Error, + operation: GitOperation, + command: String +) -> GitClientError { + let gitError: GitClientError + var exitCode: Int32 = -1 + if let shellError = error as? ShellClientError { + exitCode = shellError.exitCode + var messageParts: [String] = [] + if !shellError.stdout.isEmpty { + messageParts.append("stdout:\n\(shellError.stdout)") + } + if !shellError.stderr.isEmpty { + messageParts.append("stderr:\n\(shellError.stderr)") + } + let message = messageParts.joined(separator: "\n") + gitError = .commandFailed(command: command, message: message) + } else { + gitError = .commandFailed(command: command, message: error.localizedDescription) + } + gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=\(exitCode)") + #if !DEBUG + SentrySDK.logger.error( + "git command failed", + attributes: [ + "operation": operation.rawValue, + "exit_code": Int(exitCode), + ] + ) + #endif + return gitError +} diff --git a/supacode/Clients/Git/GitClientTypes.swift b/supacode/Clients/Git/GitClientTypes.swift new file mode 100644 index 00000000..d8b629c6 --- /dev/null +++ b/supacode/Clients/Git/GitClientTypes.swift @@ -0,0 +1,74 @@ +import Foundation + +enum GitOperation: String { + case repoRoot = "repo_root" + case worktreeList = "worktree_list" + case worktreeCreate = "worktree_create" + case worktreeRemove = "worktree_remove" + case worktreePrune = "worktree_prune" + case repoIsBare = "repo_is_bare" + case branchNames = "branch_names" + case branchNameValidation = "branch_name_validation" + case branchRefs = "branch_refs" + case defaultRemoteBranchRef = "default_remote_branch_ref" + case localHeadRef = "local_head_ref" + case ignoredFileCount = "ignored_file_count" + case untrackedFileCount = "untracked_file_count" + case branchRename = "branch_rename" + case branchDelete = "branch_delete" + case lineChanges = "line_changes" + case diffNameStatus = "diff_name_status" + case untrackedFilePaths = "untracked_file_paths" + case showFile = "show_file" + case remoteInfo = "remote_info" + case remoteList = "remote_list" + case fetchRemote = "fetch_remote" +} + +enum GitClientError: LocalizedError { + case commandFailed(command: String, message: String) + + var errorDescription: String? { + switch self { + case .commandFailed(let command, let message): + if message.isEmpty { + return "Git command failed: \(command)" + } + return "Git command failed: \(command)\n\(message)" + } + } +} + +enum GitWorktreeCreateEvent: Equatable, Sendable { + case outputLine(ShellStreamLine) + case finished(Worktree) +} + +enum LocalBranchDeletionOutcome: Equatable, Sendable { + case deleted + case notFound + case protected + case notRequested +} + +nonisolated enum GitRemoteMatcher { + static func matchingRemote(for ref: String, from remotes: [String]) -> String? { + remotes + .sorted { $0.count > $1.count } + .first { ref.hasPrefix("\($0)/") } + } +} + +struct GitWtWorktreeEntry: Decodable, Equatable { + let branch: String + let path: String + let head: String + let isBare: Bool + + enum CodingKeys: String, CodingKey { + case branch + case path + case head + case isBare = "is_bare" + } +} diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 1f765d8f..61d784d1 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -2,72 +2,6 @@ import ComposableArchitecture import Darwin import Foundation -struct GithubAuthStatus: Equatable, Sendable { - let username: String - let host: String -} - -private struct GithubAuthStatusResponse: Sendable { - let hosts: [String: [GithubAuthAccount]] - - struct GithubAuthAccount: Sendable { - let active: Bool - let login: String - } -} - -extension GithubAuthStatusResponse: Decodable { - private enum CodingKeys: String, CodingKey { - case hosts - } - - nonisolated init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.hosts = try container.decode([String: [GithubAuthAccount]].self, forKey: .hosts) - } -} - -extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { - private enum CodingKeys: String, CodingKey { - case active - case login - } - - nonisolated init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.active = try container.decode(Bool.self, forKey: .active) - self.login = try container.decode(String.self, forKey: .login) - } -} - -nonisolated struct RepoKey: Hashable, Sendable { - let owner: String - let repo: String -} - -nonisolated struct CrossRepoPullRequestRequest: Sendable, Hashable { - let owner: String - let repo: String - let branches: [String] - - var key: RepoKey { - RepoKey(owner: owner, repo: repo) - } -} - -nonisolated struct CrossRepoPullRequestResult: Sendable { - let successByRepo: [RepoKey: [String: GithubPullRequest]] - let failedRepos: [RepoKey: GithubCLIError] - - init( - successByRepo: [RepoKey: [String: GithubPullRequest]] = [:], - failedRepos: [RepoKey: GithubCLIError] = [:] - ) { - self.successByRepo = successByRepo - self.failedRepos = failedRepos - } -} - struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? @@ -131,105 +65,6 @@ extension DependencyValues { } } -private struct GithubPullRequestsRequest: Sendable { - let host: String - let owner: String - let repo: String -} - -nonisolated private struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { - let owner: Owner - let name: String - let url: String - - nonisolated var remoteInfo: GithubRemoteInfo? { - guard let host = URL(string: url)?.host else { - return nil - } - return GithubRemoteInfo(host: host, owner: owner.login, repo: name) - } - - nonisolated struct Owner: Decodable, Sendable { - let login: String - } -} - -private actor GithubCLIExecutableResolver { - private var cachedExecutableURL: URL? - private var inFlightResolution: Task? - - func executableURL(shell: ShellClient) async throws -> URL { - if let cachedExecutableURL { - return cachedExecutableURL - } - if let inFlightResolution { - return try await inFlightResolution.value - } - let resolutionTask = Task { - try await resolveExecutableURL(shell: shell) - } - inFlightResolution = resolutionTask - do { - let executableURL = try await resolutionTask.value - cachedExecutableURL = executableURL - inFlightResolution = nil - return executableURL - } catch { - inFlightResolution = nil - throw error - } - } - - func invalidate() { - cachedExecutableURL = nil - inFlightResolution?.cancel() - inFlightResolution = nil - } - - private func resolveExecutableURL(shell: ShellClient) async throws -> URL { - if let executableURL = await locateExecutableURL( - shell: shell, - useLoginShell: false - ) { - return executableURL - } - if let executableURL = await locateExecutableURL( - shell: shell, - useLoginShell: true - ) { - return executableURL - } - throw GithubCLIError.unavailable - } - - private func locateExecutableURL( - shell: ShellClient, - useLoginShell: Bool - ) async -> URL? { - let whichURL = URL(fileURLWithPath: "/usr/bin/which") - do { - let output: String - if useLoginShell { - output = try await shell.runLogin( - whichURL, - ["gh"], - nil, - log: false - ).stdout - } else { - output = try await shell.run(whichURL, ["gh"], nil).stdout - } - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return URL(fileURLWithPath: trimmed) - } catch { - return nil - } - } -} - nonisolated private func defaultBranchFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver diff --git a/supacode/Clients/Github/GithubCLIExecutableResolver.swift b/supacode/Clients/Github/GithubCLIExecutableResolver.swift new file mode 100644 index 00000000..4e95d990 --- /dev/null +++ b/supacode/Clients/Github/GithubCLIExecutableResolver.swift @@ -0,0 +1,77 @@ +import Foundation + +actor GithubCLIExecutableResolver { + private var cachedExecutableURL: URL? + private var inFlightResolution: Task? + + func executableURL(shell: ShellClient) async throws -> URL { + if let cachedExecutableURL { + return cachedExecutableURL + } + if let inFlightResolution { + return try await inFlightResolution.value + } + let resolutionTask = Task { + try await resolveExecutableURL(shell: shell) + } + inFlightResolution = resolutionTask + do { + let executableURL = try await resolutionTask.value + cachedExecutableURL = executableURL + inFlightResolution = nil + return executableURL + } catch { + inFlightResolution = nil + throw error + } + } + + func invalidate() { + cachedExecutableURL = nil + inFlightResolution?.cancel() + inFlightResolution = nil + } + + private func resolveExecutableURL(shell: ShellClient) async throws -> URL { + if let executableURL = await locateExecutableURL( + shell: shell, + useLoginShell: false + ) { + return executableURL + } + if let executableURL = await locateExecutableURL( + shell: shell, + useLoginShell: true + ) { + return executableURL + } + throw GithubCLIError.unavailable + } + + private func locateExecutableURL( + shell: ShellClient, + useLoginShell: Bool + ) async -> URL? { + let whichURL = URL(fileURLWithPath: "/usr/bin/which") + do { + let output: String + if useLoginShell { + output = try await shell.runLogin( + whichURL, + ["gh"], + nil, + log: false + ).stdout + } else { + output = try await shell.run(whichURL, ["gh"], nil).stdout + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return URL(fileURLWithPath: trimmed) + } catch { + return nil + } + } +} diff --git a/supacode/Clients/Github/GithubCLIModels.swift b/supacode/Clients/Github/GithubCLIModels.swift new file mode 100644 index 00000000..a6ff9c25 --- /dev/null +++ b/supacode/Clients/Github/GithubCLIModels.swift @@ -0,0 +1,90 @@ +import Foundation + +struct GithubAuthStatus: Equatable, Sendable { + let username: String + let host: String +} + +struct GithubAuthStatusResponse: Sendable { + let hosts: [String: [GithubAuthAccount]] + + struct GithubAuthAccount: Sendable { + let active: Bool + let login: String + } +} + +extension GithubAuthStatusResponse: Decodable { + private enum CodingKeys: String, CodingKey { + case hosts + } + + nonisolated init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.hosts = try container.decode([String: [GithubAuthAccount]].self, forKey: .hosts) + } +} + +extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { + private enum CodingKeys: String, CodingKey { + case active + case login + } + + nonisolated init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.active = try container.decode(Bool.self, forKey: .active) + self.login = try container.decode(String.self, forKey: .login) + } +} + +nonisolated struct RepoKey: Hashable, Sendable { + let owner: String + let repo: String +} + +nonisolated struct CrossRepoPullRequestRequest: Sendable, Hashable { + let owner: String + let repo: String + let branches: [String] + + var key: RepoKey { + RepoKey(owner: owner, repo: repo) + } +} + +nonisolated struct CrossRepoPullRequestResult: Sendable { + let successByRepo: [RepoKey: [String: GithubPullRequest]] + let failedRepos: [RepoKey: GithubCLIError] + + init( + successByRepo: [RepoKey: [String: GithubPullRequest]] = [:], + failedRepos: [RepoKey: GithubCLIError] = [:] + ) { + self.successByRepo = successByRepo + self.failedRepos = failedRepos + } +} + +struct GithubPullRequestsRequest: Sendable { + let host: String + let owner: String + let repo: String +} + +nonisolated struct GithubRepoViewRemoteInfoResponse: Decodable, Sendable { + let owner: Owner + let name: String + let url: String + + nonisolated var remoteInfo: GithubRemoteInfo? { + guard let host = URL(string: url)?.host else { + return nil + } + return GithubRemoteInfo(host: host, owner: owner.login, repo: name) + } + + nonisolated struct Owner: Decodable, Sendable { + let login: String + } +} -- 2.51.2 From 5bc281aace7d04e9ba7788cff91055f1d4cf3fa5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:21:06 +0900 Subject: [PATCH 327/528] Split worktree info monitor implementations --- .../BusinessLogic/WorktreeInfoMonitors.swift | 246 ++++++++++++++++++ .../WorktreeInfoWatcherManager.swift | 243 ----------------- 2 files changed, 246 insertions(+), 243 deletions(-) create mode 100644 supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift new file mode 100644 index 00000000..5ccf6553 --- /dev/null +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift @@ -0,0 +1,246 @@ +import CoreServices +import Darwin +import Dispatch +import Foundation + +@MainActor +protocol WorktreeFileEventMonitoring: AnyObject { + func cancel() +} + +@MainActor +protocol WorktreeRegistryMonitoring: AnyObject { + func cancel() +} + +final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private var stream: FSEventStreamRef? + + init?( + rootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) { + self.onEvent = onEvent + let path = rootURL.path(percentEncoded: false) + var context = FSEventStreamContext( + version: 0, + info: nil, + retain: nil, + release: nil, + copyDescription: nil + ) + context.info = Unmanaged.passUnretained(self).toOpaque() + let callback: FSEventStreamCallback = { _, callbackInfo, _, _, _, _ in + guard let callbackInfo else { return } + let monitor = Unmanaged + .fromOpaque(callbackInfo) + .takeUnretainedValue() + Task { @MainActor in + monitor.onEvent() + } + } + stream = FSEventStreamCreate( + nil, + callback, + &context, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, + FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer + | kFSEventStreamCreateFlagWatchRoot + ) + ) + guard let stream else { + return nil + } + FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + return nil + } + } + + func cancel() { + guard let stream else { + return + } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } +} + +@MainActor +final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { + private let commonGitDirectoryURL: URL + private let worktreesDirectoryURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private var commonDirectorySource: DispatchSourceFileSystemObject? + private var worktreesDirectorySource: DispatchSourceFileSystemObject? + private var isWorktreesDirectoryPresent: Bool + + init?( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void, + fileManager: FileManager = .default + ) { + guard + let commonGitDirectoryURL = Self.commonGitDirectoryURL( + for: repositoryRootURL, + fileManager: fileManager + ) + else { + return nil + } + self.commonGitDirectoryURL = commonGitDirectoryURL + worktreesDirectoryURL = commonGitDirectoryURL.appending(path: "worktrees") + self.onEvent = onEvent + isWorktreesDirectoryPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: fileManager) + commonDirectorySource = Self.makeDirectorySource(url: commonGitDirectoryURL) { [weak self] in + self?.handleCommonDirectoryEvent() + } + if isWorktreesDirectoryPresent { + startWorktreesDirectorySourceIfNeeded() + } + guard commonDirectorySource != nil || worktreesDirectorySource != nil else { + return nil + } + } + + func cancel() { + commonDirectorySource?.cancel() + worktreesDirectorySource?.cancel() + commonDirectorySource = nil + worktreesDirectorySource = nil + } + + private func handleCommonDirectoryEvent() { + let isPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: .default) + guard isPresent != isWorktreesDirectoryPresent else { + return + } + isWorktreesDirectoryPresent = isPresent + if isPresent { + startWorktreesDirectorySourceIfNeeded() + } else { + worktreesDirectorySource?.cancel() + worktreesDirectorySource = nil + } + onEvent() + } + + private func handleWorktreesDirectoryEvent() { + guard Self.isDirectory(worktreesDirectoryURL, fileManager: .default) else { + if isWorktreesDirectoryPresent { + isWorktreesDirectoryPresent = false + worktreesDirectorySource?.cancel() + worktreesDirectorySource = nil + onEvent() + } + return + } + onEvent() + } + + private func startWorktreesDirectorySourceIfNeeded() { + guard worktreesDirectorySource == nil else { + return + } + worktreesDirectorySource = Self.makeDirectorySource(url: worktreesDirectoryURL) { [weak self] in + self?.handleWorktreesDirectoryEvent() + } + } + + private static func makeDirectorySource( + url: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> DispatchSourceFileSystemObject? { + let fileDescriptor = open(url.path(percentEncoded: false), O_EVTONLY) + guard fileDescriptor >= 0 else { + return nil + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .rename, .delete, .attrib], + queue: .main + ) + source.setEventHandler { + onEvent() + } + source.setCancelHandler { + close(fileDescriptor) + } + source.resume() + return source + } + + private static func commonGitDirectoryURL( + for repositoryRootURL: URL, + fileManager: FileManager + ) -> URL? { + let repositoryRootURL = repositoryRootURL.standardizedFileURL + let dotGitURL = repositoryRootURL.appending(path: ".git") + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { + if isDirectory.boolValue { + return dotGitURL.standardizedFileURL + } + guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { + return nil + } + return commonDirectoryURL(from: gitdirURL, fileManager: fileManager) + } + if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), + fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) + { + return repositoryRootURL + } + return nil + } + + private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { + guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "gitdir:" + guard trimmed.hasPrefix(prefix) else { + return nil + } + let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return nil + } + return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) + .standardizedFileURL + } + + private static func commonDirectoryURL(from gitdirURL: URL, fileManager: FileManager) -> URL { + let commondirURL = gitdirURL.appending(path: "commondir") + guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return gitdirURL.standardizedFileURL + } + let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return gitdirURL.standardizedFileURL + } + return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) + .standardizedFileURL + } + + private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory = ObjCBool(false) + return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + && isDirectory.boolValue + } +} diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 2e8487c9..4172d9ee 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,18 +1,7 @@ -import CoreServices import Darwin import Dispatch import Foundation -@MainActor -protocol WorktreeFileEventMonitoring: AnyObject { - func cancel() -} - -@MainActor -protocol WorktreeRegistryMonitoring: AnyObject { - func cancel() -} - @MainActor final class WorktreeInfoWatcherManager { typealias WorktreePhaseOffset = @Sendable (Worktree.ID, Duration) -> Duration @@ -772,235 +761,3 @@ final class WorktreeInfoWatcherManager { return true } } - -private final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { - private let onEvent: @MainActor @Sendable () -> Void - private var stream: FSEventStreamRef? - - init?( - rootURL: URL, - onEvent: @escaping @MainActor @Sendable () -> Void - ) { - self.onEvent = onEvent - let path = rootURL.path(percentEncoded: false) - var context = FSEventStreamContext( - version: 0, - info: nil, - retain: nil, - release: nil, - copyDescription: nil - ) - context.info = Unmanaged.passUnretained(self).toOpaque() - let callback: FSEventStreamCallback = { _, callbackInfo, _, _, _, _ in - guard let callbackInfo else { return } - let monitor = Unmanaged - .fromOpaque(callbackInfo) - .takeUnretainedValue() - Task { @MainActor in - monitor.onEvent() - } - } - stream = FSEventStreamCreate( - nil, - callback, - &context, - [path] as CFArray, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 1.0, - FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents - | kFSEventStreamCreateFlagNoDefer - | kFSEventStreamCreateFlagWatchRoot - ) - ) - guard let stream else { - return nil - } - FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) - guard FSEventStreamStart(stream) else { - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - self.stream = nil - return nil - } - } - - func cancel() { - guard let stream else { - return - } - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - self.stream = nil - } -} - -@MainActor -private final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { - private let commonGitDirectoryURL: URL - private let worktreesDirectoryURL: URL - private let onEvent: @MainActor @Sendable () -> Void - private var commonDirectorySource: DispatchSourceFileSystemObject? - private var worktreesDirectorySource: DispatchSourceFileSystemObject? - private var isWorktreesDirectoryPresent: Bool - - init?( - repositoryRootURL: URL, - onEvent: @escaping @MainActor @Sendable () -> Void, - fileManager: FileManager = .default - ) { - guard - let commonGitDirectoryURL = Self.commonGitDirectoryURL( - for: repositoryRootURL, - fileManager: fileManager - ) - else { - return nil - } - self.commonGitDirectoryURL = commonGitDirectoryURL - worktreesDirectoryURL = commonGitDirectoryURL.appending(path: "worktrees") - self.onEvent = onEvent - isWorktreesDirectoryPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: fileManager) - commonDirectorySource = Self.makeDirectorySource(url: commonGitDirectoryURL) { [weak self] in - self?.handleCommonDirectoryEvent() - } - if isWorktreesDirectoryPresent { - startWorktreesDirectorySourceIfNeeded() - } - guard commonDirectorySource != nil || worktreesDirectorySource != nil else { - return nil - } - } - - func cancel() { - commonDirectorySource?.cancel() - worktreesDirectorySource?.cancel() - commonDirectorySource = nil - worktreesDirectorySource = nil - } - - private func handleCommonDirectoryEvent() { - let isPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: .default) - guard isPresent != isWorktreesDirectoryPresent else { - return - } - isWorktreesDirectoryPresent = isPresent - if isPresent { - startWorktreesDirectorySourceIfNeeded() - } else { - worktreesDirectorySource?.cancel() - worktreesDirectorySource = nil - } - onEvent() - } - - private func handleWorktreesDirectoryEvent() { - guard Self.isDirectory(worktreesDirectoryURL, fileManager: .default) else { - if isWorktreesDirectoryPresent { - isWorktreesDirectoryPresent = false - worktreesDirectorySource?.cancel() - worktreesDirectorySource = nil - onEvent() - } - return - } - onEvent() - } - - private func startWorktreesDirectorySourceIfNeeded() { - guard worktreesDirectorySource == nil else { - return - } - worktreesDirectorySource = Self.makeDirectorySource(url: worktreesDirectoryURL) { [weak self] in - self?.handleWorktreesDirectoryEvent() - } - } - - private static func makeDirectorySource( - url: URL, - onEvent: @escaping @MainActor @Sendable () -> Void - ) -> DispatchSourceFileSystemObject? { - let fileDescriptor = open(url.path(percentEncoded: false), O_EVTONLY) - guard fileDescriptor >= 0 else { - return nil - } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fileDescriptor, - eventMask: [.write, .rename, .delete, .attrib], - queue: .main - ) - source.setEventHandler { - onEvent() - } - source.setCancelHandler { - close(fileDescriptor) - } - source.resume() - return source - } - - private static func commonGitDirectoryURL( - for repositoryRootURL: URL, - fileManager: FileManager - ) -> URL? { - let repositoryRootURL = repositoryRootURL.standardizedFileURL - let dotGitURL = repositoryRootURL.appending(path: ".git") - var isDirectory = ObjCBool(false) - if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { - if isDirectory.boolValue { - return dotGitURL.standardizedFileURL - } - guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { - return nil - } - return commonDirectoryURL(from: gitdirURL, fileManager: fileManager) - } - if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), - fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) - { - return repositoryRootURL - } - return nil - } - - private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { - guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { - return nil - } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "gitdir:" - guard trimmed.hasPrefix(prefix) else { - return nil - } - let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { - return nil - } - return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) - .standardizedFileURL - } - - private static func commonDirectoryURL(from gitdirURL: URL, fileManager: FileManager) -> URL { - let commondirURL = gitdirURL.appending(path: "commondir") - guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { - return gitdirURL.standardizedFileURL - } - let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { - return gitdirURL.standardizedFileURL - } - return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) - .standardizedFileURL - } - - private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { - var isDirectory = ObjCBool(false) - return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) - && isDirectory.boolValue - } -} -- 2.51.2 From ec44227770b89e77453f525b493731076e4d1f1a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:24:20 +0900 Subject: [PATCH 328/528] Split worktree detail toolbar views --- .../Views/WorktreeDetailToolbarViews.swift | 322 +++++++++++++++++ .../Views/WorktreeDetailView.swift | 325 +----------------- 2 files changed, 324 insertions(+), 323 deletions(-) create mode 100644 supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift new file mode 100644 index 00000000..e490ff9e --- /dev/null +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -0,0 +1,322 @@ +import SwiftUI + +struct MultiSelectedWorktreeSummary: Identifiable { + let id: Worktree.ID + let name: String + let repositoryName: String? +} + +struct MultiSelectedWorktreesDetailView: View { + let rows: [MultiSelectedWorktreeSummary] + + private let visibleRowsLimit = 8 + + var body: some View { + let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display + VStack(alignment: .leading, spacing: 16) { + Text("\(rows.count) worktrees selected") + .font(.title3) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(rows.prefix(visibleRowsLimit))) { row in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.name) + .lineLimit(1) + if let repositoryName = row.repositoryName { + Text(repositoryName) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .font(.body) + } + if rows.count > visibleRowsLimit { + Text("+\(rows.count - visibleRowsLimit) more") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Divider() + VStack(alignment: .leading, spacing: 6) { + Text("Available actions") + .font(.headline) + Text("Archive selected") + Text("Delete selected (\(deleteShortcut))") + Text("Right-click any selected worktree to apply actions to all selected worktrees.") + } + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct RunScriptToolbarButton: View { + let isRunning: Bool + let isEnabled: Bool + let runHelpText: String + let stopHelpText: String + let runShortcut: String? + let stopShortcut: String? + let runAction: () -> Void + let stopAction: () -> Void + @Environment(CommandKeyObserver.self) private var commandKeyObserver + + var body: some View { + if isRunning { + button( + config: RunScriptButtonConfig( + title: "Stop", + systemImage: "stop.fill", + helpText: stopHelpText, + shortcut: stopShortcut, + isEnabled: true, + action: stopAction + )) + } else { + button( + config: RunScriptButtonConfig( + title: "Run", + systemImage: "play.fill", + helpText: runHelpText, + shortcut: runShortcut, + isEnabled: isEnabled, + action: runAction + )) + } + } + + @ViewBuilder + private func button(config: RunScriptButtonConfig) -> some View { + Button { + config.action() + } label: { + HStack(spacing: 6) { + Image(systemName: config.systemImage) + .accessibilityHidden(true) + Text(config.title) + + if commandKeyObserver.isPressed, let shortcut = config.shortcut { + Text(shortcut) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .font(.caption) + .help(config.helpText) + .disabled(!config.isEnabled) + } + + private struct RunScriptButtonConfig { + let title: String + let systemImage: String + let helpText: String + let shortcut: String? + let isEnabled: Bool + let action: () -> Void + } +} + +struct UserCustomCommandToolbarButton: View { + let title: String + let systemImage: String + let shortcut: String? + let isEnabled: Bool + let action: () -> Void + @Environment(CommandKeyObserver.self) private var commandKeyObserver + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 6) { + Image(systemName: systemImage) + .accessibilityHidden(true) + Text(title) + if commandKeyObserver.isPressed, let shortcut { + Text(shortcut) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .font(.caption) + .help(helpText) + .disabled(!isEnabled) + } + + private var helpText: String { + guard isEnabled else { + return "\(title) (Set command script in Repository Settings)" + } + if let shortcut { + return "\(title) (\(shortcut))" + } + return title + } +} + +struct CustomCommandOverflowButton: View { + let entries: [(index: Int, command: UserCustomCommand)] + let shortcutDisplay: (UserCustomCommand) -> String? + let onRunCustomCommand: (Int) -> Void + + @State private var isPresented = false + private let maxVisibleRows = 10 + + var body: some View { + Button { + isPresented.toggle() + } label: { + Image(systemName: "chevron.down") + .font(.caption2) + .accessibilityLabel("More custom commands") + } + .help("More custom commands") + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(entries, id: \.command.id) { entry in + Button { + isPresented = false + onRunCustomCommand(entry.index) + } label: { + HStack(spacing: 8) { + Image(systemName: entry.command.resolvedSystemImage) + .foregroundStyle(.secondary) + .frame(width: 14) + .accessibilityHidden(true) + Text(entry.command.resolvedTitle) + .lineLimit(1) + Spacer(minLength: 0) + if let shortcut = shortcutDisplay(entry.command) { + Text(shortcut) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!entry.command.hasRunnableCommand) + } + } + .padding(8) + } + .frame(width: 320, height: popoverHeight) + } + } + + private var popoverHeight: CGFloat { + let visibleRows = min(maxVisibleRows, max(entries.count, 1)) + return CGFloat(visibleRows) * 32 + 16 + } +} + +@MainActor +private struct WorktreeToolbarPreview: View { + private let toolbarState: WorktreeDetailView.WorktreeToolbarState + private let commandKeyObserver: CommandKeyObserver + + init() { + toolbarState = WorktreeDetailView.WorktreeToolbarState( + title: DetailToolbarTitle(kind: .branch(name: "feature/toolbar-preview")), + statusToast: nil, + pullRequest: nil, + codeHost: .github, + notificationGroups: [], + unseenNotificationWorktreeCount: 0, + openActionSelection: .finder, + showExtras: false, + runScriptEnabled: true, + runScriptIsRunning: false, + customCommands: [ + UserCustomCommand( + title: "Test", + systemImage: "checkmark.circle.fill", + command: "swift test", + execution: .shellScript, + shortcut: UserCustomShortcut( + key: "u", + modifiers: UserCustomShortcutModifiers() + ) + ) + ], + isUpdateAvailable: true, + isUpdateReadyToInstall: false, + availableUpdateVersion: "2026.5.1", + showRunButtonInToolbar: true, + showDefaultEditorInToolbar: true + ) + let observer = CommandKeyObserver() + observer.isPressed = false + commandKeyObserver = observer + } + + var body: some View { + NavigationStack { + Text("Worktree Toolbar") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .toolbar { + WorktreeDetailView.WorktreeToolbarContent( + toolbarState: toolbarState, + onRenameBranch: { _ in }, + externalRenamePrompt: nil, + onConsumeExternalRenamePrompt: { _ in }, + onOpenWorktree: { _ in }, + onOpenActionSelectionChanged: { _ in }, + onCopyPath: {}, + onSelectNotification: { _, _ in }, + onDismissAllNotifications: {}, + onRunScript: {}, + onStopRunScript: {}, + onRunCustomCommand: { _ in }, + onActivateUpdateButton: {} + ) + } + .environment(commandKeyObserver) + .frame(width: 900, height: 160) + } +} + +#Preview("Worktree Toolbar") { + WorktreeToolbarPreview() +} + +@MainActor +private struct CanvasToolbarPreview: View { + var body: some View { + NavigationSplitView { + List { + Text("Sidebar Item 1") + Text("Sidebar Item 2") + } + .navigationSplitViewColumnWidth(220) + } detail: { + Text("Canvas Content") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Canvas") + .toolbar { + ToolbarItem(placement: .primaryAction) { + ToolbarNotificationsPopoverButton( + groups: [], + unseenWorktreeCount: 0, + onSelectNotification: { _, _ in }, + onDismissAll: {} + ) + } + } + } + .frame(width: 900, height: 300) + } +} + +#Preview("Canvas Toolbar") { + CanvasToolbarPreview() +} diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index e272dd22..4240533d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -713,7 +713,7 @@ struct WorktreeDetailView: View { let stopRunScript: (() -> Void)? } - fileprivate struct WorktreeToolbarState { + struct WorktreeToolbarState { let title: DetailToolbarTitle let statusToast: RepositoriesFeature.StatusToast? let pullRequest: GithubPullRequest? @@ -732,7 +732,7 @@ struct WorktreeDetailView: View { let showDefaultEditorInToolbar: Bool } - fileprivate struct WorktreeToolbarContent: ToolbarContent { + struct WorktreeToolbarContent: ToolbarContent { let toolbarState: WorktreeToolbarState let onRenameBranch: (String) -> Void let externalRenamePrompt: PendingRenameBranchRequest? @@ -981,324 +981,3 @@ struct WorktreeDetailView: View { return nil } } - -private struct MultiSelectedWorktreeSummary: Identifiable { - let id: Worktree.ID - let name: String - let repositoryName: String? -} - -private struct MultiSelectedWorktreesDetailView: View { - let rows: [MultiSelectedWorktreeSummary] - - private let visibleRowsLimit = 8 - - var body: some View { - let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display - VStack(alignment: .leading, spacing: 16) { - Text("\(rows.count) worktrees selected") - .font(.title3) - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(rows.prefix(visibleRowsLimit))) { row in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.name) - .lineLimit(1) - if let repositoryName = row.repositoryName { - Text(repositoryName) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .font(.body) - } - if rows.count > visibleRowsLimit { - Text("+\(rows.count - visibleRowsLimit) more") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Divider() - VStack(alignment: .leading, spacing: 6) { - Text("Available actions") - .font(.headline) - Text("Archive selected") - Text("Delete selected (\(deleteShortcut))") - Text("Right-click any selected worktree to apply actions to all selected worktrees.") - } - .font(.caption) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - } - .padding(20) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } -} - -private struct RunScriptToolbarButton: View { - let isRunning: Bool - let isEnabled: Bool - let runHelpText: String - let stopHelpText: String - let runShortcut: String? - let stopShortcut: String? - let runAction: () -> Void - let stopAction: () -> Void - @Environment(CommandKeyObserver.self) private var commandKeyObserver - - var body: some View { - if isRunning { - button( - config: RunScriptButtonConfig( - title: "Stop", - systemImage: "stop.fill", - helpText: stopHelpText, - shortcut: stopShortcut, - isEnabled: true, - action: stopAction - )) - } else { - button( - config: RunScriptButtonConfig( - title: "Run", - systemImage: "play.fill", - helpText: runHelpText, - shortcut: runShortcut, - isEnabled: isEnabled, - action: runAction - )) - } - } - - @ViewBuilder - private func button(config: RunScriptButtonConfig) -> some View { - Button { - config.action() - } label: { - HStack(spacing: 6) { - Image(systemName: config.systemImage) - .accessibilityHidden(true) - Text(config.title) - - if commandKeyObserver.isPressed, let shortcut = config.shortcut { - Text(shortcut) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .font(.caption) - .help(config.helpText) - .disabled(!config.isEnabled) - } - - private struct RunScriptButtonConfig { - let title: String - let systemImage: String - let helpText: String - let shortcut: String? - let isEnabled: Bool - let action: () -> Void - } -} - -private struct UserCustomCommandToolbarButton: View { - let title: String - let systemImage: String - let shortcut: String? - let isEnabled: Bool - let action: () -> Void - @Environment(CommandKeyObserver.self) private var commandKeyObserver - - var body: some View { - Button { - action() - } label: { - HStack(spacing: 6) { - Image(systemName: systemImage) - .accessibilityHidden(true) - Text(title) - if commandKeyObserver.isPressed, let shortcut { - Text(shortcut) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .font(.caption) - .help(helpText) - .disabled(!isEnabled) - } - - private var helpText: String { - guard isEnabled else { - return "\(title) (Set command script in Repository Settings)" - } - if let shortcut { - return "\(title) (\(shortcut))" - } - return title - } -} - -private struct CustomCommandOverflowButton: View { - let entries: [(index: Int, command: UserCustomCommand)] - let shortcutDisplay: (UserCustomCommand) -> String? - let onRunCustomCommand: (Int) -> Void - - @State private var isPresented = false - private let maxVisibleRows = 10 - - var body: some View { - Button { - isPresented.toggle() - } label: { - Image(systemName: "chevron.down") - .font(.caption2) - .accessibilityLabel("More custom commands") - } - .help("More custom commands") - .popover(isPresented: $isPresented, arrowEdge: .bottom) { - ScrollView { - VStack(alignment: .leading, spacing: 2) { - ForEach(entries, id: \.command.id) { entry in - Button { - isPresented = false - onRunCustomCommand(entry.index) - } label: { - HStack(spacing: 8) { - Image(systemName: entry.command.resolvedSystemImage) - .foregroundStyle(.secondary) - .frame(width: 14) - .accessibilityHidden(true) - Text(entry.command.resolvedTitle) - .lineLimit(1) - Spacer(minLength: 0) - if let shortcut = shortcutDisplay(entry.command) { - Text(shortcut) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!entry.command.hasRunnableCommand) - } - } - .padding(8) - } - .frame(width: 320, height: popoverHeight) - } - } - - private var popoverHeight: CGFloat { - let visibleRows = min(maxVisibleRows, max(entries.count, 1)) - return CGFloat(visibleRows) * 32 + 16 - } -} - -@MainActor -private struct WorktreeToolbarPreview: View { - private let toolbarState: WorktreeDetailView.WorktreeToolbarState - private let commandKeyObserver: CommandKeyObserver - - init() { - toolbarState = WorktreeDetailView.WorktreeToolbarState( - title: DetailToolbarTitle(kind: .branch(name: "feature/toolbar-preview")), - statusToast: nil, - pullRequest: nil, - codeHost: .github, - notificationGroups: [], - unseenNotificationWorktreeCount: 0, - openActionSelection: .finder, - showExtras: false, - runScriptEnabled: true, - runScriptIsRunning: false, - customCommands: [ - UserCustomCommand( - title: "Test", - systemImage: "checkmark.circle.fill", - command: "swift test", - execution: .shellScript, - shortcut: UserCustomShortcut( - key: "u", - modifiers: UserCustomShortcutModifiers() - ) - ) - ], - isUpdateAvailable: true, - isUpdateReadyToInstall: false, - availableUpdateVersion: "2026.5.1", - showRunButtonInToolbar: true, - showDefaultEditorInToolbar: true - ) - let observer = CommandKeyObserver() - observer.isPressed = false - commandKeyObserver = observer - } - - var body: some View { - NavigationStack { - Text("Worktree Toolbar") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .toolbar { - WorktreeDetailView.WorktreeToolbarContent( - toolbarState: toolbarState, - onRenameBranch: { _ in }, - externalRenamePrompt: nil, - onConsumeExternalRenamePrompt: { _ in }, - onOpenWorktree: { _ in }, - onOpenActionSelectionChanged: { _ in }, - onCopyPath: {}, - onSelectNotification: { _, _ in }, - onDismissAllNotifications: {}, - onRunScript: {}, - onStopRunScript: {}, - onRunCustomCommand: { _ in }, - onActivateUpdateButton: {} - ) - } - .environment(commandKeyObserver) - .frame(width: 900, height: 160) - } -} - -#Preview("Worktree Toolbar") { - WorktreeToolbarPreview() -} - -@MainActor -private struct CanvasToolbarPreview: View { - var body: some View { - NavigationSplitView { - List { - Text("Sidebar Item 1") - Text("Sidebar Item 2") - } - .navigationSplitViewColumnWidth(220) - } detail: { - Text("Canvas Content") - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("Canvas") - .toolbar { - ToolbarItem(placement: .primaryAction) { - ToolbarNotificationsPopoverButton( - groups: [], - unseenWorktreeCount: 0, - onSelectNotification: { _, _ in }, - onDismissAll: {} - ) - } - } - } - .frame(width: 900, height: 300) - } -} - -#Preview("Canvas Toolbar") { - CanvasToolbarPreview() -} -- 2.51.2 From 28cb46ee66001bea1083c2b397631696d5171a31 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:28:50 +0900 Subject: [PATCH 329/528] Split repository settings custom command views --- ...RepositorySettingsCustomCommandsView.swift | 903 +++++++++++++ .../RepositorySettingsSupportingViews.swift | 221 ++++ .../Views/RepositorySettingsView.swift | 1149 +---------------- 3 files changed, 1140 insertions(+), 1133 deletions(-) create mode 100644 supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift create mode 100644 supacode/Features/Settings/Views/RepositorySettingsSupportingViews.swift diff --git a/supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift b/supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift new file mode 100644 index 00000000..87bed00d --- /dev/null +++ b/supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift @@ -0,0 +1,903 @@ +import AppKit +import ComposableArchitecture +import SwiftUI + +extension RepositorySettingsView { + @ViewBuilder + var customCommandsEditor: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(spacing: 0) { + customCommandsHeaderRow + Divider() + ScrollView { + LazyVStack(spacing: 4) { + ForEach(store.userSettings.customCommands) { command in + customCommandRow(command) + .id(command.id) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } + .frame(height: customCommandsListHeight) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + + HStack(spacing: 8) { + Button { + addCustomCommand() + } label: { + ZStack { + Image(systemName: "plus") + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .accessibilityLabel("Add command") + } + .buttonStyle(.plain) + .help("Add command") + + Button { + removeSelectedCustomCommand() + } label: { + ZStack { + Image(systemName: "minus") + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .accessibilityLabel("Remove selected command") + } + .buttonStyle(.plain) + .disabled(store.userSettings.customCommands.isEmpty) + .help("Remove selected command") + + Spacer(minLength: 0) + + Text("\(store.userSettings.customCommands.count) commands") + .font(.caption) + .foregroundStyle(.secondary) + } + if let invalidMessage = selectedCommandInvalidMessage { + Text(invalidMessage) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Click cells to edit icon, name, command, and shortcut inline.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .background { + FirstResponderAnchorView { anchor in + if customCommandsFocusAnchor !== anchor { + customCommandsFocusAnchor = anchor + } + } + .frame(width: 0, height: 0) + } + } + + @ViewBuilder + func customCommandIconCell(_ command: UserCustomCommand) -> some View { + if let binding = bindingForCustomCommand(id: command.id) { + InlineEditableCellButton( + isActive: iconPickerCommandID == command.id, + contentAlignment: .center + ) { + selectCustomCommand(command.id) + toggleIconEditor(for: command.id) + } label: { + Image(systemName: binding.wrappedValue.resolvedSystemImage) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .center) + .accessibilityHidden(true) + } + .popover( + isPresented: Binding( + get: { iconPickerCommandID == command.id }, + set: { isPresented in + if !isPresented { + closePopoverAndRestoreCommandFocus(for: command.id) + } + } + ), + arrowEdge: .bottom + ) { + iconEditorPopover(for: binding, commandID: command.id) + } + } else { + InlineEditableCellButton( + contentAlignment: .center + ) { + selectCustomCommand(command.id) + } label: { + Image(systemName: command.resolvedSystemImage) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .center) + .accessibilityHidden(true) + } + } + } + + @ViewBuilder + func customCommandNameCell(_ command: UserCustomCommand) -> some View { + let isSelected = selectedCustomCommandID == command.id + if isSelected, + editingNameCommandID == command.id, + let binding = bindingForCustomCommand(id: command.id) + { + InlineEditableFieldContainer(isActive: true) { + TextField("", text: binding.title) + .textFieldStyle(.plain) + .padding(.leading, -4) + .focused($focusedNameEditorCommandID, equals: command.id) + .onSubmit { + endNameEditing() + } + } + .onAppear { + focusedNameEditorCommandID = command.id + } + } else { + InlineEditableCellButton { + selectCustomCommand(command.id) + beginNameEditing(for: command.id) + } label: { + Text(bindingForCustomCommand(id: command.id)?.wrappedValue.resolvedTitle ?? command.resolvedTitle) + .lineLimit(1) + } + } + } + + @ViewBuilder + func customCommandCell(_ command: UserCustomCommand) -> some View { + if let binding = bindingForCustomCommand(id: command.id) { + InlineEditableCellButton( + isActive: commandEditorCommandID == command.id + ) { + selectCustomCommand(command.id) + toggleCommandEditor(for: command.id) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(inlineCommandTitle(for: binding.wrappedValue.execution)) + .font(.caption) + .foregroundStyle(.secondary) + Text(inlineCommandScriptPreview(for: binding.wrappedValue.command)) + .lineLimit(1) + } + } + .popover( + isPresented: Binding( + get: { commandEditorCommandID == command.id }, + set: { isPresented in + if !isPresented { + closePopoverAndRestoreCommandFocus(for: command.id) + } + } + ), + arrowEdge: .bottom + ) { + commandEditorPopover(for: binding) + } + .help("New Tab runs in a new tab. In Place sends input to the focused terminal.") + } else { + InlineEditableCellButton { + selectCustomCommand(command.id) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(inlineCommandTitle(for: command.execution)) + .font(.caption) + .foregroundStyle(.secondary) + Text(inlineCommandScriptPreview(for: command.command)) + .lineLimit(1) + } + } + } + } + + @ViewBuilder + func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { + let resolvedBinding = resolvedCustomCommandBindings.keybinding(for: customCommandBindingID(for: command.id)) + let shortcutDisplay = resolvedBinding?.display ?? "Unassigned" + let isRecording = recordingCustomCommandID == command.id + + InlineEditableCellButton( + isActive: isRecording, + activeColor: .orange + ) { + selectCustomCommand(command.id) + toggleRecording(for: command.id) + } label: { + Text(isRecording ? "Recording…" : shortcutDisplay) + .font(.body.monospaced()) + .foregroundStyle(isRecording ? Color.orange : (resolvedBinding == nil ? .secondary : .primary)) + .lineLimit(1) + } + .contextMenu { + if command.shortcut != nil { + Button("Clear Shortcut") { + clearShortcut(for: command.id) + } + } + } + .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") + } + + var effectiveSelectedCommandID: UserCustomCommand.ID? { + selectedCustomCommandID ?? editingNameCommandID ?? commandEditorCommandID ?? iconPickerCommandID + ?? recordingCustomCommandID + } + + var removableCommandID: UserCustomCommand.ID? { + let commands = store.userSettings.customCommands + if let selectedCustomCommandID, + commands.contains(where: { $0.id == selectedCustomCommandID }) + { + return selectedCustomCommandID + } + if let effectiveSelectedCommandID, + commands.contains(where: { $0.id == effectiveSelectedCommandID }) + { + return effectiveSelectedCommandID + } + return commands.last?.id + } + + var customCommandsHeaderRow: some View { + HStack(spacing: 8) { + customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) + customCommandHeaderCell("Name", width: customCommandsNameColumnWidth) + customCommandHeaderCell("Command") + customCommandHeaderCell("Shortcut", width: customCommandsShortcutColumnWidth) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .font(.headline) + .foregroundStyle(.secondary) + } + + @ViewBuilder + func customCommandRow(_ command: UserCustomCommand) -> some View { + let isSelected = selectedCustomCommandID == command.id + HStack(spacing: 8) { + customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { + customCommandIconCell(command) + } + customCommandRowCell(width: customCommandsNameColumnWidth) { + customCommandNameCell(command) + } + customCommandRowCell { + customCommandCell(command) + } + customCommandRowCell(width: customCommandsShortcutColumnWidth) { + customCommandShortcutCell(command) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor.opacity(0.35) : .clear) + } + .contentShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityAddTraits(.isButton) + .onTapGesture { + selectCustomCommand(command.id) + } + } + + @ViewBuilder + func customCommandHeaderCell( + _ title: String, + width: CGFloat? = nil, + alignment: Alignment = .leading + ) -> some View { + if let width { + Text(title) + .frame(width: width, alignment: alignment) + } else { + Text(title) + .frame(maxWidth: .infinity, alignment: alignment) + } + } + + @ViewBuilder + func customCommandRowCell( + width: CGFloat? = nil, + alignment: Alignment = .leading, + @ViewBuilder content: () -> Content + ) -> some View { + if let width { + content() + .frame(width: width, alignment: alignment) + .frame(maxHeight: .infinity, alignment: alignment) + } else { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + } + } + + func selectCustomCommand(_ commandID: UserCustomCommand.ID) { + if selectedCustomCommandID != commandID { + selectedCustomCommandID = commandID + } + } + + func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { + switch execution { + case .shellScript: + return "New Tab" + case .terminalInput: + return "In Place" + case .split: + return "New Split" + } + } + + func inlineCommandScriptPreview(for script: String) -> String { + let firstLine = + script + .split(separator: "\n", omittingEmptySubsequences: false) + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return firstLine.isEmpty ? "Click to set command script" : firstLine + } + + func iconEditorPopover( + for command: Binding, + commandID: UserCustomCommand.ID + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("Icon") + .font(.headline) + Text("Pick from common symbols or enter any SF Symbol name available in your system.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + TextField("SF Symbol name", text: command.systemImage) + .textFieldStyle(.roundedBorder) + Button("Open SF Symbols") { + openSFSymbolsReference() + } + } + + ScrollView { + LazyVGrid( + columns: Array(repeating: GridItem(.fixed(24), spacing: 8), count: 10), + spacing: 8 + ) { + ForEach(Self.symbolPresets, id: \.self) { symbol in + Button { + command.wrappedValue.systemImage = symbol + closePopoverAndRestoreCommandFocus(for: commandID) + } label: { + Image(systemName: symbol) + .frame(width: 24, height: 24) + .accessibilityHidden(true) + } + .buttonStyle(.plain) + .help(symbol) + } + } + .padding(12) + } + .frame(maxHeight: 124) + } + .padding(12) + .frame(width: 360) + } + + func commandEditorPopover(for command: Binding) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("Command") + .font(.headline) + Text("Choose where this command runs and edit the script used by this repository custom command.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Picker("Execution", selection: command.execution) { + Text("New Tab") + .tag(UserCustomCommandExecution.shellScript) + Text("In Place") + .tag(UserCustomCommandExecution.terminalInput) + Text("New Split") + .tag(UserCustomCommandExecution.split) + } + .pickerStyle(.segmented) + + if command.wrappedValue.execution == .split { + Picker("Split Direction", selection: command.splitDirection) { + ForEach(UserCustomSplitDirection.allCases) { direction in + Text(direction.title).tag(direction) + } + } + .pickerStyle(.menu) + .help("Direction to split the focused terminal pane.") + } + + PlainTextEditor( + text: command.command, + isMonospaced: true, + shouldFocus: true, + placeholder: scriptPlaceholder(for: command.wrappedValue.execution) + ) + .frame(height: 140) + + Text(scriptDescription(for: command.wrappedValue.execution)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if command.wrappedValue.execution.supportsCloseOnSuccess { + Toggle("Close on success", isOn: command.closeOnSuccess) + .help("Automatically closes the tab or split when the command exits with code 0.") + .toggleStyle(.checkbox) + } + } + .padding(12) + .frame(width: 420) + } + + var selectedCommandInvalidMessage: String? { + guard let selectedCustomCommandID else { + return nil + } + return invalidMessageByCommandID[selectedCustomCommandID] + } + + func openSFSymbolsReference() { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.SFSymbols") { + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, _ in } + return + } + guard let url = URL(string: "https://developer.apple.com/sf-symbols/") else { + return + } + NSWorkspace.shared.open(url) + } + + func toggleIconEditor(for commandID: UserCustomCommand.ID) { + if iconPickerCommandID == commandID { + closePopoverAndRestoreCommandFocus(for: commandID) + return + } + iconPickerCommandID = commandID + commandEditorCommandID = nil + endNameEditing() + if recordingCustomCommandID == commandID { + recordingCustomCommandID = nil + } + } + + func toggleCommandEditor(for commandID: UserCustomCommand.ID) { + if commandEditorCommandID == commandID { + closePopoverAndRestoreCommandFocus(for: commandID) + return + } + commandEditorCommandID = commandID + iconPickerCommandID = nil + endNameEditing() + if recordingCustomCommandID == commandID { + recordingCustomCommandID = nil + } + } + + func beginNameEditing(for commandID: UserCustomCommand.ID) { + editingNameCommandID = commandID + iconPickerCommandID = nil + commandEditorCommandID = nil + if recordingCustomCommandID == commandID { + recordingCustomCommandID = nil + } + focusedNameEditorCommandID = commandID + } + + func endNameEditing() { + editingNameCommandID = nil + focusedNameEditorCommandID = nil + } + + func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { + popoverRefocusTask?.cancel() + + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + iconPickerCommandID = nil + commandEditorCommandID = nil + } + focusCustomCommandsArea() + scheduleCommandFocusRestore(for: commandID) + } + + func focusCustomCommandsArea() { + guard let window = NSApp.keyWindow else { + return + } + if let customCommandsFocusAnchor, + customCommandsFocusAnchor.window === window + { + _ = window.makeFirstResponder(customCommandsFocusAnchor) + return + } + _ = window.makeFirstResponder(nil) + } + + func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { + popoverRefocusTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled else { + return + } + guard iconPickerCommandID == nil, commandEditorCommandID == nil else { + return + } + guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { + return + } + + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + selectCustomCommand(commandID) + endNameEditing() + } + } + } + + func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { + switch execution { + case .shellScript: + return "npm test && swift test" + case .terminalInput: + return "pnpm test --watch" + case .split: + return "tail -f logs/app.log" + } + } + + func scriptDescription(for execution: UserCustomCommandExecution) -> String { + switch execution { + case .shellScript: + return "Runs in a new terminal tab." + case .terminalInput: + return "Sends input to the currently focused terminal." + case .split: + return "Runs in a new split of the focused terminal." + } + } + + var resolvedCustomCommandBindings: ResolvedKeybindingMap { + let commands = store.userSettings.customCommands + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: commands) + return KeybindingResolver.resolve( + schema: .appResolverSchema(customCommands: commands), + userOverrides: store.keybindingUserOverrides, + migratedOverrides: migration.overrides + ) + } + + func customCommandBindingID(for commandID: String) -> String { + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID) + } + + func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding? { + guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { + return nil + } + + return Binding( + get: { + store.userSettings.customCommands.first(where: { $0.id == commandID }) + ?? UserCustomCommand( + id: commandID, + title: "", + systemImage: "terminal", + command: "", + execution: .shellScript, + shortcut: nil + ) + }, + set: { updatedCommand in + updateCustomCommand(id: commandID) { command in + command.title = updatedCommand.title + command.systemImage = updatedCommand.systemImage + command.command = updatedCommand.command + command.execution = updatedCommand.execution + command.splitDirection = updatedCommand.splitDirection + command.closeOnSuccess = updatedCommand.closeOnSuccess + command.shortcut = updatedCommand.shortcut + } + } + ) + } + + func syncSelectedCommandID(with commands: [UserCustomCommand]) { + guard !commands.isEmpty else { + selectedCustomCommandID = nil + recordingCustomCommandID = nil + iconPickerCommandID = nil + commandEditorCommandID = nil + editingNameCommandID = nil + focusedNameEditorCommandID = nil + return + } + + if let selectedCustomCommandID, + commands.contains(where: { $0.id == selectedCustomCommandID }) + { + return + } + + selectedCustomCommandID = commands[0].id + } + + func clearRemovedCommandState(using commands: [UserCustomCommand]) { + let validIDs = Set(commands.map(\.id)) + + invalidMessageByCommandID = invalidMessageByCommandID.filter { validIDs.contains($0.key) } + + if let recordingCustomCommandID, + !validIDs.contains(recordingCustomCommandID) + { + self.recordingCustomCommandID = nil + } + + if let iconPickerCommandID, + !validIDs.contains(iconPickerCommandID) + { + self.iconPickerCommandID = nil + } + + if let commandEditorCommandID, + !validIDs.contains(commandEditorCommandID) + { + self.commandEditorCommandID = nil + } + + if let editingNameCommandID, + !validIDs.contains(editingNameCommandID) + { + self.editingNameCommandID = nil + focusedNameEditorCommandID = nil + } + } + + func addCustomCommand() { + let commandsBinding = $store.userSettings.customCommands + let current = commandsBinding.wrappedValue + let next = UserRepositorySettings.normalizedCommands(current + [.default(index: current.count)]) + commandsBinding.wrappedValue = next + guard let commandID = next.last?.id else { + selectedCustomCommandID = nil + editingNameCommandID = nil + focusedNameEditorCommandID = nil + return + } + selectedCustomCommandID = commandID + editingNameCommandID = commandID + focusedNameEditorCommandID = commandID + iconPickerCommandID = nil + commandEditorCommandID = nil + recordingCustomCommandID = nil + } + + func removeSelectedCustomCommand() { + guard let selectedCommandID = removableCommandID else { + return + } + + let commandsBinding = $store.userSettings.customCommands + var commands = commandsBinding.wrappedValue + let removalIndex: Int? + if let index = commands.firstIndex(where: { $0.id == selectedCommandID }) { + removalIndex = index + commands.remove(at: index) + } else if !commands.isEmpty { + removalIndex = commands.count - 1 + commands.removeLast() + } else { + removalIndex = nil + } + + guard let removalIndex else { + return + } + + let normalizedCommands = UserRepositorySettings.normalizedCommands(commands) + commandsBinding.wrappedValue = normalizedCommands + + if normalizedCommands.isEmpty { + selectedCustomCommandID = nil + } else if removalIndex < normalizedCommands.count { + selectedCustomCommandID = normalizedCommands[removalIndex].id + } else { + selectedCustomCommandID = normalizedCommands[normalizedCommands.count - 1].id + } + clearRemovedCommandState(using: normalizedCommands) + } + + func clearShortcut(for commandID: UserCustomCommand.ID) { + invalidMessageByCommandID[commandID] = nil + updateCustomCommand(id: commandID) { command in + command.shortcut = nil + } + if recordingCustomCommandID == commandID { + recordingCustomCommandID = nil + } + } + + func updateCustomCommand( + id: UserCustomCommand.ID, + update: (inout UserCustomCommand) -> Void + ) { + let commandsBinding = $store.userSettings.customCommands + var commands = commandsBinding.wrappedValue + guard let index = commands.firstIndex(where: { $0.id == id }) else { + return + } + + update(&commands[index]) + commandsBinding.wrappedValue = UserRepositorySettings.normalizedCommands(commands) + } + + func toggleRecording(for commandID: UserCustomCommand.ID) { + invalidMessageByCommandID[commandID] = nil + iconPickerCommandID = nil + commandEditorCommandID = nil + endNameEditing() + + if recordingCustomCommandID == commandID { + recordingCustomCommandID = nil + return + } + + recordingCustomCommandID = commandID + } + + func startRecorderMonitor() { + stopRecorderMonitor() + recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in + guard let commandID = recordingCustomCommandID else { + return event + } + handleRecorderEvent(event, commandID: commandID) + return nil + } + } + + func stopRecorderMonitor() { + if let recorderMonitor { + NSEvent.removeMonitor(recorderMonitor) + self.recorderMonitor = nil + } + } + + func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { + if event.keyCode == 53 { // Escape + recordingCustomCommandID = nil + return + } + + guard + let keyToken = keyTokenResolver.resolveKeyToken( + keyCode: event.keyCode, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) + else { + invalidMessageByCommandID[commandID] = "Unsupported key. Use letters, numbers, or punctuation." + return + } + + let modifiers = KeybindingModifiers( + command: event.modifierFlags.contains(.command), + shift: event.modifierFlags.contains(.shift), + option: event.modifierFlags.contains(.option), + control: event.modifierFlags.contains(.control) + ) + + guard !modifiers.isEmpty else { + invalidMessageByCommandID[commandID] = "Shortcut must include at least one modifier key." + return + } + + let binding = Keybinding(key: keyToken, modifiers: modifiers) + guard let shortcut = binding.userCustomShortcut else { + invalidMessageByCommandID[commandID] = + "Custom command shortcuts support letters, numbers, and punctuation only." + return + } + + applyRecordedShortcut(shortcut.normalized(), to: commandID) + } + + func applyRecordedShortcut( + _ shortcut: UserCustomShortcut, + to commandID: UserCustomCommand.ID + ) { + invalidMessageByCommandID[commandID] = nil + + guard let existingCommand = firstConflictingCommand(for: commandID, shortcut: shortcut) else { + updateCustomCommand(id: commandID) { command in + command.shortcut = shortcut + } + recordingCustomCommandID = nil + return + } + + let newTitle = + store.userSettings.customCommands.first(where: { $0.id == commandID })?.resolvedTitle ?? "Command" + + pendingShortcutConflict = CustomCommandShortcutConflict( + newCommandID: commandID, + newCommandTitle: newTitle, + existingCommandID: existingCommand.id, + existingCommandTitle: existingCommand.resolvedTitle, + shortcutDisplay: shortcut.display + ) + pendingShortcut = PendingCustomShortcut(commandID: commandID, shortcut: shortcut) + recordingCustomCommandID = nil + } + + func firstConflictingCommand( + for commandID: UserCustomCommand.ID, + shortcut: UserCustomShortcut + ) -> UserCustomCommand? { + store.userSettings.customCommands.first { command in + guard command.id != commandID else { return false } + guard let existingShortcut = command.shortcut?.normalized() else { return false } + return existingShortcut == shortcut + } + } + + func applyPendingShortcut(replacingConflict: Bool) { + guard let pendingShortcut else { + clearPendingShortcutConflict() + return + } + + if replacingConflict, + let existingCommandID = pendingShortcutConflict?.existingCommandID + { + updateCustomCommand(id: existingCommandID) { command in + command.shortcut = nil + } + } + + updateCustomCommand(id: pendingShortcut.commandID) { command in + command.shortcut = pendingShortcut.shortcut + } + + clearPendingShortcutConflict() + } + + func clearPendingShortcutConflict() { + pendingShortcutConflict = nil + pendingShortcut = nil + } + + var isShortcutConflictAlertPresented: Binding { + Binding( + get: { pendingShortcutConflict != nil }, + set: { shouldPresent in + if !shouldPresent { + clearPendingShortcutConflict() + } + } + ) + } + + var customCommandsIconColumnWidth: CGFloat { 48 } + + var customCommandsNameColumnWidth: CGFloat { 130 } + + var customCommandsShortcutColumnWidth: CGFloat { 100 } + + var customCommandsListHeight: CGFloat { 200 } +} diff --git a/supacode/Features/Settings/Views/RepositorySettingsSupportingViews.swift b/supacode/Features/Settings/Views/RepositorySettingsSupportingViews.swift new file mode 100644 index 00000000..f2406cd6 --- /dev/null +++ b/supacode/Features/Settings/Views/RepositorySettingsSupportingViews.swift @@ -0,0 +1,221 @@ +import AppKit +import SwiftUI + +struct InlineEditableCellButton: View { + let isActive: Bool + let activeColor: Color + let contentAlignment: Alignment + let action: () -> Void + @ViewBuilder let label: () -> Label + + @State private var isHovering = false + + init( + isActive: Bool = false, + activeColor: Color = .accentColor, + contentAlignment: Alignment = .leading, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.isActive = isActive + self.activeColor = activeColor + self.contentAlignment = contentAlignment + self.action = action + self.label = label + } + + var body: some View { + Button(action: action) { + label() + .frame(maxWidth: .infinity, alignment: contentAlignment) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: contentAlignment) + .onHover { hovering in + isHovering = hovering + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(borderColor, lineWidth: borderWidth) + } + } + + private var borderColor: Color { + if isActive { + return activeColor + } + if isHovering { + return Color(nsColor: .tertiaryLabelColor) + } + return .clear + } + + private var borderWidth: CGFloat { + (isActive || isHovering) ? 1 : 0 + } +} + +struct InlineEditableFieldContainer: View { + let isActive: Bool + let activeColor: Color + @ViewBuilder let content: () -> Content + @State private var isHovering = false + + init( + isActive: Bool = false, + activeColor: Color = .accentColor, + @ViewBuilder content: @escaping () -> Content + ) { + self.isActive = isActive + self.activeColor = activeColor + self.content = content + } + + var body: some View { + content() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .onHover { hovering in + isHovering = hovering + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(borderColor, lineWidth: borderWidth) + } + } + + private var borderColor: Color { + if isActive { + return activeColor + } + if isHovering { + return Color(nsColor: .tertiaryLabelColor) + } + return .clear + } + + private var borderWidth: CGFloat { + (isActive || isHovering) ? 1 : 0 + } +} + +struct FirstResponderAnchorView: NSViewRepresentable { + let onResolve: (NSView) -> Void + + func makeNSView(context: Context) -> FirstResponderAnchorNSView { + let view = FirstResponderAnchorNSView() + onResolve(view) + return view + } + + func updateNSView(_ nsView: FirstResponderAnchorNSView, context: Context) { + onResolve(nsView) + } +} + +final class FirstResponderAnchorNSView: NSView { + override var acceptsFirstResponder: Bool { + true + } +} + +struct BranchPickerPopover: View { + @Binding var searchText: String + let options: [String] + let automaticLabel: String + let selection: String? + let onSelect: (String?) -> Void + @FocusState private var isSearchFocused: Bool + + var filteredOptions: [String] { + if searchText.isEmpty { return options } + return options.filter { $0.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + VStack(spacing: 0) { + TextField("Filter branches...", text: $searchText) + .textFieldStyle(.roundedBorder) + .focused($isSearchFocused) + .padding(8) + Divider() + List { + Button { + onSelect(nil) + } label: { + HStack { + Text(automaticLabel) + Spacer() + if selection == nil { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + ForEach(filteredOptions, id: \.self) { ref in + Button { + onSelect(ref) + } label: { + HStack { + Text(ref) + Spacer() + if selection == ref { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + } + .frame(width: 300, height: 350) + .onAppear { isSearchFocused = true } + } +} + +struct CustomCommandShortcutConflict: Equatable { + let newCommandID: UserCustomCommand.ID + let newCommandTitle: String + let existingCommandID: UserCustomCommand.ID + let existingCommandTitle: String + let shortcutDisplay: String +} + +struct PendingCustomShortcut: Equatable { + let commandID: UserCustomCommand.ID + let shortcut: UserCustomShortcut +} + +struct ScriptEnvironmentRow: View { + let name: String + var value: String? + let description: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(name) + .monospaced() + if let value { + Text(value) + .foregroundStyle(.secondary) + .monospaced() + } + Text(description) + .foregroundStyle(.tertiary) + } + } +} diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index a8683197..6c7f8fcf 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -7,22 +7,22 @@ struct RepositorySettingsView: View { @State private var isBranchPickerPresented = false @State private var branchSearchText = "" - @State private var selectedCustomCommandID: UserCustomCommand.ID? - @State private var recordingCustomCommandID: UserCustomCommand.ID? - @State private var recorderMonitor: Any? - @State private var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] - @State private var pendingShortcutConflict: CustomCommandShortcutConflict? - @State private var pendingShortcut: PendingCustomShortcut? - @State private var iconPickerCommandID: UserCustomCommand.ID? - @State private var customCommandsFocusAnchor: NSView? - @State private var popoverRefocusTask: Task? - @State private var commandEditorCommandID: UserCustomCommand.ID? - @State private var editingNameCommandID: UserCustomCommand.ID? - @FocusState private var focusedNameEditorCommandID: UserCustomCommand.ID? - - private let keyTokenResolver = ShortcutKeyTokenResolver() - - private static let symbolPresets = [ + @State var selectedCustomCommandID: UserCustomCommand.ID? + @State var recordingCustomCommandID: UserCustomCommand.ID? + @State var recorderMonitor: Any? + @State var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] + @State var pendingShortcutConflict: CustomCommandShortcutConflict? + @State var pendingShortcut: PendingCustomShortcut? + @State var iconPickerCommandID: UserCustomCommand.ID? + @State var customCommandsFocusAnchor: NSView? + @State var popoverRefocusTask: Task? + @State var commandEditorCommandID: UserCustomCommand.ID? + @State var editingNameCommandID: UserCustomCommand.ID? + @FocusState var focusedNameEditorCommandID: UserCustomCommand.ID? + + let keyTokenResolver = ShortcutKeyTokenResolver() + + static let symbolPresets = [ "terminal", "terminal.fill", "play.fill", @@ -377,1121 +377,4 @@ struct RepositorySettingsView: View { ) } } - - @ViewBuilder - private var customCommandsEditor: some View { - VStack(alignment: .leading, spacing: 10) { - VStack(spacing: 0) { - customCommandsHeaderRow - Divider() - ScrollView { - LazyVStack(spacing: 4) { - ForEach(store.userSettings.customCommands) { command in - customCommandRow(command) - .id(command.id) - } - } - .padding(.horizontal, 6) - .padding(.vertical, 6) - } - .frame(height: customCommandsListHeight) - } - .clipShape(RoundedRectangle(cornerRadius: 8)) - - HStack(spacing: 8) { - Button { - addCustomCommand() - } label: { - ZStack { - Image(systemName: "plus") - .frame(width: 16, height: 16) - } - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .accessibilityLabel("Add command") - } - .buttonStyle(.plain) - .help("Add command") - - Button { - removeSelectedCustomCommand() - } label: { - ZStack { - Image(systemName: "minus") - .frame(width: 16, height: 16) - } - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .accessibilityLabel("Remove selected command") - } - .buttonStyle(.plain) - .disabled(store.userSettings.customCommands.isEmpty) - .help("Remove selected command") - - Spacer(minLength: 0) - - Text("\(store.userSettings.customCommands.count) commands") - .font(.caption) - .foregroundStyle(.secondary) - } - if let invalidMessage = selectedCommandInvalidMessage { - Text(invalidMessage) - .font(.caption) - .foregroundStyle(.red) - } else { - Text("Click cells to edit icon, name, command, and shortcut inline.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .background { - FirstResponderAnchorView { anchor in - if customCommandsFocusAnchor !== anchor { - customCommandsFocusAnchor = anchor - } - } - .frame(width: 0, height: 0) - } - } - - @ViewBuilder - private func customCommandIconCell(_ command: UserCustomCommand) -> some View { - if let binding = bindingForCustomCommand(id: command.id) { - InlineEditableCellButton( - isActive: iconPickerCommandID == command.id, - contentAlignment: .center - ) { - selectCustomCommand(command.id) - toggleIconEditor(for: command.id) - } label: { - Image(systemName: binding.wrappedValue.resolvedSystemImage) - .foregroundStyle(.secondary) - .frame(width: 16, alignment: .center) - .accessibilityHidden(true) - } - .popover( - isPresented: Binding( - get: { iconPickerCommandID == command.id }, - set: { isPresented in - if !isPresented { - closePopoverAndRestoreCommandFocus(for: command.id) - } - } - ), - arrowEdge: .bottom - ) { - iconEditorPopover(for: binding, commandID: command.id) - } - } else { - InlineEditableCellButton( - contentAlignment: .center - ) { - selectCustomCommand(command.id) - } label: { - Image(systemName: command.resolvedSystemImage) - .foregroundStyle(.secondary) - .frame(width: 16, alignment: .center) - .accessibilityHidden(true) - } - } - } - - @ViewBuilder - private func customCommandNameCell(_ command: UserCustomCommand) -> some View { - let isSelected = selectedCustomCommandID == command.id - if isSelected, - editingNameCommandID == command.id, - let binding = bindingForCustomCommand(id: command.id) - { - InlineEditableFieldContainer(isActive: true) { - TextField("", text: binding.title) - .textFieldStyle(.plain) - .padding(.leading, -4) - .focused($focusedNameEditorCommandID, equals: command.id) - .onSubmit { - endNameEditing() - } - } - .onAppear { - focusedNameEditorCommandID = command.id - } - } else { - InlineEditableCellButton { - selectCustomCommand(command.id) - beginNameEditing(for: command.id) - } label: { - Text(bindingForCustomCommand(id: command.id)?.wrappedValue.resolvedTitle ?? command.resolvedTitle) - .lineLimit(1) - } - } - } - - @ViewBuilder - private func customCommandCell(_ command: UserCustomCommand) -> some View { - if let binding = bindingForCustomCommand(id: command.id) { - InlineEditableCellButton( - isActive: commandEditorCommandID == command.id - ) { - selectCustomCommand(command.id) - toggleCommandEditor(for: command.id) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(inlineCommandTitle(for: binding.wrappedValue.execution)) - .font(.caption) - .foregroundStyle(.secondary) - Text(inlineCommandScriptPreview(for: binding.wrappedValue.command)) - .lineLimit(1) - } - } - .popover( - isPresented: Binding( - get: { commandEditorCommandID == command.id }, - set: { isPresented in - if !isPresented { - closePopoverAndRestoreCommandFocus(for: command.id) - } - } - ), - arrowEdge: .bottom - ) { - commandEditorPopover(for: binding) - } - .help("New Tab runs in a new tab. In Place sends input to the focused terminal.") - } else { - InlineEditableCellButton { - selectCustomCommand(command.id) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(inlineCommandTitle(for: command.execution)) - .font(.caption) - .foregroundStyle(.secondary) - Text(inlineCommandScriptPreview(for: command.command)) - .lineLimit(1) - } - } - } - } - - @ViewBuilder - private func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { - let resolvedBinding = resolvedCustomCommandBindings.keybinding(for: customCommandBindingID(for: command.id)) - let shortcutDisplay = resolvedBinding?.display ?? "Unassigned" - let isRecording = recordingCustomCommandID == command.id - - InlineEditableCellButton( - isActive: isRecording, - activeColor: .orange - ) { - selectCustomCommand(command.id) - toggleRecording(for: command.id) - } label: { - Text(isRecording ? "Recording…" : shortcutDisplay) - .font(.body.monospaced()) - .foregroundStyle(isRecording ? Color.orange : (resolvedBinding == nil ? .secondary : .primary)) - .lineLimit(1) - } - .contextMenu { - if command.shortcut != nil { - Button("Clear Shortcut") { - clearShortcut(for: command.id) - } - } - } - .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") - } - - private var effectiveSelectedCommandID: UserCustomCommand.ID? { - selectedCustomCommandID ?? editingNameCommandID ?? commandEditorCommandID ?? iconPickerCommandID - ?? recordingCustomCommandID - } - - private var removableCommandID: UserCustomCommand.ID? { - let commands = store.userSettings.customCommands - if let selectedCustomCommandID, - commands.contains(where: { $0.id == selectedCustomCommandID }) - { - return selectedCustomCommandID - } - if let effectiveSelectedCommandID, - commands.contains(where: { $0.id == effectiveSelectedCommandID }) - { - return effectiveSelectedCommandID - } - return commands.last?.id - } - - private var customCommandsHeaderRow: some View { - HStack(spacing: 8) { - customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) - customCommandHeaderCell("Name", width: customCommandsNameColumnWidth) - customCommandHeaderCell("Command") - customCommandHeaderCell("Shortcut", width: customCommandsShortcutColumnWidth) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .font(.headline) - .foregroundStyle(.secondary) - } - - @ViewBuilder - private func customCommandRow(_ command: UserCustomCommand) -> some View { - let isSelected = selectedCustomCommandID == command.id - HStack(spacing: 8) { - customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { - customCommandIconCell(command) - } - customCommandRowCell(width: customCommandsNameColumnWidth) { - customCommandNameCell(command) - } - customCommandRowCell { - customCommandCell(command) - } - customCommandRowCell(width: customCommandsShortcutColumnWidth) { - customCommandShortcutCell(command) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background { - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor.opacity(0.35) : .clear) - } - .contentShape(RoundedRectangle(cornerRadius: 8)) - .accessibilityAddTraits(.isButton) - .onTapGesture { - selectCustomCommand(command.id) - } - } - - @ViewBuilder - private func customCommandHeaderCell( - _ title: String, - width: CGFloat? = nil, - alignment: Alignment = .leading - ) -> some View { - if let width { - Text(title) - .frame(width: width, alignment: alignment) - } else { - Text(title) - .frame(maxWidth: .infinity, alignment: alignment) - } - } - - @ViewBuilder - private func customCommandRowCell( - width: CGFloat? = nil, - alignment: Alignment = .leading, - @ViewBuilder content: () -> Content - ) -> some View { - if let width { - content() - .frame(width: width, alignment: alignment) - .frame(maxHeight: .infinity, alignment: alignment) - } else { - content() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) - } - } - - private func selectCustomCommand(_ commandID: UserCustomCommand.ID) { - if selectedCustomCommandID != commandID { - selectedCustomCommandID = commandID - } - } - - private func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { - switch execution { - case .shellScript: - return "New Tab" - case .terminalInput: - return "In Place" - case .split: - return "New Split" - } - } - - private func inlineCommandScriptPreview(for script: String) -> String { - let firstLine = - script - .split(separator: "\n", omittingEmptySubsequences: false) - .first - .map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return firstLine.isEmpty ? "Click to set command script" : firstLine - } - - private func iconEditorPopover( - for command: Binding, - commandID: UserCustomCommand.ID - ) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text("Icon") - .font(.headline) - Text("Pick from common symbols or enter any SF Symbol name available in your system.") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - TextField("SF Symbol name", text: command.systemImage) - .textFieldStyle(.roundedBorder) - Button("Open SF Symbols") { - openSFSymbolsReference() - } - } - - ScrollView { - LazyVGrid( - columns: Array(repeating: GridItem(.fixed(24), spacing: 8), count: 10), - spacing: 8 - ) { - ForEach(Self.symbolPresets, id: \.self) { symbol in - Button { - command.wrappedValue.systemImage = symbol - closePopoverAndRestoreCommandFocus(for: commandID) - } label: { - Image(systemName: symbol) - .frame(width: 24, height: 24) - .accessibilityHidden(true) - } - .buttonStyle(.plain) - .help(symbol) - } - } - .padding(12) - } - .frame(maxHeight: 124) - } - .padding(12) - .frame(width: 360) - } - - private func commandEditorPopover(for command: Binding) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text("Command") - .font(.headline) - Text("Choose where this command runs and edit the script used by this repository custom command.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Picker("Execution", selection: command.execution) { - Text("New Tab") - .tag(UserCustomCommandExecution.shellScript) - Text("In Place") - .tag(UserCustomCommandExecution.terminalInput) - Text("New Split") - .tag(UserCustomCommandExecution.split) - } - .pickerStyle(.segmented) - - if command.wrappedValue.execution == .split { - Picker("Split Direction", selection: command.splitDirection) { - ForEach(UserCustomSplitDirection.allCases) { direction in - Text(direction.title).tag(direction) - } - } - .pickerStyle(.menu) - .help("Direction to split the focused terminal pane.") - } - - PlainTextEditor( - text: command.command, - isMonospaced: true, - shouldFocus: true, - placeholder: scriptPlaceholder(for: command.wrappedValue.execution) - ) - .frame(height: 140) - - Text(scriptDescription(for: command.wrappedValue.execution)) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - if command.wrappedValue.execution.supportsCloseOnSuccess { - Toggle("Close on success", isOn: command.closeOnSuccess) - .help("Automatically closes the tab or split when the command exits with code 0.") - .toggleStyle(.checkbox) - } - } - .padding(12) - .frame(width: 420) - } - - private var selectedCommandInvalidMessage: String? { - guard let selectedCustomCommandID else { - return nil - } - return invalidMessageByCommandID[selectedCustomCommandID] - } - - private func openSFSymbolsReference() { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.SFSymbols") { - let configuration = NSWorkspace.OpenConfiguration() - NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, _ in } - return - } - guard let url = URL(string: "https://developer.apple.com/sf-symbols/") else { - return - } - NSWorkspace.shared.open(url) - } - - private func toggleIconEditor(for commandID: UserCustomCommand.ID) { - if iconPickerCommandID == commandID { - closePopoverAndRestoreCommandFocus(for: commandID) - return - } - iconPickerCommandID = commandID - commandEditorCommandID = nil - endNameEditing() - if recordingCustomCommandID == commandID { - recordingCustomCommandID = nil - } - } - - private func toggleCommandEditor(for commandID: UserCustomCommand.ID) { - if commandEditorCommandID == commandID { - closePopoverAndRestoreCommandFocus(for: commandID) - return - } - commandEditorCommandID = commandID - iconPickerCommandID = nil - endNameEditing() - if recordingCustomCommandID == commandID { - recordingCustomCommandID = nil - } - } - - private func beginNameEditing(for commandID: UserCustomCommand.ID) { - editingNameCommandID = commandID - iconPickerCommandID = nil - commandEditorCommandID = nil - if recordingCustomCommandID == commandID { - recordingCustomCommandID = nil - } - focusedNameEditorCommandID = commandID - } - - private func endNameEditing() { - editingNameCommandID = nil - focusedNameEditorCommandID = nil - } - - private func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { - popoverRefocusTask?.cancel() - - var transaction = Transaction() - transaction.animation = nil - withTransaction(transaction) { - iconPickerCommandID = nil - commandEditorCommandID = nil - } - focusCustomCommandsArea() - scheduleCommandFocusRestore(for: commandID) - } - - private func focusCustomCommandsArea() { - guard let window = NSApp.keyWindow else { - return - } - if let customCommandsFocusAnchor, - customCommandsFocusAnchor.window === window - { - _ = window.makeFirstResponder(customCommandsFocusAnchor) - return - } - _ = window.makeFirstResponder(nil) - } - - private func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { - popoverRefocusTask = Task { @MainActor in - await Task.yield() - guard !Task.isCancelled else { - return - } - guard iconPickerCommandID == nil, commandEditorCommandID == nil else { - return - } - guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { - return - } - - var transaction = Transaction() - transaction.animation = nil - withTransaction(transaction) { - selectCustomCommand(commandID) - endNameEditing() - } - } - } - - private func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { - switch execution { - case .shellScript: - return "npm test && swift test" - case .terminalInput: - return "pnpm test --watch" - case .split: - return "tail -f logs/app.log" - } - } - - private func scriptDescription(for execution: UserCustomCommandExecution) -> String { - switch execution { - case .shellScript: - return "Runs in a new terminal tab." - case .terminalInput: - return "Sends input to the currently focused terminal." - case .split: - return "Runs in a new split of the focused terminal." - } - } - - private var resolvedCustomCommandBindings: ResolvedKeybindingMap { - let commands = store.userSettings.customCommands - let migration = LegacyCustomCommandShortcutMigration.migrate(commands: commands) - return KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: commands), - userOverrides: store.keybindingUserOverrides, - migratedOverrides: migration.overrides - ) - } - - private func customCommandBindingID(for commandID: String) -> String { - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID) - } - - private func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding? { - guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { - return nil - } - - return Binding( - get: { - store.userSettings.customCommands.first(where: { $0.id == commandID }) - ?? UserCustomCommand( - id: commandID, - title: "", - systemImage: "terminal", - command: "", - execution: .shellScript, - shortcut: nil - ) - }, - set: { updatedCommand in - updateCustomCommand(id: commandID) { command in - command.title = updatedCommand.title - command.systemImage = updatedCommand.systemImage - command.command = updatedCommand.command - command.execution = updatedCommand.execution - command.splitDirection = updatedCommand.splitDirection - command.closeOnSuccess = updatedCommand.closeOnSuccess - command.shortcut = updatedCommand.shortcut - } - } - ) - } - - private func syncSelectedCommandID(with commands: [UserCustomCommand]) { - guard !commands.isEmpty else { - selectedCustomCommandID = nil - recordingCustomCommandID = nil - iconPickerCommandID = nil - commandEditorCommandID = nil - editingNameCommandID = nil - focusedNameEditorCommandID = nil - return - } - - if let selectedCustomCommandID, - commands.contains(where: { $0.id == selectedCustomCommandID }) - { - return - } - - selectedCustomCommandID = commands[0].id - } - - private func clearRemovedCommandState(using commands: [UserCustomCommand]) { - let validIDs = Set(commands.map(\.id)) - - invalidMessageByCommandID = invalidMessageByCommandID.filter { validIDs.contains($0.key) } - - if let recordingCustomCommandID, - !validIDs.contains(recordingCustomCommandID) - { - self.recordingCustomCommandID = nil - } - - if let iconPickerCommandID, - !validIDs.contains(iconPickerCommandID) - { - self.iconPickerCommandID = nil - } - - if let commandEditorCommandID, - !validIDs.contains(commandEditorCommandID) - { - self.commandEditorCommandID = nil - } - - if let editingNameCommandID, - !validIDs.contains(editingNameCommandID) - { - self.editingNameCommandID = nil - focusedNameEditorCommandID = nil - } - } - - private func addCustomCommand() { - let commandsBinding = $store.userSettings.customCommands - let current = commandsBinding.wrappedValue - let next = UserRepositorySettings.normalizedCommands(current + [.default(index: current.count)]) - commandsBinding.wrappedValue = next - guard let commandID = next.last?.id else { - selectedCustomCommandID = nil - editingNameCommandID = nil - focusedNameEditorCommandID = nil - return - } - selectedCustomCommandID = commandID - editingNameCommandID = commandID - focusedNameEditorCommandID = commandID - iconPickerCommandID = nil - commandEditorCommandID = nil - recordingCustomCommandID = nil - } - - private func removeSelectedCustomCommand() { - guard let selectedCommandID = removableCommandID else { - return - } - - let commandsBinding = $store.userSettings.customCommands - var commands = commandsBinding.wrappedValue - let removalIndex: Int? - if let index = commands.firstIndex(where: { $0.id == selectedCommandID }) { - removalIndex = index - commands.remove(at: index) - } else if !commands.isEmpty { - removalIndex = commands.count - 1 - commands.removeLast() - } else { - removalIndex = nil - } - - guard let removalIndex else { - return - } - - let normalizedCommands = UserRepositorySettings.normalizedCommands(commands) - commandsBinding.wrappedValue = normalizedCommands - - if normalizedCommands.isEmpty { - selectedCustomCommandID = nil - } else if removalIndex < normalizedCommands.count { - selectedCustomCommandID = normalizedCommands[removalIndex].id - } else { - selectedCustomCommandID = normalizedCommands[normalizedCommands.count - 1].id - } - clearRemovedCommandState(using: normalizedCommands) - } - - private func clearShortcut(for commandID: UserCustomCommand.ID) { - invalidMessageByCommandID[commandID] = nil - updateCustomCommand(id: commandID) { command in - command.shortcut = nil - } - if recordingCustomCommandID == commandID { - recordingCustomCommandID = nil - } - } - - private func updateCustomCommand( - id: UserCustomCommand.ID, - update: (inout UserCustomCommand) -> Void - ) { - let commandsBinding = $store.userSettings.customCommands - var commands = commandsBinding.wrappedValue - guard let index = commands.firstIndex(where: { $0.id == id }) else { - return - } - - update(&commands[index]) - commandsBinding.wrappedValue = UserRepositorySettings.normalizedCommands(commands) - } - - private func toggleRecording(for commandID: UserCustomCommand.ID) { - invalidMessageByCommandID[commandID] = nil - iconPickerCommandID = nil - commandEditorCommandID = nil - endNameEditing() - - if recordingCustomCommandID == commandID { - recordingCustomCommandID = nil - return - } - - recordingCustomCommandID = commandID - } - - private func startRecorderMonitor() { - stopRecorderMonitor() - recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in - guard let commandID = recordingCustomCommandID else { - return event - } - handleRecorderEvent(event, commandID: commandID) - return nil - } - } - - private func stopRecorderMonitor() { - if let recorderMonitor { - NSEvent.removeMonitor(recorderMonitor) - self.recorderMonitor = nil - } - } - - private func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { - if event.keyCode == 53 { // Escape - recordingCustomCommandID = nil - return - } - - guard - let keyToken = keyTokenResolver.resolveKeyToken( - keyCode: event.keyCode, - charactersIgnoringModifiers: event.charactersIgnoringModifiers - ) - else { - invalidMessageByCommandID[commandID] = "Unsupported key. Use letters, numbers, or punctuation." - return - } - - let modifiers = KeybindingModifiers( - command: event.modifierFlags.contains(.command), - shift: event.modifierFlags.contains(.shift), - option: event.modifierFlags.contains(.option), - control: event.modifierFlags.contains(.control) - ) - - guard !modifiers.isEmpty else { - invalidMessageByCommandID[commandID] = "Shortcut must include at least one modifier key." - return - } - - let binding = Keybinding(key: keyToken, modifiers: modifiers) - guard let shortcut = binding.userCustomShortcut else { - invalidMessageByCommandID[commandID] = - "Custom command shortcuts support letters, numbers, and punctuation only." - return - } - - applyRecordedShortcut(shortcut.normalized(), to: commandID) - } - - private func applyRecordedShortcut( - _ shortcut: UserCustomShortcut, - to commandID: UserCustomCommand.ID - ) { - invalidMessageByCommandID[commandID] = nil - - guard let existingCommand = firstConflictingCommand(for: commandID, shortcut: shortcut) else { - updateCustomCommand(id: commandID) { command in - command.shortcut = shortcut - } - recordingCustomCommandID = nil - return - } - - let newTitle = - store.userSettings.customCommands.first(where: { $0.id == commandID })?.resolvedTitle ?? "Command" - - pendingShortcutConflict = CustomCommandShortcutConflict( - newCommandID: commandID, - newCommandTitle: newTitle, - existingCommandID: existingCommand.id, - existingCommandTitle: existingCommand.resolvedTitle, - shortcutDisplay: shortcut.display - ) - pendingShortcut = PendingCustomShortcut(commandID: commandID, shortcut: shortcut) - recordingCustomCommandID = nil - } - - private func firstConflictingCommand( - for commandID: UserCustomCommand.ID, - shortcut: UserCustomShortcut - ) -> UserCustomCommand? { - store.userSettings.customCommands.first { command in - guard command.id != commandID else { return false } - guard let existingShortcut = command.shortcut?.normalized() else { return false } - return existingShortcut == shortcut - } - } - - private func applyPendingShortcut(replacingConflict: Bool) { - guard let pendingShortcut else { - clearPendingShortcutConflict() - return - } - - if replacingConflict, - let existingCommandID = pendingShortcutConflict?.existingCommandID - { - updateCustomCommand(id: existingCommandID) { command in - command.shortcut = nil - } - } - - updateCustomCommand(id: pendingShortcut.commandID) { command in - command.shortcut = pendingShortcut.shortcut - } - - clearPendingShortcutConflict() - } - - private func clearPendingShortcutConflict() { - pendingShortcutConflict = nil - pendingShortcut = nil - } - - private var isShortcutConflictAlertPresented: Binding { - Binding( - get: { pendingShortcutConflict != nil }, - set: { shouldPresent in - if !shouldPresent { - clearPendingShortcutConflict() - } - } - ) - } - - private var customCommandsIconColumnWidth: CGFloat { 48 } - - private var customCommandsNameColumnWidth: CGFloat { 130 } - - private var customCommandsShortcutColumnWidth: CGFloat { 100 } - - private var customCommandsListHeight: CGFloat { 200 } -} - -private struct InlineEditableCellButton: View { - let isActive: Bool - let activeColor: Color - let contentAlignment: Alignment - let action: () -> Void - @ViewBuilder let label: () -> Label - - @State private var isHovering = false - - init( - isActive: Bool = false, - activeColor: Color = .accentColor, - contentAlignment: Alignment = .leading, - action: @escaping () -> Void, - @ViewBuilder label: @escaping () -> Label - ) { - self.isActive = isActive - self.activeColor = activeColor - self.contentAlignment = contentAlignment - self.action = action - self.label = label - } - - var body: some View { - Button(action: action) { - label() - .frame(maxWidth: .infinity, alignment: contentAlignment) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: contentAlignment) - .onHover { hovering in - isHovering = hovering - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(borderColor, lineWidth: borderWidth) - } - } - - private var borderColor: Color { - if isActive { - return activeColor - } - if isHovering { - return Color(nsColor: .tertiaryLabelColor) - } - return .clear - } - - private var borderWidth: CGFloat { - (isActive || isHovering) ? 1 : 0 - } -} - -private struct InlineEditableFieldContainer: View { - let isActive: Bool - let activeColor: Color - @ViewBuilder let content: () -> Content - @State private var isHovering = false - - init( - isActive: Bool = false, - activeColor: Color = .accentColor, - @ViewBuilder content: @escaping () -> Content - ) { - self.isActive = isActive - self.activeColor = activeColor - self.content = content - } - - var body: some View { - content() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .onHover { hovering in - isHovering = hovering - } - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(borderColor, lineWidth: borderWidth) - } - } - - private var borderColor: Color { - if isActive { - return activeColor - } - if isHovering { - return Color(nsColor: .tertiaryLabelColor) - } - return .clear - } - - private var borderWidth: CGFloat { - (isActive || isHovering) ? 1 : 0 - } -} - -private struct FirstResponderAnchorView: NSViewRepresentable { - let onResolve: (NSView) -> Void - - func makeNSView(context: Context) -> FirstResponderAnchorNSView { - let view = FirstResponderAnchorNSView() - onResolve(view) - return view - } - - func updateNSView(_ nsView: FirstResponderAnchorNSView, context: Context) { - onResolve(nsView) - } -} - -private final class FirstResponderAnchorNSView: NSView { - override var acceptsFirstResponder: Bool { - true - } -} - -private struct BranchPickerPopover: View { - @Binding var searchText: String - let options: [String] - let automaticLabel: String - let selection: String? - let onSelect: (String?) -> Void - @FocusState private var isSearchFocused: Bool - - var filteredOptions: [String] { - if searchText.isEmpty { return options } - return options.filter { $0.localizedCaseInsensitiveContains(searchText) } - } - - var body: some View { - VStack(spacing: 0) { - TextField("Filter branches...", text: $searchText) - .textFieldStyle(.roundedBorder) - .focused($isSearchFocused) - .padding(8) - Divider() - List { - Button { - onSelect(nil) - } label: { - HStack { - Text(automaticLabel) - Spacer() - if selection == nil { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - .accessibilityHidden(true) - } - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - ForEach(filteredOptions, id: \.self) { ref in - Button { - onSelect(ref) - } label: { - HStack { - Text(ref) - Spacer() - if selection == ref { - Image(systemName: "checkmark") - .foregroundStyle(.tint) - .accessibilityHidden(true) - } - } - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .listStyle(.plain) - } - .frame(width: 300, height: 350) - .onAppear { isSearchFocused = true } - } -} - -private struct CustomCommandShortcutConflict: Equatable { - let newCommandID: UserCustomCommand.ID - let newCommandTitle: String - let existingCommandID: UserCustomCommand.ID - let existingCommandTitle: String - let shortcutDisplay: String -} - -private struct PendingCustomShortcut: Equatable { - let commandID: UserCustomCommand.ID - let shortcut: UserCustomShortcut -} - -private struct ScriptEnvironmentRow: View { - let name: String - var value: String? - let description: String - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(name) - .monospaced() - if let value { - Text(value) - .foregroundStyle(.secondary) - .monospaced() - } - Text(description) - .foregroundStyle(.tertiary) - } - } } -- 2.51.2 From e43a7385d996740b30f7da68e68f6c349b2180c3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:31:39 +0900 Subject: [PATCH 330/528] Split canvas focus and support views --- .../Canvas/Views/CanvasSupportViews.swift | 413 +++++++++ .../Canvas/Views/CanvasView+Focus.swift | 297 +++++++ .../Features/Canvas/Views/CanvasView.swift | 838 ++---------------- 3 files changed, 777 insertions(+), 771 deletions(-) create mode 100644 supacode/Features/Canvas/Views/CanvasSupportViews.swift create mode 100644 supacode/Features/Canvas/Views/CanvasView+Focus.swift diff --git a/supacode/Features/Canvas/Views/CanvasSupportViews.swift b/supacode/Features/Canvas/Views/CanvasSupportViews.swift new file mode 100644 index 00000000..f8f8a44a --- /dev/null +++ b/supacode/Features/Canvas/Views/CanvasSupportViews.swift @@ -0,0 +1,413 @@ +import AppKit +import SwiftUI + +struct ActiveResize { + let edge: CanvasCardView.CardResizeEdge + var translation: CGSize +} + +// MARK: - Scroll Container + +/// Wraps SwiftUI content in an NSView whose `scrollWheel` override catches +/// unhandled scroll-wheel events and translates them into canvas-offset changes. +/// Focused terminals consume their own scroll events (they don't call super), +/// so only events over empty space or unfocused cards reach this container. +struct CanvasScrollContainer: NSViewRepresentable { + @Binding var offset: CGSize + @Binding var lastOffset: CGSize + @Binding var scale: CGFloat + @Binding var lastScale: CGFloat + var isInteractionEnabled: Bool + @ViewBuilder var content: Content + + func makeCoordinator() -> CanvasScrollCoordinator { + CanvasScrollCoordinator() + } + + func makeNSView(context: Context) -> CanvasScrollContainerView { + let container = CanvasScrollContainerView() + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.topAnchor.constraint(equalTo: container.topAnchor), + hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), + hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + container.scrollCoordinator = context.coordinator + return container + } + + func updateNSView(_ nsView: CanvasScrollContainerView, context: Context) { + context.coordinator.offset = $offset + context.coordinator.lastOffset = $lastOffset + context.coordinator.scale = $scale + context.coordinator.lastScale = $lastScale + nsView.isInteractionEnabled = isInteractionEnabled + if let hosting = nsView.subviews.first as? NSHostingView { + hosting.rootView = content + } + } +} + +class CanvasScrollCoordinator { + var offset: Binding = .constant(.zero) + var lastOffset: Binding = .constant(.zero) + var scale: Binding = .constant(1.0) + var lastScale: Binding = .constant(1.0) + + func handleScroll(deltaX: CGFloat, deltaY: CGFloat) { + let current = offset.wrappedValue + let newOffset = CGSize( + width: current.width + deltaX, + height: current.height + deltaY + ) + offset.wrappedValue = newOffset + lastOffset.wrappedValue = newOffset + } + + func handleZoom(deltaY: CGFloat, anchor: CGPoint, isPrecise: Bool) { + let result = CanvasZoomMath.zoom( + currentScale: scale.wrappedValue, + currentOffset: offset.wrappedValue, + deltaY: deltaY, + anchor: anchor, + isPrecise: isPrecise + ) + scale.wrappedValue = result.scale + lastScale.wrappedValue = result.scale + offset.wrappedValue = result.offset + lastOffset.wrappedValue = result.offset + } + + func setOffset(_ newOffset: CGSize) { + offset.wrappedValue = newOffset + lastOffset.wrappedValue = newOffset + } +} + +/// Pure zoom math, extracted for testability. +enum CanvasZoomMath { + static let minScale: CGFloat = 0.25 + static let maxScale: CGFloat = 2.0 + + struct Result: Equatable { + let scale: CGFloat + let offset: CGSize + } + + /// Compute the new scale and offset for a Cmd+wheel zoom step. + /// Keeps the canvas point under `anchor` fixed under the cursor: + /// `screen = canvas * scale + offset` ⇒ `canvas = (anchor - offset) / scale`. + static func zoom( + currentScale: CGFloat, + currentOffset: CGSize, + deltaY: CGFloat, + anchor: CGPoint, + isPrecise: Bool + ) -> Result { + let sensitivity: CGFloat = isPrecise ? 0.0025 : 0.005 + let factor = exp(deltaY * sensitivity) + let newScale = max(minScale, min(maxScale, currentScale * factor)) + guard newScale != currentScale else { + return Result(scale: currentScale, offset: currentOffset) + } + let canvasX = (anchor.x - currentOffset.width) / currentScale + let canvasY = (anchor.y - currentOffset.height) / currentScale + let newOffset = CGSize( + width: anchor.x - canvasX * newScale, + height: anchor.y - canvasY * newScale + ) + return Result(scale: newScale, offset: newOffset) + } +} + +class CanvasScrollContainerView: NSView { + var scrollCoordinator: CanvasScrollCoordinator? + /// When false (a card is expanded), the container ignores scroll/zoom/ + /// middle-drag so the canvas can't pan or zoom behind the expanded card. + var isInteractionEnabled = true { + didSet { + guard !isInteractionEnabled, oldValue else { return } + if isMiddlePanning { endMiddlePan() } + isPanning = false + } + } + + /// Whether the container is actively redirecting scroll events to canvas + /// panning (as opposed to the brief bounce period after a gesture ends). + var isPanning = false + var scrollMonitor: Any? + /// Brief delay after finger-up to wait for momentum events. + var momentumTimer: Timer? + /// Grace period after a pan gesture ends. A follow-up gesture that begins + /// during this window is still treated as canvas panning, even if the + /// cursor now sits on a focused terminal. + var bounceTimer: Timer? + + // MARK: - Middle-click pan + var middleButtonMonitor: Any? + var isMiddlePanning = false + var middlePanStartLocation: NSPoint = .zero + var middlePanStartOffset: CGSize = .zero + var hasPushedPanCursor = false + + override func scrollWheel(with event: NSEvent) { + guard isInteractionEnabled else { + super.scrollWheel(with: event) + return + } + if handleZoomEventIfNeeded(event) { return } + if event.phase == .began { + startPanning() + } + if event.phase == .began || event.phase == .changed || event.phase == .mayBegin || event.momentumPhase != [] { + scrollCoordinator?.handleScroll(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY) + return + } + super.scrollWheel(with: event) + } + + /// If the event is a Cmd+scroll, route it to canvas zoom and report `true`. + /// Used by both the direct `scrollWheel` override and the local monitor so + /// pressing Cmd mid-gesture switches behavior immediately. + fileprivate func handleZoomEventIfNeeded(_ event: NSEvent) -> Bool { + guard isInteractionEnabled else { return false } + guard event.modifierFlags.contains(.command), event.scrollingDeltaY != 0 else { return false } + let viewLocation = convert(event.locationInWindow, from: nil) + let anchor = CGPoint(x: viewLocation.x, y: bounds.height - viewLocation.y) + scrollCoordinator?.handleZoom( + deltaY: event.scrollingDeltaY, + anchor: anchor, + isPrecise: event.hasPreciseScrollingDeltas + ) + return true + } + + // MARK: - Pan lifecycle + + func startPanning() { + isPanning = true + momentumTimer?.invalidate() + momentumTimer = nil + bounceTimer?.invalidate() + bounceTimer = nil + guard scrollMonitor == nil else { return } + installMonitor() + } + + func installMonitor() { + scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, event.window === self.window else { return event } + guard self.isInteractionEnabled else { return event } + + // Cmd toggled mid-gesture — switch to zoom for this event. + if self.handleZoomEventIfNeeded(event) { return nil } + + // --- New gesture ------------------------------------------------ + if event.phase == .began { + if self.isPanning { + // Already panning (edge case). Let normal dispatch decide. + return event + } + // Within the bounce window — treat as a continuation of panning. + self.startPanning() + self.scrollCoordinator?.handleScroll( + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY + ) + return nil + } + + // Only intercept while actively panning (not during bounce). + guard self.isPanning else { return event } + + // --- Ongoing gesture / momentum -------------------------------- + self.momentumTimer?.invalidate() + self.momentumTimer = nil + + if event.phase == .changed || event.momentumPhase != [] { + self.scrollCoordinator?.handleScroll( + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY + ) + } + + // Finger lifted — momentum may follow shortly. + if event.phase == .ended || event.phase == .cancelled { + self.momentumTimer = Timer.scheduledTimer( + withTimeInterval: 0.1, repeats: false + ) { [weak self] _ in + MainActor.assumeIsolated { self?.enterBounce() } + } + } + + // Momentum finished. + if event.momentumPhase == .ended || event.momentumPhase == .cancelled { + self.enterBounce() + } + + return nil + } + } + + /// Transition from active panning to the bounce (grace) period. + /// The monitor stays alive so a quick follow-up gesture resumes panning. + func enterBounce() { + isPanning = false + momentumTimer?.invalidate() + momentumTimer = nil + bounceTimer?.invalidate() + bounceTimer = Timer.scheduledTimer( + withTimeInterval: 0.3, repeats: false + ) { [weak self] _ in + MainActor.assumeIsolated { self?.tearDownMonitor() } + } + } + + func tearDownMonitor() { + isPanning = false + momentumTimer?.invalidate() + momentumTimer = nil + bounceTimer?.invalidate() + bounceTimer = nil + if let monitor = scrollMonitor { + scrollMonitor = nil + DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } + } + } + + // MARK: - Middle-click pan + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + installMiddleButtonMonitor() + } else { + tearDownMiddleButtonMonitor() + } + } + + func installMiddleButtonMonitor() { + guard middleButtonMonitor == nil else { return } + let mask: NSEvent.EventTypeMask = [.otherMouseDown, .otherMouseDragged, .otherMouseUp] + middleButtonMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in + guard let self, event.window === self.window, event.buttonNumber == 2 else { return event } + guard self.isInteractionEnabled else { return event } + + switch event.type { + case .otherMouseDown: + let location = self.convert(event.locationInWindow, from: nil) + guard self.bounds.contains(location) else { return event } + self.beginMiddlePan(at: event.locationInWindow) + return nil + case .otherMouseDragged: + guard self.isMiddlePanning else { return event } + self.updateMiddlePan(to: event.locationInWindow) + return nil + case .otherMouseUp: + guard self.isMiddlePanning else { return event } + self.endMiddlePan() + return nil + default: + return event + } + } + } + + func beginMiddlePan(at windowLocation: NSPoint) { + isMiddlePanning = true + middlePanStartLocation = windowLocation + middlePanStartOffset = scrollCoordinator?.offset.wrappedValue ?? .zero + if !hasPushedPanCursor { + NSCursor.closedHand.push() + hasPushedPanCursor = true + } + } + + func updateMiddlePan(to windowLocation: NSPoint) { + let deltaX = windowLocation.x - middlePanStartLocation.x + // Window Y grows upward; canvas offset Y grows downward (SwiftUI top-left). + let deltaY = middlePanStartLocation.y - windowLocation.y + let newOffset = CGSize( + width: middlePanStartOffset.width + deltaX, + height: middlePanStartOffset.height + deltaY + ) + scrollCoordinator?.setOffset(newOffset) + } + + func endMiddlePan() { + isMiddlePanning = false + if hasPushedPanCursor { + NSCursor.pop() + hasPushedPanCursor = false + } + } + + func tearDownMiddleButtonMonitor() { + if isMiddlePanning { endMiddlePan() } + if let monitor = middleButtonMonitor { + middleButtonMonitor = nil + DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } + } + } + + override func removeFromSuperview() { + tearDownMonitor() + tearDownMiddleButtonMonitor() + super.removeFromSuperview() + } +} + +/// Screen-space transform for a card on canvas. +struct CardScreenGeometry { + var size: CGSize + var center: CGPoint + var scale: CGFloat +} + +/// An `Animatable` container that interpolates a card between its in-canvas +/// frame (`progress` 0) and the full-viewport expanded frame (`progress` 1). +/// +/// Because `animatableData` is `progress`, SwiftUI re-evaluates `body` on every +/// frame of the transition, so the card's size, center, and scale advance +/// together and the terminal re-flows in lock-step with the offset/scale — a +/// true magic-move from where the card sits. This sidesteps SwiftUI's implicit +/// per-modifier interpolation, which only reached the Animatable terminal and +/// left offset/scale to snap (the "grows from the center" / "no animation" bugs). +struct AnimatedExpandableCard: View, Animatable { + var progress: CGFloat + var collapsed: CardScreenGeometry + var expanded: CardScreenGeometry + let titleBarHeight: CGFloat + @ViewBuilder let content: (CGSize) -> Content + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + var body: some View { + let fraction = max(0, min(1, progress)) + let size = CGSize( + width: lerp(collapsed.size.width, expanded.size.width, fraction), + height: lerp(collapsed.size.height, expanded.size.height, fraction) + ) + let center = CGPoint( + x: lerp(collapsed.center.x, expanded.center.x, fraction), + y: lerp(collapsed.center.y, expanded.center.y, fraction) + ) + let scale = lerp(collapsed.scale, expanded.scale, fraction) + content(size) + .scaleEffect(scale, anchor: .center) + .offset( + x: center.x - size.width / 2, + y: center.y - (size.height + titleBarHeight) / 2 + ) + } + + func lerp(_ start: CGFloat, _ end: CGFloat, _ fraction: CGFloat) -> CGFloat { + start + (end - start) * fraction + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView+Focus.swift b/supacode/Features/Canvas/Views/CanvasView+Focus.swift new file mode 100644 index 00000000..88a5d009 --- /dev/null +++ b/supacode/Features/Canvas/Views/CanvasView+Focus.swift @@ -0,0 +1,297 @@ +import AppKit +import SwiftUI + +extension CanvasView { + /// Run a reducer-driven canvas command (from the command palette) and clear + /// the one-shot request. + func fulfillCommandRequest(_ request: CanvasCommandRequest?) { + guard let request else { return } + switch request.command { + case .toggleExpand: + toggleExpandFocusedCard() + case .arrange: + arrangeCardsWithFit() + case .organize: + organizeCardsWithFit() + case .selectAll: + selectAllCards() + } + onCommandConsumed(request.id) + } + + /// Expand a card in place: raise it to the top, then flip `expandedTabID` + /// inside one `withAnimation` so the card's size, center, and scale all + /// interpolate together from its in-canvas frame to the full viewport at + /// scale 1 — a magic-move from where the card actually sits. The canvas + /// transform is left untouched, so every other card stays exactly where it + /// was, behind a dimming scrim. + func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { + guard viewportSize.width > 0, viewportSize.height > 0, + layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil + else { return } + focusSingleCard(tabID, states: states) + withAnimation(expandAnimation) { + expandedTabID = tabID + } + } + + /// Restore the expanded card back into the (unchanged) canvas. + func collapseExpand() { + guard expandedTabID != nil else { return } + withAnimation(expandAnimation) { + expandedTabID = nil + } + } + + /// Drop expand state without animation — used right before a relayout + /// (Arrange/Organize) takes over the canvas. + func cancelExpandForRelayout() { + expandedTabID = nil + } + + func fulfillPendingFocusRequest( + _ request: CanvasFocusRequest?, + states: [WorktreeTerminalState] + ) { + guard let request else { return } + let tabID = CanvasFocusResolver.resolve( + request: request, + candidates: collectFocusCandidates(from: states), + currentPrimaryTabID: selectionState.primaryTabID + ) + guard let tabID else { return } + focusSingleCard(tabID, states: states) + focusViewport(on: tabID) + onFocusRequestConsumed(request.id) + } + + func focusViewport(on tabID: TerminalTabID) { + guard viewportSize.width > 0, viewportSize.height > 0 else { return } + let cardKey = tabID.rawValue.uuidString + guard let layout = layoutStore.cardLayouts[cardKey] else { return } + + let horizontalPadding: CGFloat = 80 + let verticalPadding: CGFloat = 80 + bottomToolbarReserve + let availableWidth = max(1, viewportSize.width - horizontalPadding) + let availableHeight = max(1, viewportSize.height - verticalPadding) + let targetScale = max( + 0.25, + min( + 1.25, + min( + availableWidth / layout.size.width, + availableHeight / (layout.size.height + titleBarHeight) + ) + ) + ) + let targetOffset = CGSize( + width: viewportSize.width / 2 - layout.position.x * targetScale, + height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale + ) + canvasScale = targetScale + canvasOffset = targetOffset + lastCanvasScale = targetScale + lastCanvasOffset = targetOffset + focusViewportAnimationID &+= 1 + } + + func handleSelectionShieldTap( + _ tabID: TerminalTabID, + surfaceState _: WorktreeTerminalState, + states: [WorktreeTerminalState] + ) { + let cmdHeld = NSEvent.modifierFlags.contains(.command) + mutateSelection(states: states) { state in + if cmdHeld { + state.toggleSelection(tabID) + } else if state.isBroadcasting, state.selectedTabIDs.contains(tabID) { + state.setPrimary(tabID) + } else { + state.focusSingle(tabID) + } + } + } + + func clearSelection(states: [WorktreeTerminalState]) { + mutateSelection(states: states) { state in + state.clear() + } + } + + func pruneSelection( + previousOrder: [TerminalTabID], + currentOrder: [TerminalTabID], + states: [WorktreeTerminalState] + ) { + let previousPrimaryTabID = selectionState.primaryTabID + selectionState.pruneAutoAdvancingPrimary(previousOrder: previousOrder, currentOrder: currentOrder) + syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) + syncBroadcastCallbacks(states: states) + } + + func mutateSelection( + states: [WorktreeTerminalState], + mutation: (inout CanvasSelectionState) -> Void + ) { + let previousPrimaryTabID = selectionState.primaryTabID + mutation(&selectionState) + selectionState.prune(to: Set(collectVisibleTabIDs(from: states))) + syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) + syncBroadcastCallbacks(states: states) + } + + func syncPrimaryFocus( + from previousTabID: TerminalTabID?, + to newTabID: TerminalTabID?, + states: [WorktreeTerminalState] + ) { + if let previousTabID, previousTabID != newTabID { + unfocusTab(previousTabID, states: states) + } + + guard let newTabID, + let ownerState = states.first(where: { $0.surfaceView(for: newTabID) != nil }), + let surfaceView = ownerState.surfaceView(for: newTabID) + else { + terminalManager.canvasFocusedWorktreeID = nil + onFocusedWorktreeChanged(nil) + return + } + + layoutStore.moveToFront(newTabID.rawValue.uuidString) + ownerState.tabManager.selectTab(newTabID) + terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID + onFocusedWorktreeChanged(ownerState.worktreeID) + surfaceView.focusDidChange(true) + surfaceView.requestFocus() + } + + func unfocusTab(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { + guard let state = states.first(where: { $0.surfaceView(for: tabID) != nil }) else { return } + for surface in state.splitTree(for: tabID).leaves() { + surface.focusDidChange(false) + } + } + + func syncBroadcastCallbacks(states: [WorktreeTerminalState]) { + clearBroadcastCallbacks(states: states) + + guard selectionState.isBroadcasting, + let primaryTabID = selectionState.primaryTabID, + let primaryState = terminalManager.stateContaining(tabId: primaryTabID) + else { + return + } + + let selectedTabIDs = selectionState.selectedTabIDs + let beginBroadcast = { selectionState.beginBroadcastInteractionIfNeeded() } + for primarySurface in primaryState.splitTree(for: primaryTabID).leaves() { + primarySurface.onCommittedText = { [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] text in + Task { @MainActor in + beginBroadcast() + terminalManager.broadcastCommittedText(text, from: primaryTabID, to: selectedTabIDs) + } + } + primarySurface.onMirroredKey = { + [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] mirroredKey in + Task { @MainActor in + beginBroadcast() + terminalManager.broadcastMirroredKey(mirroredKey, from: primaryTabID, to: selectedTabIDs) + } + } + } + } + + func clearBroadcastCallbacks(states: [WorktreeTerminalState]) { + for state in states { + for tab in state.tabManager.tabs { + for surface in state.splitTree(for: tab.id).leaves() { + surface.onCommittedText = nil + surface.onMirroredKey = nil + } + } + } + } + + // MARK: - Occlusion + + func activateCanvas() { + cleanStaleLayouts() + + let activeStates = terminalManager.activeWorktreeStates + + // Mark all states as canvas-managed so that tree updates (e.g. split + // creation) don't trigger applySurfaceActivity with stale normal-mode + // window visibility, which would occlude every surface. + for state in activeStates { + state.isCanvasManaged = true + } + + // Auto-focus the card that was active before entering canvas. + if let selectedID = terminalManager.selectedWorktreeID, + let state = activeStates.first(where: { $0.worktreeID == selectedID }), + let tabID = state.tabManager.selectedTabId + { + selectionState.focusSingle(tabID) + syncPrimaryFocus(from: nil, to: tabID, states: activeStates) + } else { + selectionState.clear() + syncBroadcastCallbacks(states: activeStates) + } + + for state in activeStates { + state.setAllSurfacesOccluded() + } + // Un-occlude all surfaces visible on canvas (including split panes) + for state in activeStates { + for tab in state.tabManager.tabs { + for surface in state.splitTree(for: tab.id).leaves() { + surface.setOcclusion(true) + } + } + } + } + + func deactivateCanvas() { + expandedTabID = nil + let activeStates = terminalManager.activeWorktreeStates + for state in activeStates { + state.isCanvasManaged = false + } + clearBroadcastCallbacks(states: activeStates) + selectionState.clear() + // Don't occlude surfaces here. In SwiftUI's if/else view swap, + // onAppear fires before onDisappear, so occluding here would undo + // WorktreeTerminalTabsView.onAppear's syncFocus() and cause blank + // surfaces. Cleanup of non-selected worktrees is handled by + // setSelectedWorktreeID in the async exit flow. + } + + /// Looks up the user-pinned `RepositoryAppearance` for a given repo + /// root URL by deriving the canonical `Repository.ID` (the + /// path-policy-normalized path string) and querying the @Shared + /// dict. Returns `.empty` when no entry exists, which keeps cards + /// visually identical to before the appearance feature shipped. + func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { + let id = repositoryID(for: repositoryRootURL) + return repositoryAppearances[id] ?? .empty + } + + /// Resolves the user-defined display title for the repo at this root + /// URL, falling back to `Repository.name(for:)` (folder name) when no + /// custom title was set. Reads from the static dictionary populated + /// by the parent reducer — no per-call `@Shared` subscription on the + /// canvas hot path. + func repositoryDisplayName(for repositoryRootURL: URL) -> String { + let id = repositoryID(for: repositoryRootURL) + return repositoryCustomTitles[id] ?? Repository.name(for: repositoryRootURL) + } + + /// Mirrors the same path normalization the `Repository.ID` is built + /// from, so dict lookups match what the reducer stores. + func repositoryID(for repositoryRootURL: URL) -> Repository.ID { + PathPolicy.normalizePath( + repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true + ) ?? repositoryRootURL.path(percentEncoded: false) + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index bcb8236c..39ee0af8 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -3,8 +3,8 @@ import Sharing import SwiftUI struct CanvasView: View { - @Environment(CommandKeyObserver.self) private var commandKeyObserver - @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(CommandKeyObserver.self) var commandKeyObserver + @Environment(\.resolvedKeybindings) var resolvedKeybindings let terminalManager: WorktreeTerminalManager /// Per-repo display titles resolved by the parent reducer. Used to @@ -22,46 +22,46 @@ struct CanvasView: View { /// Reports whether a card is currently expanded in place, so the parent can /// give the window toolbar a matching scrim (it can't be covered from here). var onExpandedChange: (Bool) -> Void = { _ in } - @State private var layoutStore = CanvasLayoutStore() - @Shared(.repositoryAppearances) private var repositoryAppearances - - @State private var canvasOffset: CGSize = .zero - @State private var lastCanvasOffset: CGSize = .zero - @State private var canvasScale: CGFloat = 1.0 - @State private var lastCanvasScale: CGFloat = 1.0 - @State private var selectionState = CanvasSelectionState() - @State private var lastTitleBarTapDate: Date = .distantPast - @State private var activeResize: [TerminalTabID: ActiveResize] = [:] - @State private var hasPerformedInitialFit = false - @State private var hasSeenCanvasCards = false - @State private var viewportSize: CGSize = .zero - @State private var showsCanvasHelp = false - @State private var configReloadCounter = 0 - @State private var focusViewportAnimationID = 0 + @State var layoutStore = CanvasLayoutStore() + @Shared(.repositoryAppearances) var repositoryAppearances + + @State var canvasOffset: CGSize = .zero + @State var lastCanvasOffset: CGSize = .zero + @State var canvasScale: CGFloat = 1.0 + @State var lastCanvasScale: CGFloat = 1.0 + @State var selectionState = CanvasSelectionState() + @State var lastTitleBarTapDate: Date = .distantPast + @State var activeResize: [TerminalTabID: ActiveResize] = [:] + @State var hasPerformedInitialFit = false + @State var hasSeenCanvasCards = false + @State var viewportSize: CGSize = .zero + @State var showsCanvasHelp = false + @State var configReloadCounter = 0 + @State var focusViewportAnimationID = 0 /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. - @State private var expandedTabID: TerminalTabID? - - private let minCardWidth: CGFloat = 300 - private let minCardHeight: CGFloat = 200 - private let maxCardWidth: CGFloat = 2400 - private let maxCardHeight: CGFloat = 1600 - private let titleBarHeight: CGFloat = 28 - private let cardSpacing: CGFloat = 20 + @State var expandedTabID: TerminalTabID? + + let minCardWidth: CGFloat = 300 + let minCardHeight: CGFloat = 200 + let maxCardWidth: CGFloat = 2400 + let maxCardHeight: CGFloat = 1600 + let titleBarHeight: CGFloat = 28 + let cardSpacing: CGFloat = 20 /// Reserved height at the bottom of the viewport for the help button and /// layout toolbar so cards don't sit underneath them after auto-fit. /// Cards end up shifted upward by half of this amount. - private let bottomToolbarReserve: CGFloat = 50 + let bottomToolbarReserve: CGFloat = 50 /// Margin kept on every side of a card temporarily expanded to near-fullscreen. - private let expandPadding: CGFloat = 40 + let expandPadding: CGFloat = 40 /// Shared animation for expand / restore / relayout. Matches the easeInOut /// 0.2s that `CanvasCardView` uses to animate `cardSize`, so the canvas /// scale/offset stays in lock-step with the card's terminal size refit. - private let expandAnimation: Animation = .easeInOut(duration: 0.2) + let expandAnimation: Animation = .easeInOut(duration: 0.2) /// Width of the screen hosting the canvas window, used to scale the default /// card size. Falls back to the large-screen reference when unknown. - private var hostScreenWidth: CGFloat { + var hostScreenWidth: CGFloat { (NSApp.keyWindow?.screen ?? NSScreen.main)?.frame.width ?? CanvasCardLayout.maxDefaultScreenWidth } @@ -69,7 +69,7 @@ struct CanvasView: View { /// Default size for newly created and uniformly arranged cards, scaled to the /// host screen so small screens (14") don't zoom out into tiny text while /// large screens still get the roomier card. - private var adaptiveDefaultCardSize: CGSize { + var adaptiveDefaultCardSize: CGSize { CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: hostScreenWidth) } @@ -232,7 +232,7 @@ struct CanvasView: View { .onDisappear { deactivateCanvas() } } - private func showsSelectionShield(for tabID: TerminalTabID) -> Bool { + func showsSelectionShield(for tabID: TerminalTabID) -> Bool { if commandKeyObserver.isPressed { return true } if selectionState.isSelecting { return true } if selectionState.isBroadcasting, selectionState.primaryTabID != tabID { return true } @@ -245,7 +245,7 @@ struct CanvasView: View { /// Uses .offset() (not .position()) to avoid parent size proposals /// reaching the NSView, keeping terminal grid stable during zoom. @ViewBuilder - private func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { + func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { // Pin to .topLeading and fill the viewport so each card's `.offset()` keeps // the same (0,0) origin it had under GeometryReader — otherwise the scrim's // full-size frame would resize the stack and shift the cards' base position. @@ -282,7 +282,7 @@ struct CanvasView: View { } @ViewBuilder - private func cardView( + func cardView( for tab: TerminalTabItem, in state: WorktreeTerminalState, activeStates: [WorktreeTerminalState] @@ -391,7 +391,7 @@ struct CanvasView: View { // MARK: - Canvas Gestures - private var canvasPanGesture: some Gesture { + var canvasPanGesture: some Gesture { DragGesture() .onChanged { value in canvasOffset = CGSize( @@ -404,7 +404,7 @@ struct CanvasView: View { } } - private var canvasZoomGesture: some Gesture { + var canvasZoomGesture: some Gesture { MagnifyGesture() .onChanged { value in let newScale = max(0.25, min(2.0, lastCanvasScale * value.magnification)) @@ -434,7 +434,7 @@ struct CanvasView: View { /// Batch-position all cards that don't have stored layouts yet. /// Uses a single, consistent column count to avoid overlap between /// cards positioned in different passes. - private func ensureLayouts(for cardKeys: [String]) { + func ensureLayouts(for cardKeys: [String]) { let unpositioned = cardKeys.filter { layoutStore.cardLayouts[$0] == nil } guard !unpositioned.isEmpty else { return } @@ -461,11 +461,11 @@ struct CanvasView: View { /// Balanced grid: columns ≈ sqrt(N). No viewport constraint — the canvas /// is infinite and fitToView handles zoom. - private func gridColumns(for count: Int) -> Int { + func gridColumns(for count: Int) -> Int { max(1, Int(ceil(sqrt(Double(count))))) } - private func gridPosition(index: Int, columns: Int, cardSize: CGSize) -> CGPoint { + func gridPosition(index: Int, columns: Int, cardSize: CGSize) -> CGPoint { let cardW = cardSize.width let cardH = cardSize.height + titleBarHeight let row = index / columns @@ -478,7 +478,7 @@ struct CanvasView: View { /// Compute effective center and size accounting for resize only (not drag). /// Drag is applied separately via `.offset()` to avoid layout passes. - private func resizedFrame( + func resizedFrame( for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> (center: CGPoint, size: CGSize) { @@ -504,24 +504,24 @@ struct CanvasView: View { return (CGPoint(x: centerX, y: centerY), CGSize(width: width, height: height)) } - private func screenPosition(for canvasCenter: CGPoint) -> CGPoint { + func screenPosition(for canvasCenter: CGPoint) -> CGPoint { CGPoint( x: canvasCenter.x * canvasScale + canvasOffset.width, y: canvasCenter.y * canvasScale + canvasOffset.height ) } - private func clampWidth(_ width: CGFloat) -> CGFloat { + func clampWidth(_ width: CGFloat) -> CGFloat { max(minCardWidth, min(maxCardWidth, width)) } - private func clampHeight(_ height: CGFloat) -> CGFloat { + func clampHeight(_ height: CGFloat) -> CGFloat { max(minCardHeight, min(maxCardHeight, height)) } // MARK: - Organize & Fit - private func collectCardKeys(from states: [WorktreeTerminalState]) -> [String] { + func collectCardKeys(from states: [WorktreeTerminalState]) -> [String] { states.flatMap { state in state.tabManager.tabs.compactMap { tab in state.surfaceView(for: tab.id) != nil ? tab.id.rawValue.uuidString : nil @@ -529,7 +529,7 @@ struct CanvasView: View { } } - private func collectVisibleTabIDs(from states: [WorktreeTerminalState]) -> [TerminalTabID] { + func collectVisibleTabIDs(from states: [WorktreeTerminalState]) -> [TerminalTabID] { states.flatMap { state in state.tabManager.tabs.compactMap { tab in state.surfaceView(for: tab.id) != nil ? tab.id : nil @@ -537,7 +537,7 @@ struct CanvasView: View { } } - private func collectFocusCandidates(from states: [WorktreeTerminalState]) -> [CanvasFocusCandidate] { + func collectFocusCandidates(from states: [WorktreeTerminalState]) -> [CanvasFocusCandidate] { states.flatMap { state in state.tabManager.tabs.compactMap { tab in state.surfaceView(for: tab.id) != nil @@ -548,7 +548,7 @@ struct CanvasView: View { } /// Reset all card positions to a clean grid layout (uniform sizes). - private func organizeCards() { + func organizeCards() { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) let columns = gridColumns(for: keys.count) let cardSize = adaptiveDefaultCardSize @@ -565,7 +565,7 @@ struct CanvasView: View { /// Arrange cards using MaxRects-BSSF bin packing. Preserves each card's /// current size and finds a compact layout whose aspect ratio matches /// the viewport. - private func arrangeCards() { + func arrangeCards() { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) guard !keys.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } @@ -584,7 +584,7 @@ struct CanvasView: View { /// Arrange cards (preserving sizes) and refit the viewport, animated. /// Shared by the toolbar button and the keyboard shortcut. - private func arrangeCardsWithFit() { + func arrangeCardsWithFit() { withAnimation(.easeInOut(duration: 0.2)) { cancelExpandForRelayout() arrangeCards() @@ -594,7 +594,7 @@ struct CanvasView: View { /// Organize cards into a uniform grid and refit the viewport, animated. /// Shared by the toolbar button and the keyboard shortcut. - private func organizeCardsWithFit() { + func organizeCardsWithFit() { withAnimation(.easeInOut(duration: 0.2)) { cancelExpandForRelayout() organizeCards() @@ -603,7 +603,7 @@ struct CanvasView: View { } /// Adjust scale and offset so all cards fit within the viewport. - private func fitToView(canvasSize: CGSize) { + func fitToView(canvasSize: CGSize) { guard canvasSize.width > 0, canvasSize.height > 0 else { return } let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) @@ -645,13 +645,13 @@ struct CanvasView: View { } /// Remove stored layouts for tabs that no longer exist. - private func cleanStaleLayouts() { + func cleanStaleLayouts() { let visibleKeys = Set(collectCardKeys(from: terminalManager.activeWorktreeStates)) guard !visibleKeys.isEmpty || hasSeenCanvasCards else { return } layoutStore.prune(to: visibleKeys) } - private var canvasHelpButton: some View { + var canvasHelpButton: some View { Button { showsCanvasHelp.toggle() } label: { @@ -667,7 +667,7 @@ struct CanvasView: View { .padding() } - private var canvasHelpContent: some View { + var canvasHelpContent: some View { let expandShortcut = AppShortcuts.display( for: AppShortcuts.CommandID.expandCanvasCard, in: resolvedKeybindings @@ -699,7 +699,7 @@ struct CanvasView: View { .frame(width: 320, alignment: .leading) } - private func canvasHelpRow(icon: String, title: String, detail: String) -> some View { + func canvasHelpRow(icon: String, title: String, detail: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: icon) .foregroundStyle(.secondary) @@ -715,7 +715,7 @@ struct CanvasView: View { } } - private var canvasToolbar: some View { + var canvasToolbar: some View { HStack(spacing: 8) { if selectionState.isBroadcasting { Label( @@ -776,7 +776,7 @@ struct CanvasView: View { .padding() } - private func zIndex(for tabID: TerminalTabID, cardKey: String) -> Double { + func zIndex(for tabID: TerminalTabID, cardKey: String) -> Double { let base = layoutStore.zIndex(for: cardKey) if selectionState.primaryTabID == tabID { return 10_000 + base @@ -789,7 +789,7 @@ struct CanvasView: View { // MARK: - Drag - private func commitDrag(for cardKey: String, translation: CGSize) { + func commitDrag(for cardKey: String, translation: CGSize) { if var layout = layoutStore.cardLayouts[cardKey] { layout.position.x += translation.width layout.position.y += translation.height @@ -799,7 +799,7 @@ struct CanvasView: View { // MARK: - Resize - private func commitResize(for tabID: TerminalTabID, cardKey: String, surfaces: [GhosttySurfaceView]) { + func commitResize(for tabID: TerminalTabID, cardKey: String, surfaces: [GhosttySurfaceView]) { guard activeResize[tabID] != nil else { return } if var layout = layoutStore.cardLayouts[cardKey] { let resized = resizedFrame(for: tabID, baseLayout: layout) @@ -818,7 +818,7 @@ struct CanvasView: View { } } - private func selectAllCards() { + func selectAllCards() { let activeStates = terminalManager.activeWorktreeStates let allTabIDs = collectVisibleTabIDs(from: activeStates) guard allTabIDs.count > 1 else { return } @@ -829,7 +829,7 @@ struct CanvasView: View { // MARK: - Selection and Focus - private func focusSingleCard( + func focusSingleCard( _ tabID: TerminalTabID, states: [WorktreeTerminalState] ) { @@ -841,7 +841,7 @@ struct CanvasView: View { // MARK: - Expand In Place - private var expandMetrics: CanvasExpandGeometry.Metrics { + var expandMetrics: CanvasExpandGeometry.Metrics { CanvasExpandGeometry.Metrics( padding: expandPadding, bottomReserve: bottomToolbarReserve, @@ -852,14 +852,14 @@ struct CanvasView: View { /// Screen-space center for a fully expanded card: horizontally centered and /// within the toolbar-adjusted viewport. Independent of canvas pan/zoom. - private var expandedScreenCenter: CGPoint { + var expandedScreenCenter: CGPoint { CGPoint(x: viewportSize.width / 2, y: (viewportSize.height - bottomToolbarReserve) / 2) } /// A card's normal (non-expanded) on-screen frame, following the canvas /// pan/zoom and any in-progress resize. This is the `progress = 0` endpoint of /// the expand magic-move. - private func nonExpandedGeometry( + func nonExpandedGeometry( for tabID: TerminalTabID, baseLayout: CanvasCardLayout ) -> CardScreenGeometry { @@ -874,7 +874,7 @@ struct CanvasView: View { /// The full-viewport expanded frame at scale 1 — the `progress = 1` endpoint. /// Independent of the canvas transform, so it covers the viewport regardless /// of the (frozen) background. - private func expandedGeometry() -> CardScreenGeometry { + func expandedGeometry() -> CardScreenGeometry { CardScreenGeometry( size: CanvasExpandGeometry.expandedSize(viewport: viewportSize, metrics: expandMetrics), center: expandedScreenCenter, @@ -884,7 +884,7 @@ struct CanvasView: View { /// Toggle expand/restore for a card — used by the title-bar button and the /// title-bar double-click. - private func toggleExpand(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { + func toggleExpand(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { if expandedTabID == tabID { collapseExpand() } else { @@ -894,715 +894,11 @@ struct CanvasView: View { /// Toggle expand/restore for the focused (primary) card. Used by the keyboard /// shortcut and the command palette, which target whichever card is focused. - private func toggleExpandFocusedCard() { + func toggleExpandFocusedCard() { if expandedTabID != nil { collapseExpand() } else if let tabID = selectionState.primaryTabID { expandCard(tabID, states: terminalManager.activeWorktreeStates) } } - - /// Run a reducer-driven canvas command (from the command palette) and clear - /// the one-shot request. - private func fulfillCommandRequest(_ request: CanvasCommandRequest?) { - guard let request else { return } - switch request.command { - case .toggleExpand: - toggleExpandFocusedCard() - case .arrange: - arrangeCardsWithFit() - case .organize: - organizeCardsWithFit() - case .selectAll: - selectAllCards() - } - onCommandConsumed(request.id) - } - - /// Expand a card in place: raise it to the top, then flip `expandedTabID` - /// inside one `withAnimation` so the card's size, center, and scale all - /// interpolate together from its in-canvas frame to the full viewport at - /// scale 1 — a magic-move from where the card actually sits. The canvas - /// transform is left untouched, so every other card stays exactly where it - /// was, behind a dimming scrim. - private func expandCard(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { - guard viewportSize.width > 0, viewportSize.height > 0, - layoutStore.cardLayouts[tabID.rawValue.uuidString] != nil - else { return } - focusSingleCard(tabID, states: states) - withAnimation(expandAnimation) { - expandedTabID = tabID - } - } - - /// Restore the expanded card back into the (unchanged) canvas. - private func collapseExpand() { - guard expandedTabID != nil else { return } - withAnimation(expandAnimation) { - expandedTabID = nil - } - } - - /// Drop expand state without animation — used right before a relayout - /// (Arrange/Organize) takes over the canvas. - private func cancelExpandForRelayout() { - expandedTabID = nil - } - - private func fulfillPendingFocusRequest( - _ request: CanvasFocusRequest?, - states: [WorktreeTerminalState] - ) { - guard let request else { return } - let tabID = CanvasFocusResolver.resolve( - request: request, - candidates: collectFocusCandidates(from: states), - currentPrimaryTabID: selectionState.primaryTabID - ) - guard let tabID else { return } - focusSingleCard(tabID, states: states) - focusViewport(on: tabID) - onFocusRequestConsumed(request.id) - } - - private func focusViewport(on tabID: TerminalTabID) { - guard viewportSize.width > 0, viewportSize.height > 0 else { return } - let cardKey = tabID.rawValue.uuidString - guard let layout = layoutStore.cardLayouts[cardKey] else { return } - - let horizontalPadding: CGFloat = 80 - let verticalPadding: CGFloat = 80 + bottomToolbarReserve - let availableWidth = max(1, viewportSize.width - horizontalPadding) - let availableHeight = max(1, viewportSize.height - verticalPadding) - let targetScale = max( - 0.25, - min( - 1.25, - min( - availableWidth / layout.size.width, - availableHeight / (layout.size.height + titleBarHeight) - ) - ) - ) - let targetOffset = CGSize( - width: viewportSize.width / 2 - layout.position.x * targetScale, - height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale - ) - canvasScale = targetScale - canvasOffset = targetOffset - lastCanvasScale = targetScale - lastCanvasOffset = targetOffset - focusViewportAnimationID &+= 1 - } - - private func handleSelectionShieldTap( - _ tabID: TerminalTabID, - surfaceState _: WorktreeTerminalState, - states: [WorktreeTerminalState] - ) { - let cmdHeld = NSEvent.modifierFlags.contains(.command) - mutateSelection(states: states) { state in - if cmdHeld { - state.toggleSelection(tabID) - } else if state.isBroadcasting, state.selectedTabIDs.contains(tabID) { - state.setPrimary(tabID) - } else { - state.focusSingle(tabID) - } - } - } - - private func clearSelection(states: [WorktreeTerminalState]) { - mutateSelection(states: states) { state in - state.clear() - } - } - - private func pruneSelection( - previousOrder: [TerminalTabID], - currentOrder: [TerminalTabID], - states: [WorktreeTerminalState] - ) { - let previousPrimaryTabID = selectionState.primaryTabID - selectionState.pruneAutoAdvancingPrimary(previousOrder: previousOrder, currentOrder: currentOrder) - syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) - syncBroadcastCallbacks(states: states) - } - - private func mutateSelection( - states: [WorktreeTerminalState], - mutation: (inout CanvasSelectionState) -> Void - ) { - let previousPrimaryTabID = selectionState.primaryTabID - mutation(&selectionState) - selectionState.prune(to: Set(collectVisibleTabIDs(from: states))) - syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) - syncBroadcastCallbacks(states: states) - } - - private func syncPrimaryFocus( - from previousTabID: TerminalTabID?, - to newTabID: TerminalTabID?, - states: [WorktreeTerminalState] - ) { - if let previousTabID, previousTabID != newTabID { - unfocusTab(previousTabID, states: states) - } - - guard let newTabID, - let ownerState = states.first(where: { $0.surfaceView(for: newTabID) != nil }), - let surfaceView = ownerState.surfaceView(for: newTabID) - else { - terminalManager.canvasFocusedWorktreeID = nil - onFocusedWorktreeChanged(nil) - return - } - - layoutStore.moveToFront(newTabID.rawValue.uuidString) - ownerState.tabManager.selectTab(newTabID) - terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID - onFocusedWorktreeChanged(ownerState.worktreeID) - surfaceView.focusDidChange(true) - surfaceView.requestFocus() - } - - private func unfocusTab(_ tabID: TerminalTabID, states: [WorktreeTerminalState]) { - guard let state = states.first(where: { $0.surfaceView(for: tabID) != nil }) else { return } - for surface in state.splitTree(for: tabID).leaves() { - surface.focusDidChange(false) - } - } - - private func syncBroadcastCallbacks(states: [WorktreeTerminalState]) { - clearBroadcastCallbacks(states: states) - - guard selectionState.isBroadcasting, - let primaryTabID = selectionState.primaryTabID, - let primaryState = terminalManager.stateContaining(tabId: primaryTabID) - else { - return - } - - let selectedTabIDs = selectionState.selectedTabIDs - let beginBroadcast = { selectionState.beginBroadcastInteractionIfNeeded() } - for primarySurface in primaryState.splitTree(for: primaryTabID).leaves() { - primarySurface.onCommittedText = { [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] text in - Task { @MainActor in - beginBroadcast() - terminalManager.broadcastCommittedText(text, from: primaryTabID, to: selectedTabIDs) - } - } - primarySurface.onMirroredKey = { - [terminalManager, selectedTabIDs, primaryTabID, beginBroadcast] mirroredKey in - Task { @MainActor in - beginBroadcast() - terminalManager.broadcastMirroredKey(mirroredKey, from: primaryTabID, to: selectedTabIDs) - } - } - } - } - - private func clearBroadcastCallbacks(states: [WorktreeTerminalState]) { - for state in states { - for tab in state.tabManager.tabs { - for surface in state.splitTree(for: tab.id).leaves() { - surface.onCommittedText = nil - surface.onMirroredKey = nil - } - } - } - } - - // MARK: - Occlusion - - private func activateCanvas() { - cleanStaleLayouts() - - let activeStates = terminalManager.activeWorktreeStates - - // Mark all states as canvas-managed so that tree updates (e.g. split - // creation) don't trigger applySurfaceActivity with stale normal-mode - // window visibility, which would occlude every surface. - for state in activeStates { - state.isCanvasManaged = true - } - - // Auto-focus the card that was active before entering canvas. - if let selectedID = terminalManager.selectedWorktreeID, - let state = activeStates.first(where: { $0.worktreeID == selectedID }), - let tabID = state.tabManager.selectedTabId - { - selectionState.focusSingle(tabID) - syncPrimaryFocus(from: nil, to: tabID, states: activeStates) - } else { - selectionState.clear() - syncBroadcastCallbacks(states: activeStates) - } - - for state in activeStates { - state.setAllSurfacesOccluded() - } - // Un-occlude all surfaces visible on canvas (including split panes) - for state in activeStates { - for tab in state.tabManager.tabs { - for surface in state.splitTree(for: tab.id).leaves() { - surface.setOcclusion(true) - } - } - } - } - - private func deactivateCanvas() { - expandedTabID = nil - let activeStates = terminalManager.activeWorktreeStates - for state in activeStates { - state.isCanvasManaged = false - } - clearBroadcastCallbacks(states: activeStates) - selectionState.clear() - // Don't occlude surfaces here. In SwiftUI's if/else view swap, - // onAppear fires before onDisappear, so occluding here would undo - // WorktreeTerminalTabsView.onAppear's syncFocus() and cause blank - // surfaces. Cleanup of non-selected worktrees is handled by - // setSelectedWorktreeID in the async exit flow. - } - - /// Looks up the user-pinned `RepositoryAppearance` for a given repo - /// root URL by deriving the canonical `Repository.ID` (the - /// path-policy-normalized path string) and querying the @Shared - /// dict. Returns `.empty` when no entry exists, which keeps cards - /// visually identical to before the appearance feature shipped. - private func appearance(for repositoryRootURL: URL) -> RepositoryAppearance { - let id = repositoryID(for: repositoryRootURL) - return repositoryAppearances[id] ?? .empty - } - - /// Resolves the user-defined display title for the repo at this root - /// URL, falling back to `Repository.name(for:)` (folder name) when no - /// custom title was set. Reads from the static dictionary populated - /// by the parent reducer — no per-call `@Shared` subscription on the - /// canvas hot path. - private func repositoryDisplayName(for repositoryRootURL: URL) -> String { - let id = repositoryID(for: repositoryRootURL) - return repositoryCustomTitles[id] ?? Repository.name(for: repositoryRootURL) - } - - /// Mirrors the same path normalization the `Repository.ID` is built - /// from, so dict lookups match what the reducer stores. - private func repositoryID(for repositoryRootURL: URL) -> Repository.ID { - PathPolicy.normalizePath( - repositoryRootURL.path(percentEncoded: false), resolvingSymlinks: true - ) ?? repositoryRootURL.path(percentEncoded: false) - } -} - -private struct ActiveResize { - let edge: CanvasCardView.CardResizeEdge - var translation: CGSize -} - -// MARK: - Scroll Container - -/// Wraps SwiftUI content in an NSView whose `scrollWheel` override catches -/// unhandled scroll-wheel events and translates them into canvas-offset changes. -/// Focused terminals consume their own scroll events (they don't call super), -/// so only events over empty space or unfocused cards reach this container. -private struct CanvasScrollContainer: NSViewRepresentable { - @Binding var offset: CGSize - @Binding var lastOffset: CGSize - @Binding var scale: CGFloat - @Binding var lastScale: CGFloat - var isInteractionEnabled: Bool - @ViewBuilder var content: Content - - func makeCoordinator() -> CanvasScrollCoordinator { - CanvasScrollCoordinator() - } - - func makeNSView(context: Context) -> CanvasScrollContainerView { - let container = CanvasScrollContainerView() - let hosting = NSHostingView(rootView: content) - hosting.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(hosting) - NSLayoutConstraint.activate([ - hosting.topAnchor.constraint(equalTo: container.topAnchor), - hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), - hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), - hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), - ]) - container.scrollCoordinator = context.coordinator - return container - } - - func updateNSView(_ nsView: CanvasScrollContainerView, context: Context) { - context.coordinator.offset = $offset - context.coordinator.lastOffset = $lastOffset - context.coordinator.scale = $scale - context.coordinator.lastScale = $lastScale - nsView.isInteractionEnabled = isInteractionEnabled - if let hosting = nsView.subviews.first as? NSHostingView { - hosting.rootView = content - } - } -} - -private class CanvasScrollCoordinator { - var offset: Binding = .constant(.zero) - var lastOffset: Binding = .constant(.zero) - var scale: Binding = .constant(1.0) - var lastScale: Binding = .constant(1.0) - - func handleScroll(deltaX: CGFloat, deltaY: CGFloat) { - let current = offset.wrappedValue - let newOffset = CGSize( - width: current.width + deltaX, - height: current.height + deltaY - ) - offset.wrappedValue = newOffset - lastOffset.wrappedValue = newOffset - } - - func handleZoom(deltaY: CGFloat, anchor: CGPoint, isPrecise: Bool) { - let result = CanvasZoomMath.zoom( - currentScale: scale.wrappedValue, - currentOffset: offset.wrappedValue, - deltaY: deltaY, - anchor: anchor, - isPrecise: isPrecise - ) - scale.wrappedValue = result.scale - lastScale.wrappedValue = result.scale - offset.wrappedValue = result.offset - lastOffset.wrappedValue = result.offset - } - - func setOffset(_ newOffset: CGSize) { - offset.wrappedValue = newOffset - lastOffset.wrappedValue = newOffset - } -} - -/// Pure zoom math, extracted for testability. -enum CanvasZoomMath { - static let minScale: CGFloat = 0.25 - static let maxScale: CGFloat = 2.0 - - struct Result: Equatable { - let scale: CGFloat - let offset: CGSize - } - - /// Compute the new scale and offset for a Cmd+wheel zoom step. - /// Keeps the canvas point under `anchor` fixed under the cursor: - /// `screen = canvas * scale + offset` ⇒ `canvas = (anchor - offset) / scale`. - static func zoom( - currentScale: CGFloat, - currentOffset: CGSize, - deltaY: CGFloat, - anchor: CGPoint, - isPrecise: Bool - ) -> Result { - let sensitivity: CGFloat = isPrecise ? 0.0025 : 0.005 - let factor = exp(deltaY * sensitivity) - let newScale = max(minScale, min(maxScale, currentScale * factor)) - guard newScale != currentScale else { - return Result(scale: currentScale, offset: currentOffset) - } - let canvasX = (anchor.x - currentOffset.width) / currentScale - let canvasY = (anchor.y - currentOffset.height) / currentScale - let newOffset = CGSize( - width: anchor.x - canvasX * newScale, - height: anchor.y - canvasY * newScale - ) - return Result(scale: newScale, offset: newOffset) - } -} - -private class CanvasScrollContainerView: NSView { - var scrollCoordinator: CanvasScrollCoordinator? - /// When false (a card is expanded), the container ignores scroll/zoom/ - /// middle-drag so the canvas can't pan or zoom behind the expanded card. - var isInteractionEnabled = true { - didSet { - guard !isInteractionEnabled, oldValue else { return } - if isMiddlePanning { endMiddlePan() } - isPanning = false - } - } - - /// Whether the container is actively redirecting scroll events to canvas - /// panning (as opposed to the brief bounce period after a gesture ends). - private var isPanning = false - private var scrollMonitor: Any? - /// Brief delay after finger-up to wait for momentum events. - private var momentumTimer: Timer? - /// Grace period after a pan gesture ends. A follow-up gesture that begins - /// during this window is still treated as canvas panning, even if the - /// cursor now sits on a focused terminal. - private var bounceTimer: Timer? - - // MARK: - Middle-click pan - private var middleButtonMonitor: Any? - private var isMiddlePanning = false - private var middlePanStartLocation: NSPoint = .zero - private var middlePanStartOffset: CGSize = .zero - private var hasPushedPanCursor = false - - override func scrollWheel(with event: NSEvent) { - guard isInteractionEnabled else { - super.scrollWheel(with: event) - return - } - if handleZoomEventIfNeeded(event) { return } - if event.phase == .began { - startPanning() - } - if event.phase == .began || event.phase == .changed || event.phase == .mayBegin || event.momentumPhase != [] { - scrollCoordinator?.handleScroll(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY) - return - } - super.scrollWheel(with: event) - } - - /// If the event is a Cmd+scroll, route it to canvas zoom and report `true`. - /// Used by both the direct `scrollWheel` override and the local monitor so - /// pressing Cmd mid-gesture switches behavior immediately. - fileprivate func handleZoomEventIfNeeded(_ event: NSEvent) -> Bool { - guard isInteractionEnabled else { return false } - guard event.modifierFlags.contains(.command), event.scrollingDeltaY != 0 else { return false } - let viewLocation = convert(event.locationInWindow, from: nil) - let anchor = CGPoint(x: viewLocation.x, y: bounds.height - viewLocation.y) - scrollCoordinator?.handleZoom( - deltaY: event.scrollingDeltaY, - anchor: anchor, - isPrecise: event.hasPreciseScrollingDeltas - ) - return true - } - - // MARK: - Pan lifecycle - - private func startPanning() { - isPanning = true - momentumTimer?.invalidate() - momentumTimer = nil - bounceTimer?.invalidate() - bounceTimer = nil - guard scrollMonitor == nil else { return } - installMonitor() - } - - private func installMonitor() { - scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - guard let self, event.window === self.window else { return event } - guard self.isInteractionEnabled else { return event } - - // Cmd toggled mid-gesture — switch to zoom for this event. - if self.handleZoomEventIfNeeded(event) { return nil } - - // --- New gesture ------------------------------------------------ - if event.phase == .began { - if self.isPanning { - // Already panning (edge case). Let normal dispatch decide. - return event - } - // Within the bounce window — treat as a continuation of panning. - self.startPanning() - self.scrollCoordinator?.handleScroll( - deltaX: event.scrollingDeltaX, - deltaY: event.scrollingDeltaY - ) - return nil - } - - // Only intercept while actively panning (not during bounce). - guard self.isPanning else { return event } - - // --- Ongoing gesture / momentum -------------------------------- - self.momentumTimer?.invalidate() - self.momentumTimer = nil - - if event.phase == .changed || event.momentumPhase != [] { - self.scrollCoordinator?.handleScroll( - deltaX: event.scrollingDeltaX, - deltaY: event.scrollingDeltaY - ) - } - - // Finger lifted — momentum may follow shortly. - if event.phase == .ended || event.phase == .cancelled { - self.momentumTimer = Timer.scheduledTimer( - withTimeInterval: 0.1, repeats: false - ) { [weak self] _ in - MainActor.assumeIsolated { self?.enterBounce() } - } - } - - // Momentum finished. - if event.momentumPhase == .ended || event.momentumPhase == .cancelled { - self.enterBounce() - } - - return nil - } - } - - /// Transition from active panning to the bounce (grace) period. - /// The monitor stays alive so a quick follow-up gesture resumes panning. - private func enterBounce() { - isPanning = false - momentumTimer?.invalidate() - momentumTimer = nil - bounceTimer?.invalidate() - bounceTimer = Timer.scheduledTimer( - withTimeInterval: 0.3, repeats: false - ) { [weak self] _ in - MainActor.assumeIsolated { self?.tearDownMonitor() } - } - } - - private func tearDownMonitor() { - isPanning = false - momentumTimer?.invalidate() - momentumTimer = nil - bounceTimer?.invalidate() - bounceTimer = nil - if let monitor = scrollMonitor { - scrollMonitor = nil - DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } - } - } - - // MARK: - Middle-click pan - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if window != nil { - installMiddleButtonMonitor() - } else { - tearDownMiddleButtonMonitor() - } - } - - private func installMiddleButtonMonitor() { - guard middleButtonMonitor == nil else { return } - let mask: NSEvent.EventTypeMask = [.otherMouseDown, .otherMouseDragged, .otherMouseUp] - middleButtonMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in - guard let self, event.window === self.window, event.buttonNumber == 2 else { return event } - guard self.isInteractionEnabled else { return event } - - switch event.type { - case .otherMouseDown: - let location = self.convert(event.locationInWindow, from: nil) - guard self.bounds.contains(location) else { return event } - self.beginMiddlePan(at: event.locationInWindow) - return nil - case .otherMouseDragged: - guard self.isMiddlePanning else { return event } - self.updateMiddlePan(to: event.locationInWindow) - return nil - case .otherMouseUp: - guard self.isMiddlePanning else { return event } - self.endMiddlePan() - return nil - default: - return event - } - } - } - - private func beginMiddlePan(at windowLocation: NSPoint) { - isMiddlePanning = true - middlePanStartLocation = windowLocation - middlePanStartOffset = scrollCoordinator?.offset.wrappedValue ?? .zero - if !hasPushedPanCursor { - NSCursor.closedHand.push() - hasPushedPanCursor = true - } - } - - private func updateMiddlePan(to windowLocation: NSPoint) { - let deltaX = windowLocation.x - middlePanStartLocation.x - // Window Y grows upward; canvas offset Y grows downward (SwiftUI top-left). - let deltaY = middlePanStartLocation.y - windowLocation.y - let newOffset = CGSize( - width: middlePanStartOffset.width + deltaX, - height: middlePanStartOffset.height + deltaY - ) - scrollCoordinator?.setOffset(newOffset) - } - - private func endMiddlePan() { - isMiddlePanning = false - if hasPushedPanCursor { - NSCursor.pop() - hasPushedPanCursor = false - } - } - - private func tearDownMiddleButtonMonitor() { - if isMiddlePanning { endMiddlePan() } - if let monitor = middleButtonMonitor { - middleButtonMonitor = nil - DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } - } - } - - override func removeFromSuperview() { - tearDownMonitor() - tearDownMiddleButtonMonitor() - super.removeFromSuperview() - } -} - -/// Screen-space transform for a card on canvas. -private struct CardScreenGeometry { - var size: CGSize - var center: CGPoint - var scale: CGFloat -} - -/// An `Animatable` container that interpolates a card between its in-canvas -/// frame (`progress` 0) and the full-viewport expanded frame (`progress` 1). -/// -/// Because `animatableData` is `progress`, SwiftUI re-evaluates `body` on every -/// frame of the transition, so the card's size, center, and scale advance -/// together and the terminal re-flows in lock-step with the offset/scale — a -/// true magic-move from where the card sits. This sidesteps SwiftUI's implicit -/// per-modifier interpolation, which only reached the Animatable terminal and -/// left offset/scale to snap (the "grows from the center" / "no animation" bugs). -private struct AnimatedExpandableCard: View, Animatable { - var progress: CGFloat - var collapsed: CardScreenGeometry - var expanded: CardScreenGeometry - let titleBarHeight: CGFloat - @ViewBuilder let content: (CGSize) -> Content - - var animatableData: CGFloat { - get { progress } - set { progress = newValue } - } - - var body: some View { - let fraction = max(0, min(1, progress)) - let size = CGSize( - width: lerp(collapsed.size.width, expanded.size.width, fraction), - height: lerp(collapsed.size.height, expanded.size.height, fraction) - ) - let center = CGPoint( - x: lerp(collapsed.center.x, expanded.center.x, fraction), - y: lerp(collapsed.center.y, expanded.center.y, fraction) - ) - let scale = lerp(collapsed.scale, expanded.scale, fraction) - content(size) - .scaleEffect(scale, anchor: .center) - .offset( - x: center.x - size.width / 2, - y: center.y - (size.height + titleBarHeight) / 2 - ) - } - - private func lerp(_ start: CGFloat, _ end: CGFloat, _ fraction: CGFloat) -> CGFloat { - start + (end - start) * fraction - } } -- 2.51.2 From eed6a021efe79825759a4493a88dfd7d3decf09d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:37:33 +0900 Subject: [PATCH 331/528] Split Ghostty runtime helpers --- .../Ghostty/GhosttyRuntime+Callbacks.swift | 298 ++++++ .../GhosttyRuntime+ThemeFallback.swift | 309 +++++++ .../Ghostty/GhosttyRuntime.swift | 872 +----------------- .../Ghostty/GhosttyRuntimeSupport.swift | 219 +++++ 4 files changed, 855 insertions(+), 843 deletions(-) create mode 100644 supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttyRuntimeSupport.swift diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift new file mode 100644 index 00000000..dacab910 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift @@ -0,0 +1,298 @@ +import AppKit +import GhosttyKit + +extension GhosttyRuntime { + static func runtime(from userdata: UnsafeMutableRawPointer?) -> GhosttyRuntime? { + guard let userdata else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + static func runtime(fromApp app: ghostty_app_t) -> GhosttyRuntime? { + guard let userdata = ghostty_app_userdata(app) else { return nil } + return runtime(from: userdata) + } + + static func surfaceBridge(fromUserdata userdata: UnsafeMutableRawPointer?) + -> GhosttySurfaceBridge? + { + guard let userdata else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + static func surfaceBridge(fromSurface surface: ghostty_surface_t?) + -> GhosttySurfaceBridge? + { + guard let surface, let userdata = ghostty_surface_userdata(surface) else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + nonisolated static func wakeupCallback(_ userdata: UnsafeMutableRawPointer?) { + let userdataBits = userdata.map { UInt(bitPattern: $0) } + if Thread.isMainThread { + MainActor.assumeIsolated { + wakeup(userdataBits: userdataBits) + } + return + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + wakeup(userdataBits: userdataBits) + } + } + } + + nonisolated static func actionCallback( + _ app: ghostty_app_t?, + _ target: ghostty_target_s, + _ action: ghostty_action_s + ) -> Bool { + guard let app else { return false } + let appBits = UInt(bitPattern: app) + if Thread.isMainThread { + return MainActor.assumeIsolated { + handleAction(appBits: appBits, target: target, action: action) + } + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + _ = handleAction(appBits: appBits, target: target, action: action) + } + } + return false + } + + nonisolated static func readClipboardCallback( + _ userdata: UnsafeMutableRawPointer?, + _ location: ghostty_clipboard_e, + _ state: UnsafeMutableRawPointer? + ) -> Bool { + let userdataBits = userdata.map { UInt(bitPattern: $0) } + let stateBits = state.map { UInt(bitPattern: $0) } + if Thread.isMainThread { + return MainActor.assumeIsolated { + readClipboard(userdataBits: userdataBits, location: location, stateBits: stateBits) + } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { + readClipboard(userdataBits: userdataBits, location: location, stateBits: stateBits) + } + } + } + + nonisolated static func confirmReadClipboardCallback( + _ userdata: UnsafeMutableRawPointer?, + _ string: UnsafePointer?, + _ state: UnsafeMutableRawPointer?, + _ request: ghostty_clipboard_request_e + ) { + guard let string else { return } + let value = String(cString: string) + let userdataBits = userdata.map { UInt(bitPattern: $0) } + let stateBits = state.map { UInt(bitPattern: $0) } + if Thread.isMainThread { + MainActor.assumeIsolated { + confirmReadClipboard( + userdataBits: userdataBits, + value: value, + stateBits: stateBits, + request: request + ) + } + return + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + confirmReadClipboard( + userdataBits: userdataBits, + value: value, + stateBits: stateBits, + request: request + ) + } + } + } + + nonisolated static func writeClipboardCallback( + _ userdata: UnsafeMutableRawPointer?, + _ location: ghostty_clipboard_e, + _ content: UnsafePointer?, + _ len: Int, + _ confirm: Bool + ) { + _ = userdata + guard let content, len > 0 else { return } + let items: [(mime: String, data: String)] = (0.. Bool { + guard let app = ghostty_app_t(bitPattern: appBits) else { return false } + if let runtime = runtime(fromApp: app) { + if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE, target.tag == GHOSTTY_TARGET_APP { + let config = action.action.config_change.config + guard let clone = ghostty_config_clone(config) else { return false } + runtime.setConfig(clone) + if let scheme = runtime.currentColorScheme { + runtime.reconcileThemeFallback(for: scheme) + } + runtime.onConfigChange?() + NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: runtime) + } + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { + let soft = action.action.reload_config.soft + runtime.reloadConfig(soft: soft, target: target) + } + } + if action.tag == GHOSTTY_ACTION_OPEN_CONFIG, target.tag == GHOSTTY_TARGET_APP { + openGhosttyConfig() + return true + } + if action.tag == GHOSTTY_ACTION_QUIT { + if let runtime = runtime(fromApp: app) { + runtime.onQuit?() + } + return true + } + if action.tag == GHOSTTY_ACTION_CLOSE_WINDOW { + closeWindow(target: target) + return true + } + guard target.tag == GHOSTTY_TARGET_SURFACE else { return false } + guard let surface = target.target.surface else { return false } + guard let bridge = surfaceBridge(fromSurface: surface) else { return false } + return bridge.handleAction(target: target, action: action) + } + + static func closeWindow(target: ghostty_target_s) { + switch target.tag { + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let bridge = surfaceBridge(fromSurface: surface) else { return } + bridge.surfaceView?.window?.close() + default: + break + } + } + + static func openGhosttyConfig() { + let configStr = ghostty_config_open_path() + defer { ghostty_string_free(configStr) } + guard let ptr = configStr.ptr else { return } + let path = String(data: Data(bytes: ptr, count: Int(configStr.len)), encoding: .utf8) ?? "" + guard !path.isEmpty else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-t", path] + try? process.run() + } + + static func readClipboard( + userdataBits: UInt?, + location: ghostty_clipboard_e, + stateBits: UInt? + ) -> Bool { + let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } + let state = stateBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } + guard let bridge = surfaceBridge(fromUserdata: userdata), let surface = bridge.surface else { + return false + } + guard let value = NSPasteboard.ghostty(location)?.getOpinionatedStringContents() else { + return false + } + value.withCString { ptr in + ghostty_surface_complete_clipboard_request(surface, ptr, state, false) + } + return true + } + + static func confirmReadClipboard( + userdataBits: UInt?, + value: String, + stateBits: UInt?, + request: ghostty_clipboard_request_e + ) { + _ = request + let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } + let state = stateBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } + guard let bridge = surfaceBridge(fromUserdata: userdata), let surface = bridge.surface else { + return + } + value.withCString { ptr in + ghostty_surface_complete_clipboard_request(surface, ptr, state, true) + } + } + + static func writeClipboard( + location: ghostty_clipboard_e, + items: [(mime: String, data: String)], + confirm: Bool + ) { + _ = confirm + + guard let pasteboard = NSPasteboard.ghostty(location) else { return } + let types = items.compactMap { NSPasteboard.PasteboardType(mimeType: $0.mime) } + pasteboard.declareTypes(types, owner: nil) + for item in items { + guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue } + pasteboard.setString(item.data, forType: type) + } + } + + static func closeSurface(userdataBits: UInt?, processAlive: Bool) { + let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } + guard let bridge = surfaceBridge(fromUserdata: userdata) else { return } + bridge.closeSurface(processAlive: processAlive) + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift new file mode 100644 index 00000000..e3f5d499 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime+ThemeFallback.swift @@ -0,0 +1,309 @@ +import AppKit +import GhosttyKit +import SwiftUI + +extension GhosttyRuntime { + func reconcileThemeFallback(for scheme: ColorScheme) { + // Subprocess discovery of the user's Ghostty CLI can block the main + // thread (and in XCTest host bringup it has timed out the test runner + // preparation phase). Short-circuit under test, and dispatch the + // lookup off-main in all other cases. + guard !Self.isRunningInTestEnvironment() else { + setThemeFallbackOverride("") + return + } + Task { [weak self] in + let snapshot = await Self.probeUserConfigSnapshot() + let pair: GhosttyThemePair? = + snapshot?.themeMode.allowsMismatchFallback == true ? await Self.probeFallbackThemePair() : nil + self?.applyResolvedThemeFallback(for: scheme, snapshot: snapshot, pair: pair) + } + } + + @MainActor + func applyResolvedThemeFallback( + for scheme: ColorScheme, + snapshot: GhosttyUserConfigSnapshot?, + pair: GhosttyThemePair? + ) { + guard currentColorScheme == scheme else { return } + // `.none` (no user theme) is treated like a single dark theme here: Ghostty's + // no-theme default is the fixed dark `#282C34` reported by `+show-config`, so + // its `backgroundTone` resolves to `.dark` and adapts to a light app the same + // way an explicit single dark theme does. `.dual` is the user's explicit + // per-mode choice and is always respected. + guard let snapshot, snapshot.themeMode.allowsMismatchFallback else { + setThemeFallbackOverride("") + return + } + + let targetTone: GhosttyTerminalTone = scheme == .dark ? .dark : .light + guard snapshot.backgroundTone == .light || snapshot.backgroundTone == .dark else { + setThemeFallbackOverride("") + return + } + + if snapshot.backgroundTone == targetTone { + setThemeFallbackOverride("") + return + } + + guard let pair else { + setThemeFallbackOverride("") + return + } + + setThemeFallbackOverride("theme = light:\(pair.light),dark:\(pair.dark)") + } + + nonisolated static func isRunningInTestEnvironment() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["XCTestConfigurationFilePath"] != nil + || env["XCTestBundlePath"] != nil + || env["XCTestSessionIdentifier"] != nil + } + + func setThemeFallbackOverride(_ contents: String) { + guard contents != themeFallbackOverrideContents else { return } + themeFallbackOverrideContents = contents + applyRuntimeOverridesIfNeeded() + } + + func applyRuntimeOverridesIfNeeded() { + guard let app else { return } + + let nextSignature = [appKeybindOverrideContents, themeFallbackOverrideContents].joined(separator: "\n---\n") + guard nextSignature != runtimeOverrideSignature else { return } + + var overrideURLs: [URL] = [] + if !appKeybindOverrideContents.isEmpty { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("prowl-ghostty-keybind-overrides.conf") + do { + try appKeybindOverrideContents.write(to: url, atomically: true, encoding: .utf8) + overrideURLs.append(url) + } catch { + ghosttyLogger.warning("Failed to write ghostty keybind override file: \(error.localizedDescription)") + return + } + } + + if !themeFallbackOverrideContents.isEmpty { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("prowl-ghostty-theme-overrides.conf") + do { + try themeFallbackOverrideContents.write(to: url, atomically: true, encoding: .utf8) + overrideURLs.append(url) + } catch { + ghosttyLogger.warning("Failed to write ghostty theme override file: \(error.localizedDescription)") + return + } + } + + guard let updated = ghostty_config_new() else { return } + ghostty_config_load_default_files(updated) + ghostty_config_load_recursive_files(updated) + ghostty_config_load_cli_args(updated) + for url in overrideURLs { + url.path.withCString { path in + ghostty_config_load_file(updated, path) + } + } + ghostty_config_finalize(updated) + ghostty_app_update_config(app, updated) + if let clone = ghostty_config_clone(updated) { + setConfig(clone) + } + ghostty_config_free(updated) + runtimeOverrideSignature = nextSignature + onConfigChange?() + NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: self) + } + + nonisolated static func probeUserConfigSnapshot() async -> GhosttyUserConfigSnapshot? { + // `await` ensures this runs on a cooperative executor rather than on the + // caller's MainActor, so the synchronous subprocess calls below never block + // the main thread. + await Task.yield() + return userConfigSnapshotFromCLI() + } + + nonisolated static func probeFallbackThemePair() async -> GhosttyThemePair? { + await Task.yield() + return resolveFallbackThemePair() + } + + nonisolated static func userConfigSnapshotFromCLI() -> GhosttyUserConfigSnapshot? { + guard let output = runGhosttyCommand(arguments: ["+show-config"]) else { return nil } + let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: output) + + // `ghostty +show-config` collapses an explicit same-name light/dark pair + // (`theme = light:X,dark:X`) back into a single `theme = X`. Trusting that + // alone would make us apply the single-theme fallback over the user's + // explicit light/dark choice, so re-derive the theme mode from the raw + // config text when we can read it. The background tone still comes from the + // resolved CLI output. + guard let rawMode = rawUserThemeMode() else { return snapshot } + return GhosttyUserConfigSnapshot(themeMode: rawMode, backgroundTone: snapshot.backgroundTone) + } + + nonisolated static func rawUserThemeMode() -> GhosttyThemeMode? { + guard let url = preferredGhosttyConfigURL(), + let contents = try? String(contentsOf: url, encoding: .utf8), + let spec = GhosttyUserConfigSnapshot.rawThemeSpec(fromConfig: contents) + else { return nil } + return GhosttyUserConfigSnapshot.parseThemeMode(from: spec) + } + + /// Mirrors Ghostty's macOS default-config selection: prefer the Application + /// Support file when present, otherwise fall back to the XDG config. Within + /// each location the modern `config.ghostty` wins over the legacy `config`. + /// `theme` set through `config-file` includes isn't resolved here; those rare + /// setups simply keep the previous `+show-config` behavior. + nonisolated static func preferredGhosttyConfigURL() -> URL? { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + + let appSupport = home.appending( + path: "Library/Application Support/com.mitchellh.ghostty", + directoryHint: .isDirectory + ) + let xdgRoot: URL + if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty { + xdgRoot = URL(fileURLWithPath: xdg, isDirectory: true) + } else { + xdgRoot = home.appending(path: ".config", directoryHint: .isDirectory) + } + let xdg = xdgRoot.appending(path: "ghostty", directoryHint: .isDirectory) + + let candidates = [ + appSupport.appending(path: "config.ghostty"), + appSupport.appending(path: "config"), + xdg.appending(path: "config.ghostty"), + xdg.appending(path: "config"), + ] + return candidates.first { fileManager.fileExists(atPath: $0.path) } + } + + nonisolated static func runGhosttyCommand(arguments: [String]) -> String? { + guard let executablePath = resolveGhosttyExecutablePath() else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + } catch { + let command = arguments.joined(separator: " ") + ghosttyLogger.warning( + "Failed to run ghostty command \(command): \(error.localizedDescription)" + ) + return nil + } + guard process.terminationStatus == 0 else { return nil } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + guard !data.isEmpty else { return "" } + return String(data: data, encoding: .utf8) + } + + nonisolated static func resolveGhosttyExecutablePath() -> String? { + ghosttyCLICacheLock.lock() + if let cachedGhosttyExecutablePath, + FileManager.default.isExecutableFile(atPath: cachedGhosttyExecutablePath) + { + defer { ghosttyCLICacheLock.unlock() } + return cachedGhosttyExecutablePath + } + if ghosttyExecutableResolutionAttempted { + ghosttyCLICacheLock.unlock() + return nil + } + ghosttyCLICacheLock.unlock() + + var resolvedPath: String? + for candidate in ghosttyExecutableCandidates where FileManager.default.isExecutableFile(atPath: candidate) { + resolvedPath = candidate + break + } + + if resolvedPath == nil { + let which = Process() + which.executableURL = URL(fileURLWithPath: "/usr/bin/which") + which.arguments = ["ghostty"] + let outputPipe = Pipe() + which.standardOutput = outputPipe + which.standardError = Pipe() + do { + try which.run() + which.waitUntilExit() + } catch { + ghosttyCLICacheLock.lock() + ghosttyExecutableResolutionAttempted = true + ghosttyCLICacheLock.unlock() + return nil + } + + if which.terminationStatus == 0 { + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + if let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty, + FileManager.default.isExecutableFile(atPath: path) + { + resolvedPath = path + } + } + } + + ghosttyCLICacheLock.lock() + defer { ghosttyCLICacheLock.unlock() } + ghosttyExecutableResolutionAttempted = true + cachedGhosttyExecutablePath = resolvedPath + return resolvedPath + } + + nonisolated static func resolveFallbackThemePair() -> GhosttyThemePair? { + ghosttyCLICacheLock.lock() + if let cachedFallbackThemePair { + defer { ghosttyCLICacheLock.unlock() } + return cachedFallbackThemePair + } + ghosttyCLICacheLock.unlock() + + let knownLightCandidates = ["Ghostty Default Style Light", "Catppuccin Latte"] + let knownDarkCandidates = ["Ghostty Default Style Dark", "Catppuccin Frappe"] + + var resolvedPair: GhosttyThemePair? + if let output = runGhosttyCommand(arguments: ["+list-themes"]) { + let availableThemes = Set( + output + .split(whereSeparator: \.isNewline) + .map { line -> String in + let raw = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + if let index = raw.lastIndex(of: "("), raw.hasSuffix(")") { + return String(raw[.. Void)? var onQuit: (() -> Void)? @@ -93,7 +93,7 @@ final class GhosttyRuntime { currentColorScheme } - private func registerNotificationObservers() { + func registerNotificationObservers() { let center = NotificationCenter.default observers.append( center.addObserver( @@ -195,7 +195,7 @@ final class GhosttyRuntime { } } - private func logLifecycleEvent(_ event: String) { + func logLifecycleEvent(_ event: String) { let validSurfaceCount = surfaceRefs.reduce(into: 0) { count, ref in if ref.isValid { count += 1 @@ -258,7 +258,7 @@ final class GhosttyRuntime { applyRuntimeOverridesIfNeeded() } - private func applyConfig( + func applyConfig( _ config: ghostty_config_t, target: ghostty_target_s, app: ghostty_app_t @@ -274,307 +274,13 @@ final class GhosttyRuntime { } } - private func applyColorSchemeToSurfaces(_ scheme: ghostty_color_scheme_e) { + func applyColorSchemeToSurfaces(_ scheme: ghostty_color_scheme_e) { for ref in surfaceRefs where ref.isValid { ghostty_surface_set_color_scheme(ref.surface, scheme) } } - private static func runtime(from userdata: UnsafeMutableRawPointer?) -> GhosttyRuntime? { - guard let userdata else { return nil } - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } - - private static func runtime(fromApp app: ghostty_app_t) -> GhosttyRuntime? { - guard let userdata = ghostty_app_userdata(app) else { return nil } - return runtime(from: userdata) - } - - private static func surfaceBridge(fromUserdata userdata: UnsafeMutableRawPointer?) - -> GhosttySurfaceBridge? - { - guard let userdata else { return nil } - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } - - private static func surfaceBridge(fromSurface surface: ghostty_surface_t?) - -> GhosttySurfaceBridge? - { - guard let surface, let userdata = ghostty_surface_userdata(surface) else { return nil } - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } - - private nonisolated static func wakeupCallback(_ userdata: UnsafeMutableRawPointer?) { - let userdataBits = userdata.map { UInt(bitPattern: $0) } - if Thread.isMainThread { - MainActor.assumeIsolated { - wakeup(userdataBits: userdataBits) - } - return - } - DispatchQueue.main.async { - MainActor.assumeIsolated { - wakeup(userdataBits: userdataBits) - } - } - } - - private nonisolated static func actionCallback( - _ app: ghostty_app_t?, - _ target: ghostty_target_s, - _ action: ghostty_action_s - ) -> Bool { - guard let app else { return false } - let appBits = UInt(bitPattern: app) - if Thread.isMainThread { - return MainActor.assumeIsolated { - handleAction(appBits: appBits, target: target, action: action) - } - } - DispatchQueue.main.async { - MainActor.assumeIsolated { - _ = handleAction(appBits: appBits, target: target, action: action) - } - } - return false - } - - private nonisolated static func readClipboardCallback( - _ userdata: UnsafeMutableRawPointer?, - _ location: ghostty_clipboard_e, - _ state: UnsafeMutableRawPointer? - ) -> Bool { - let userdataBits = userdata.map { UInt(bitPattern: $0) } - let stateBits = state.map { UInt(bitPattern: $0) } - if Thread.isMainThread { - return MainActor.assumeIsolated { - readClipboard(userdataBits: userdataBits, location: location, stateBits: stateBits) - } - } - return DispatchQueue.main.sync { - MainActor.assumeIsolated { - readClipboard(userdataBits: userdataBits, location: location, stateBits: stateBits) - } - } - } - - private nonisolated static func confirmReadClipboardCallback( - _ userdata: UnsafeMutableRawPointer?, - _ string: UnsafePointer?, - _ state: UnsafeMutableRawPointer?, - _ request: ghostty_clipboard_request_e - ) { - guard let string else { return } - let value = String(cString: string) - let userdataBits = userdata.map { UInt(bitPattern: $0) } - let stateBits = state.map { UInt(bitPattern: $0) } - if Thread.isMainThread { - MainActor.assumeIsolated { - confirmReadClipboard( - userdataBits: userdataBits, - value: value, - stateBits: stateBits, - request: request - ) - } - return - } - DispatchQueue.main.async { - MainActor.assumeIsolated { - confirmReadClipboard( - userdataBits: userdataBits, - value: value, - stateBits: stateBits, - request: request - ) - } - } - } - - private nonisolated static func writeClipboardCallback( - _ userdata: UnsafeMutableRawPointer?, - _ location: ghostty_clipboard_e, - _ content: UnsafePointer?, - _ len: Int, - _ confirm: Bool - ) { - _ = userdata - guard let content, len > 0 else { return } - let items: [(mime: String, data: String)] = (0.. Bool { - guard let app = ghostty_app_t(bitPattern: appBits) else { return false } - if let runtime = runtime(fromApp: app) { - if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE, target.tag == GHOSTTY_TARGET_APP { - let config = action.action.config_change.config - guard let clone = ghostty_config_clone(config) else { return false } - runtime.setConfig(clone) - if let scheme = runtime.currentColorScheme { - runtime.reconcileThemeFallback(for: scheme) - } - runtime.onConfigChange?() - NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: runtime) - } - if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { - let soft = action.action.reload_config.soft - runtime.reloadConfig(soft: soft, target: target) - } - } - if action.tag == GHOSTTY_ACTION_OPEN_CONFIG, target.tag == GHOSTTY_TARGET_APP { - openGhosttyConfig() - return true - } - if action.tag == GHOSTTY_ACTION_QUIT { - if let runtime = runtime(fromApp: app) { - runtime.onQuit?() - } - return true - } - if action.tag == GHOSTTY_ACTION_CLOSE_WINDOW { - closeWindow(target: target) - return true - } - guard target.tag == GHOSTTY_TARGET_SURFACE else { return false } - guard let surface = target.target.surface else { return false } - guard let bridge = surfaceBridge(fromSurface: surface) else { return false } - return bridge.handleAction(target: target, action: action) - } - - private static func closeWindow(target: ghostty_target_s) { - switch target.tag { - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let bridge = surfaceBridge(fromSurface: surface) else { return } - bridge.surfaceView?.window?.close() - default: - break - } - } - - static func openGhosttyConfig() { - let configStr = ghostty_config_open_path() - defer { ghostty_string_free(configStr) } - guard let ptr = configStr.ptr else { return } - let path = String(data: Data(bytes: ptr, count: Int(configStr.len)), encoding: .utf8) ?? "" - guard !path.isEmpty else { return } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = ["-t", path] - try? process.run() - } - - private static func readClipboard( - userdataBits: UInt?, - location: ghostty_clipboard_e, - stateBits: UInt? - ) -> Bool { - let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } - let state = stateBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } - guard let bridge = surfaceBridge(fromUserdata: userdata), let surface = bridge.surface else { - return false - } - guard let value = NSPasteboard.ghostty(location)?.getOpinionatedStringContents() else { - return false - } - value.withCString { ptr in - ghostty_surface_complete_clipboard_request(surface, ptr, state, false) - } - return true - } - - private static func confirmReadClipboard( - userdataBits: UInt?, - value: String, - stateBits: UInt?, - request: ghostty_clipboard_request_e - ) { - _ = request - let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } - let state = stateBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } - guard let bridge = surfaceBridge(fromUserdata: userdata), let surface = bridge.surface else { - return - } - value.withCString { ptr in - ghostty_surface_complete_clipboard_request(surface, ptr, state, true) - } - } - - private static func writeClipboard( - location: ghostty_clipboard_e, - items: [(mime: String, data: String)], - confirm: Bool - ) { - _ = confirm - - guard let pasteboard = NSPasteboard.ghostty(location) else { return } - let types = items.compactMap { NSPasteboard.PasteboardType(mimeType: $0.mime) } - pasteboard.declareTypes(types, owner: nil) - for item in items { - guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue } - pasteboard.setString(item.data, forType: type) - } - } - - private static func closeSurface(userdataBits: UInt?, processAlive: Bool) { - let userdata = userdataBits.flatMap { UnsafeMutableRawPointer(bitPattern: $0) } - guard let bridge = surfaceBridge(fromUserdata: userdata) else { return } - bridge.closeSurface(processAlive: processAlive) - } - - private func setConfig(_ config: ghostty_config_t) { + func setConfig(_ config: ghostty_config_t) { if let existing = self.config { ghostty_config_free(existing) } @@ -597,311 +303,7 @@ final class GhosttyRuntime { applyRuntimeOverridesIfNeeded() } - private func reconcileThemeFallback(for scheme: ColorScheme) { - // Subprocess discovery of the user's Ghostty CLI can block the main - // thread (and in XCTest host bringup it has timed out the test runner - // preparation phase). Short-circuit under test, and dispatch the - // lookup off-main in all other cases. - guard !Self.isRunningInTestEnvironment() else { - setThemeFallbackOverride("") - return - } - Task { [weak self] in - let snapshot = await Self.probeUserConfigSnapshot() - let pair: GhosttyThemePair? = - snapshot?.themeMode.allowsMismatchFallback == true ? await Self.probeFallbackThemePair() : nil - self?.applyResolvedThemeFallback(for: scheme, snapshot: snapshot, pair: pair) - } - } - - @MainActor - private func applyResolvedThemeFallback( - for scheme: ColorScheme, - snapshot: GhosttyUserConfigSnapshot?, - pair: GhosttyThemePair? - ) { - guard currentColorScheme == scheme else { return } - // `.none` (no user theme) is treated like a single dark theme here: Ghostty's - // no-theme default is the fixed dark `#282C34` reported by `+show-config`, so - // its `backgroundTone` resolves to `.dark` and adapts to a light app the same - // way an explicit single dark theme does. `.dual` is the user's explicit - // per-mode choice and is always respected. - guard let snapshot, snapshot.themeMode.allowsMismatchFallback else { - setThemeFallbackOverride("") - return - } - - let targetTone: GhosttyTerminalTone = scheme == .dark ? .dark : .light - guard snapshot.backgroundTone == .light || snapshot.backgroundTone == .dark else { - setThemeFallbackOverride("") - return - } - - if snapshot.backgroundTone == targetTone { - setThemeFallbackOverride("") - return - } - - guard let pair else { - setThemeFallbackOverride("") - return - } - - setThemeFallbackOverride("theme = light:\(pair.light),dark:\(pair.dark)") - } - - nonisolated private static func isRunningInTestEnvironment() -> Bool { - let env = ProcessInfo.processInfo.environment - return env["XCTestConfigurationFilePath"] != nil - || env["XCTestBundlePath"] != nil - || env["XCTestSessionIdentifier"] != nil - } - - private func setThemeFallbackOverride(_ contents: String) { - guard contents != themeFallbackOverrideContents else { return } - themeFallbackOverrideContents = contents - applyRuntimeOverridesIfNeeded() - } - - private func applyRuntimeOverridesIfNeeded() { - guard let app else { return } - - let nextSignature = [appKeybindOverrideContents, themeFallbackOverrideContents].joined(separator: "\n---\n") - guard nextSignature != runtimeOverrideSignature else { return } - - var overrideURLs: [URL] = [] - if !appKeybindOverrideContents.isEmpty { - let url = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("prowl-ghostty-keybind-overrides.conf") - do { - try appKeybindOverrideContents.write(to: url, atomically: true, encoding: .utf8) - overrideURLs.append(url) - } catch { - ghosttyLogger.warning("Failed to write ghostty keybind override file: \(error.localizedDescription)") - return - } - } - - if !themeFallbackOverrideContents.isEmpty { - let url = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("prowl-ghostty-theme-overrides.conf") - do { - try themeFallbackOverrideContents.write(to: url, atomically: true, encoding: .utf8) - overrideURLs.append(url) - } catch { - ghosttyLogger.warning("Failed to write ghostty theme override file: \(error.localizedDescription)") - return - } - } - - guard let updated = ghostty_config_new() else { return } - ghostty_config_load_default_files(updated) - ghostty_config_load_recursive_files(updated) - ghostty_config_load_cli_args(updated) - for url in overrideURLs { - url.path.withCString { path in - ghostty_config_load_file(updated, path) - } - } - ghostty_config_finalize(updated) - ghostty_app_update_config(app, updated) - if let clone = ghostty_config_clone(updated) { - setConfig(clone) - } - ghostty_config_free(updated) - runtimeOverrideSignature = nextSignature - onConfigChange?() - NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: self) - } - - nonisolated private static func probeUserConfigSnapshot() async -> GhosttyUserConfigSnapshot? { - // `await` ensures this runs on a cooperative executor rather than on the - // caller's MainActor, so the synchronous subprocess calls below never block - // the main thread. - await Task.yield() - return userConfigSnapshotFromCLI() - } - - nonisolated private static func probeFallbackThemePair() async -> GhosttyThemePair? { - await Task.yield() - return resolveFallbackThemePair() - } - - nonisolated private static func userConfigSnapshotFromCLI() -> GhosttyUserConfigSnapshot? { - guard let output = runGhosttyCommand(arguments: ["+show-config"]) else { return nil } - let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: output) - - // `ghostty +show-config` collapses an explicit same-name light/dark pair - // (`theme = light:X,dark:X`) back into a single `theme = X`. Trusting that - // alone would make us apply the single-theme fallback over the user's - // explicit light/dark choice, so re-derive the theme mode from the raw - // config text when we can read it. The background tone still comes from the - // resolved CLI output. - guard let rawMode = rawUserThemeMode() else { return snapshot } - return GhosttyUserConfigSnapshot(themeMode: rawMode, backgroundTone: snapshot.backgroundTone) - } - - nonisolated private static func rawUserThemeMode() -> GhosttyThemeMode? { - guard let url = preferredGhosttyConfigURL(), - let contents = try? String(contentsOf: url, encoding: .utf8), - let spec = GhosttyUserConfigSnapshot.rawThemeSpec(fromConfig: contents) - else { return nil } - return GhosttyUserConfigSnapshot.parseThemeMode(from: spec) - } - - /// Mirrors Ghostty's macOS default-config selection: prefer the Application - /// Support file when present, otherwise fall back to the XDG config. Within - /// each location the modern `config.ghostty` wins over the legacy `config`. - /// `theme` set through `config-file` includes isn't resolved here; those rare - /// setups simply keep the previous `+show-config` behavior. - nonisolated private static func preferredGhosttyConfigURL() -> URL? { - let fileManager = FileManager.default - let home = fileManager.homeDirectoryForCurrentUser - - let appSupport = home.appending( - path: "Library/Application Support/com.mitchellh.ghostty", - directoryHint: .isDirectory - ) - let xdgRoot: URL - if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty { - xdgRoot = URL(fileURLWithPath: xdg, isDirectory: true) - } else { - xdgRoot = home.appending(path: ".config", directoryHint: .isDirectory) - } - let xdg = xdgRoot.appending(path: "ghostty", directoryHint: .isDirectory) - - let candidates = [ - appSupport.appending(path: "config.ghostty"), - appSupport.appending(path: "config"), - xdg.appending(path: "config.ghostty"), - xdg.appending(path: "config"), - ] - return candidates.first { fileManager.fileExists(atPath: $0.path) } - } - - nonisolated private static func runGhosttyCommand(arguments: [String]) -> String? { - guard let executablePath = resolveGhosttyExecutablePath() else { return nil } - let process = Process() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - let outputPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = Pipe() - do { - try process.run() - process.waitUntilExit() - } catch { - let command = arguments.joined(separator: " ") - ghosttyLogger.warning( - "Failed to run ghostty command \(command): \(error.localizedDescription)" - ) - return nil - } - guard process.terminationStatus == 0 else { return nil } - let data = outputPipe.fileHandleForReading.readDataToEndOfFile() - guard !data.isEmpty else { return "" } - return String(data: data, encoding: .utf8) - } - - nonisolated private static func resolveGhosttyExecutablePath() -> String? { - ghosttyCLICacheLock.lock() - if let cachedGhosttyExecutablePath, - FileManager.default.isExecutableFile(atPath: cachedGhosttyExecutablePath) - { - defer { ghosttyCLICacheLock.unlock() } - return cachedGhosttyExecutablePath - } - if ghosttyExecutableResolutionAttempted { - ghosttyCLICacheLock.unlock() - return nil - } - ghosttyCLICacheLock.unlock() - - var resolvedPath: String? - for candidate in ghosttyExecutableCandidates where FileManager.default.isExecutableFile(atPath: candidate) { - resolvedPath = candidate - break - } - - if resolvedPath == nil { - let which = Process() - which.executableURL = URL(fileURLWithPath: "/usr/bin/which") - which.arguments = ["ghostty"] - let outputPipe = Pipe() - which.standardOutput = outputPipe - which.standardError = Pipe() - do { - try which.run() - which.waitUntilExit() - } catch { - ghosttyCLICacheLock.lock() - ghosttyExecutableResolutionAttempted = true - ghosttyCLICacheLock.unlock() - return nil - } - - if which.terminationStatus == 0 { - let data = outputPipe.fileHandleForReading.readDataToEndOfFile() - if let path = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty, - FileManager.default.isExecutableFile(atPath: path) - { - resolvedPath = path - } - } - } - - ghosttyCLICacheLock.lock() - defer { ghosttyCLICacheLock.unlock() } - ghosttyExecutableResolutionAttempted = true - cachedGhosttyExecutablePath = resolvedPath - return resolvedPath - } - - nonisolated private static func resolveFallbackThemePair() -> GhosttyThemePair? { - ghosttyCLICacheLock.lock() - if let cachedFallbackThemePair { - defer { ghosttyCLICacheLock.unlock() } - return cachedFallbackThemePair - } - ghosttyCLICacheLock.unlock() - - let knownLightCandidates = ["Ghostty Default Style Light", "Catppuccin Latte"] - let knownDarkCandidates = ["Ghostty Default Style Dark", "Catppuccin Frappe"] - - var resolvedPair: GhosttyThemePair? - if let output = runGhosttyCommand(arguments: ["+list-themes"]) { - let availableThemes = Set( - output - .split(whereSeparator: \.isNewline) - .map { line -> String in - let raw = String(line).trimmingCharacters(in: .whitespacesAndNewlines) - if let index = raw.lastIndex(of: "("), raw.hasSuffix(")") { - return String(raw[.. [String] { + static func keybindEntries(from keybindArguments: [String]) -> [String] { let prefix = "--keybind=" return keybindArguments.compactMap { argument in guard argument.hasPrefix(prefix) else { return nil } @@ -909,7 +311,7 @@ final class GhosttyRuntime { } } - private static func makeKeybindOverrideEntries( + static func makeKeybindOverrideEntries( entries: [String], previousEntries: [String] ) -> [String] { @@ -924,13 +326,13 @@ final class GhosttyRuntime { return unbindEntries + entries } - private static func keybindTrigger(from entry: String) -> String? { + static func keybindTrigger(from entry: String) -> String? { guard let separator = entry.firstIndex(of: "=") else { return nil } let trigger = String(entry[.. ghostty_config_t? { + static func loadConfig() -> ghostty_config_t? { guard let config = ghostty_config_new() else { return nil } ghostty_config_load_default_files(config) ghostty_config_load_recursive_files(config) @@ -1078,7 +480,7 @@ final class GhosttyRuntime { } func scrollbarAppearanceName() -> NSAppearance.Name { - backgroundColor().isLightColor ? .aqua : .darkAqua + backgroundColor().ghosttyIsLightColor ? .aqua : .darkAqua } /// Returns a window background color that tints the macOS glass effect @@ -1089,7 +491,7 @@ final class GhosttyRuntime { color.withAlphaComponent(0.7) } - private func backgroundColorFromConfig() -> NSColor? { + func backgroundColorFromConfig() -> NSColor? { guard let config else { return nil } var color: ghostty_config_color_s = .init() let key = "background" @@ -1099,7 +501,7 @@ final class GhosttyRuntime { return NSColor(ghostty: color) } - private static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { + static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent switch trigger.tag { case GHOSTTY_TRIGGER_PHYSICAL: @@ -1116,7 +518,7 @@ final class GhosttyRuntime { return KeyboardShortcut(key, modifiers: eventModifiers(mods: trigger.mods)) } - private static func eventModifiers(mods: ghostty_input_mods_e) -> EventModifiers { + static func eventModifiers(mods: ghostty_input_mods_e) -> EventModifiers { var flags: EventModifiers = [] if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) } if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) } @@ -1125,7 +527,7 @@ final class GhosttyRuntime { return flags } - private static let keyToEquivalent: [ghostty_input_key_e: KeyEquivalent] = [ + static let keyToEquivalent: [ghostty_input_key_e: KeyEquivalent] = [ GHOSTTY_KEY_ARROW_UP: .upArrow, GHOSTTY_KEY_ARROW_DOWN: .downArrow, GHOSTTY_KEY_ARROW_LEFT: .leftArrow, @@ -1142,219 +544,3 @@ final class GhosttyRuntime { GHOSTTY_KEY_SPACE: .space, ] } - -nonisolated struct GhosttyThemePair: Equatable, Sendable { - let light: String - let dark: String -} - -nonisolated enum GhosttyThemeMode: Equatable, Sendable { - case none - case single - case dual - - /// Whether a tone mismatch with the app appearance should fall back to a - /// default light/dark pair. A `.dual` theme is the user's explicit per-mode - /// choice and is always respected; `.single` and `.none` are adapted so a - /// light app never shows a dark terminal — `.none` because Ghostty's - /// no-theme default is a fixed dark scheme that otherwise ignores the - /// app appearance. - var allowsMismatchFallback: Bool { - switch self { - case .single, .none: - return true - case .dual: - return false - } - } -} - -nonisolated enum GhosttyTerminalTone: Equatable, Sendable { - case light - case dark - case unknown -} - -nonisolated struct GhosttyUserConfigSnapshot: Equatable, Sendable { - let themeMode: GhosttyThemeMode - let backgroundTone: GhosttyTerminalTone - - static func parse(showConfigOutput: String) -> GhosttyUserConfigSnapshot { - var themeSpec: String? - var backgroundSpec: String? - - for rawLine in showConfigOutput.split(whereSeparator: \.isNewline) { - let line = String(rawLine) - guard let separator = line.firstIndex(of: "=") else { continue } - let key = line[.. String? { - var lastSpec: String? - for rawLine in contents.split(whereSeparator: \.isNewline) { - let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.hasPrefix("#"), let separator = line.firstIndex(of: "=") else { continue } - let key = line[.. GhosttyThemeMode { - guard let spec, !spec.isEmpty else { return .none } - - var hasLight = false - var hasDark = false - - for rawPart in spec.split(separator: ",", omittingEmptySubsequences: true) { - let part = rawPart.trimmingCharacters(in: .whitespacesAndNewlines) - guard let separator = part.firstIndex(of: ":") else { continue } - let key = part[.. GhosttyTerminalTone { - // Decide "light or dark" purely from luminance. Popular dark themes - // (Dracula, Nord, One Dark, Kanagawa, Solarized Dark, etc.) often have - // noticeably tinted backgrounds, so gating on saturation misclassifies - // them as unknown and defeats the whole fallback. - guard let spec, let color = NSColor(ghosttyHexColor: spec) else { return .unknown } - - let luminance = color.luminance - if luminance >= 0.65 { - return .light - } - if luminance <= 0.35 { - return .dark - } - return .unknown - } -} - -extension Notification.Name { - static let ghosttyRuntimeConfigDidChange = Notification.Name("ghosttyRuntimeConfigDidChange") -} - -extension NSColor { - fileprivate var isLightColor: Bool { - luminance > 0.5 - } - - nonisolated var luminance: Double { - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - guard let rgb = usingColorSpace(.sRGB) else { return 0 } - rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return (0.299 * red) + (0.587 * green) + (0.114 * blue) - } - - nonisolated fileprivate convenience init?(ghosttyHexColor: String) { - let cleaned = - ghosttyHexColor - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "#", with: "") - guard cleaned.count == 6, let value = Int(cleaned, radix: 16) else { - return nil - } - - let red = Double((value >> 16) & 0xFF) / 255 - let green = Double((value >> 8) & 0xFF) / 255 - let blue = Double(value & 0xFF) / 255 - self.init(red: red, green: green, blue: blue, alpha: 1) - } - - fileprivate convenience init(ghostty: ghostty_config_color_s) { - let red = Double(ghostty.r) / 255 - let green = Double(ghostty.g) / 255 - let blue = Double(ghostty.b) / 255 - self.init(red: red, green: green, blue: blue, alpha: 1) - } -} - -extension NSPasteboard.PasteboardType { - init?(mimeType: String) { - switch mimeType { - case "text/plain": - self = .string - return - default: - break - } - guard let utType = UTType(mimeType: mimeType) else { - self.init(mimeType) - return - } - self.init(utType.identifier) - } -} - -extension NSPasteboard { - private static let ghosttyEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" - - static func ghosttyEscape(_ str: String) -> String { - var result = str - for char in ghosttyEscapeCharacters { - result = result.replacing(String(char), with: "\\\(char)") - } - return result - } - - static var ghosttySelection: NSPasteboard = { - NSPasteboard(name: .init("com.mitchellh.ghostty.selection")) - }() - - func getOpinionatedStringContents() -> String? { - if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], - urls.count > 0 - { - return - urls - .map { $0.isFileURL ? Self.ghosttyEscape($0.path) : $0.absoluteString } - .joined(separator: " ") - } - return string(forType: .string) - } - - static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { - switch clipboard { - case GHOSTTY_CLIPBOARD_STANDARD: - return Self.general - case GHOSTTY_CLIPBOARD_SELECTION: - return Self.ghosttySelection - default: - return nil - } - } -} diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntimeSupport.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntimeSupport.swift new file mode 100644 index 00000000..d7a95b74 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntimeSupport.swift @@ -0,0 +1,219 @@ +import AppKit +import GhosttyKit +import UniformTypeIdentifiers + +nonisolated struct GhosttyThemePair: Equatable, Sendable { + let light: String + let dark: String +} + +nonisolated enum GhosttyThemeMode: Equatable, Sendable { + case none + case single + case dual + + /// Whether a tone mismatch with the app appearance should fall back to a + /// default light/dark pair. A `.dual` theme is the user's explicit per-mode + /// choice and is always respected; `.single` and `.none` are adapted so a + /// light app never shows a dark terminal — `.none` because Ghostty's + /// no-theme default is a fixed dark scheme that otherwise ignores the + /// app appearance. + var allowsMismatchFallback: Bool { + switch self { + case .single, .none: + return true + case .dual: + return false + } + } +} + +nonisolated enum GhosttyTerminalTone: Equatable, Sendable { + case light + case dark + case unknown +} + +nonisolated struct GhosttyUserConfigSnapshot: Equatable, Sendable { + let themeMode: GhosttyThemeMode + let backgroundTone: GhosttyTerminalTone + + static func parse(showConfigOutput: String) -> GhosttyUserConfigSnapshot { + var themeSpec: String? + var backgroundSpec: String? + + for rawLine in showConfigOutput.split(whereSeparator: \.isNewline) { + let line = String(rawLine) + guard let separator = line.firstIndex(of: "=") else { continue } + let key = line[.. String? { + var lastSpec: String? + for rawLine in contents.split(whereSeparator: \.isNewline) { + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.hasPrefix("#"), let separator = line.firstIndex(of: "=") else { continue } + let key = line[.. GhosttyThemeMode { + guard let spec, !spec.isEmpty else { return .none } + + var hasLight = false + var hasDark = false + + for rawPart in spec.split(separator: ",", omittingEmptySubsequences: true) { + let part = rawPart.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = part.firstIndex(of: ":") else { continue } + let key = part[.. GhosttyTerminalTone { + // Decide "light or dark" purely from luminance. Popular dark themes + // (Dracula, Nord, One Dark, Kanagawa, Solarized Dark, etc.) often have + // noticeably tinted backgrounds, so gating on saturation misclassifies + // them as unknown and defeats the whole fallback. + guard let spec, let color = NSColor(ghosttyHexColor: spec) else { return .unknown } + + let luminance = color.luminance + if luminance >= 0.65 { + return .light + } + if luminance <= 0.35 { + return .dark + } + return .unknown + } +} + +extension Notification.Name { + static let ghosttyRuntimeConfigDidChange = Notification.Name("ghosttyRuntimeConfigDidChange") +} + +extension NSColor { + var ghosttyIsLightColor: Bool { + luminance > 0.5 + } + + nonisolated var luminance: Double { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard let rgb = usingColorSpace(.sRGB) else { return 0 } + rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (0.299 * red) + (0.587 * green) + (0.114 * blue) + } + + nonisolated convenience init?(ghosttyHexColor: String) { + let cleaned = + ghosttyHexColor + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "#", with: "") + guard cleaned.count == 6, let value = Int(cleaned, radix: 16) else { + return nil + } + + let red = Double((value >> 16) & 0xFF) / 255 + let green = Double((value >> 8) & 0xFF) / 255 + let blue = Double(value & 0xFF) / 255 + self.init(red: red, green: green, blue: blue, alpha: 1) + } + + convenience init(ghostty: ghostty_config_color_s) { + let red = Double(ghostty.r) / 255 + let green = Double(ghostty.g) / 255 + let blue = Double(ghostty.b) / 255 + self.init(red: red, green: green, blue: blue, alpha: 1) + } +} + +extension NSPasteboard.PasteboardType { + init?(mimeType: String) { + switch mimeType { + case "text/plain": + self = .string + return + default: + break + } + guard let utType = UTType(mimeType: mimeType) else { + self.init(mimeType) + return + } + self.init(utType.identifier) + } +} + +extension NSPasteboard { + static let ghosttyEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" + + static func ghosttyEscape(_ str: String) -> String { + var result = str + for char in ghosttyEscapeCharacters { + result = result.replacing(String(char), with: "\\\(char)") + } + return result + } + + static var ghosttySelection: NSPasteboard = { + NSPasteboard(name: .init("com.mitchellh.ghostty.selection")) + }() + + func getOpinionatedStringContents() -> String? { + if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], + urls.count > 0 + { + return + urls + .map { $0.isFileURL ? Self.ghosttyEscape($0.path) : $0.absoluteString } + .joined(separator: " ") + } + return string(forType: .string) + } + + static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { + switch clipboard { + case GHOSTTY_CLIPBOARD_STANDARD: + return Self.general + case GHOSTTY_CLIPBOARD_SELECTION: + return Self.ghosttySelection + default: + return nil + } + } +} -- 2.51.2 From 221a1fabfc6e14c9608c52444d1689de19f88c3f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:42:43 +0900 Subject: [PATCH 332/528] Split command palette support logic --- .../Reducer/CommandPaletteFeature.swift | 1089 ----------------- .../Reducer/CommandPaletteFuzzyScorer.swift | 680 ++++++++++ .../Reducer/CommandPaletteSupport.swift | 412 +++++++ 3 files changed, 1092 insertions(+), 1089 deletions(-) create mode 100644 supacode/Features/CommandPalette/Reducer/CommandPaletteFuzzyScorer.swift create mode 100644 supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6ff01eb0..4ec6ecf7 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -884,1092 +884,3 @@ private func makeClosePullRequestItem( ] } #endif - -private enum CommandPaletteItemID { - static let ghosttyPrefix = "ghostty." - static let globalCheckForUpdates = "global.check-for-updates" - static let globalOpenSettings = "global.open-settings" - static let globalOpenRepository = "global.open-repository" - static let globalNewWorktree = "global.new-worktree" - static let globalRefreshWorktrees = "global.refresh-worktrees" - static let globalJumpToLatestUnread = "global.jump-to-latest-unread" - static let globalViewArchivedWorktrees = "global.view-archived-worktrees" - static let globalInstallCLI = "global.install-cli" - static let globalToggleLeftSidebar = "global.toggle-left-sidebar" - static let globalToggleActiveAgentsPanel = "global.toggle-active-agents-panel" - static let globalToggleCanvas = "global.toggle-canvas" - static let globalExpandCanvasCard = "global.expand-canvas-card" - static let globalArrangeCanvasCards = "global.arrange-canvas-cards" - static let globalOrganizeCanvasCards = "global.organize-canvas-cards" - static let globalSelectAllCanvasCards = "global.select-all-canvas-cards" - static let globalToggleShelf = "global.toggle-shelf" - static let globalShowDiff = "global.show-diff" - static let globalRevealInFinder = "global.reveal-in-finder" - static let globalCopyPath = "global.copy-path" - static let globalRevealInSidebar = "global.reveal-in-sidebar" - static let globalRunScript = "global.run-script" - static let globalStopRunScript = "global.stop-run-script" - static let globalTogglePinWorktree = "global.toggle-pin-worktree" - static let globalRenameBranch = "global.rename-branch" - static let globalDeleteWorktree = "global.delete-worktree" - - static func openRepositorySettings(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "repo.\(repositoryID).open-settings" - } - - static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { - "custom-command.\(commandID)" - } - - static var globalIDs: [CommandPaletteItem.ID] { - [ - globalCheckForUpdates, - globalOpenSettings, - globalOpenRepository, - globalNewWorktree, - globalRefreshWorktrees, - globalJumpToLatestUnread, - globalViewArchivedWorktrees, - globalInstallCLI, - globalToggleLeftSidebar, - globalToggleActiveAgentsPanel, - globalToggleCanvas, - globalExpandCanvasCard, - globalArrangeCanvasCards, - globalOrganizeCanvasCards, - globalSelectAllCanvasCards, - globalToggleShelf, - globalShowDiff, - globalRevealInFinder, - globalCopyPath, - globalRevealInSidebar, - globalRunScript, - globalStopRunScript, - globalTogglePinWorktree, - globalRenameBranch, - globalDeleteWorktree, - ] - } - - static func worktreeSelect(_ worktreeID: Worktree.ID) -> CommandPaletteItem.ID { - "worktree.\(worktreeID).select" - } - - static func changeFocusedTabIcon(_ worktreeID: Worktree.ID) -> CommandPaletteItem.ID { - "terminal.\(worktreeID).change-focused-tab-icon" - } - - static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem.ID { - "\(ghosttyPrefix)\(command.action)|\(command.title)" - } - - static func pullRequestIDs(repositoryID: Repository.ID) -> [CommandPaletteItem.ID] { - [ - pullRequestOpen(repositoryID), - pullRequestReady(repositoryID), - pullRequestCopyFailingJobURL(repositoryID), - pullRequestCopyCiLogs(repositoryID), - pullRequestRerunFailedJobs(repositoryID), - pullRequestOpenFailingCheck(repositoryID), - pullRequestMerge(repositoryID), - pullRequestClose(repositoryID), - ] - } - - static func pullRequestOpen(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).open" - } - - static func pullRequestReady(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).ready" - } - - static func pullRequestCopyFailingJobURL(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).copy-failing-job-url" - } - - static func pullRequestCopyCiLogs(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).copy-ci-logs" - } - - static func pullRequestRerunFailedJobs(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).rerun-failed-jobs" - } - - static func pullRequestOpenFailingCheck(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).open-failing-check" - } - - static func pullRequestMerge(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).merge" - } - - static func pullRequestClose(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { - "pr.\(repositoryID).close" - } -} - -private func commandPaletteRecencyScore( - _ item: CommandPaletteItem, - recencyByID: [CommandPaletteItem.ID: TimeInterval], - now: Date -) -> Double { - guard let lastActivated = recencyByID[item.id] else { return 0 } - let ageSeconds = max(0, now.timeIntervalSince1970 - lastActivated) - let ageDays = ageSeconds / 86_400 - let cappedAgeDays = min(ageDays, 30) - return pow(0.5, cappedAgeDays / 7) -} - -private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate { - if let appAction = appDelegateAction(for: kind) { - return appAction - } - switch kind { - case .worktreeSelect(let id): - return .selectWorktree(id) - case .deleteWorktree(let worktreeID, let repositoryID): - return .deleteWorktree(worktreeID, repositoryID) - case .ghosttyCommand(let action): - return .ghosttyCommand(action) - case .changeFocusedTabIcon(let worktreeID): - return .changeFocusedTabIcon(worktreeID) - case .togglePinWorktree(let worktreeID, let isCurrentlyPinned): - return .togglePinWorktree(worktreeID, isCurrentlyPinned: isCurrentlyPinned) - case .openRepositorySettings(let repositoryID): - return .openRepositorySettings(repositoryID) - case .runCustomCommand(let index, _, _): - return .runCustomCommand(index) - case .openPullRequest, - .openRepositoryOnCodeHost, - .markPullRequestReady, - .mergePullRequest, - .closePullRequest, - .copyFailingJobURL, - .copyCiFailureLogs, - .rerunFailedJobs, - .openFailingCheckDetails: - return pullRequestDelegateAction(for: kind)! - #if DEBUG - case .debugTestToast(let toast): - return .debugTestToast(toast) - case .debugSimulateUpdateFound: - return .debugSimulateUpdateFound - case .debugLightDockNotificationDot: - return .debugLightDockNotificationDot - #endif - case .checkForUpdates, - .openSettings, - .newWorktree, - .openRepository, - .viewArchivedWorktrees, - .refreshWorktrees, - .jumpToLatestUnread, - .installCLI, - .toggleLeftSidebar, - .toggleActiveAgentsPanel, - .toggleCanvas, - .expandCanvasCard, - .arrangeCanvasCards, - .organizeCanvasCards, - .selectAllCanvasCards, - .toggleShelf, - .showDiff, - .revealInFinder, - .copyPath, - .revealInSidebar, - .runScript, - .stopRunScript, - .renameBranch: - fatalError("appDelegateAction should handle app-level command palette actions") - } -} - -private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { - if let delegate = navigationDelegateAction(for: kind) { - return delegate - } - if let delegate = viewDelegateAction(for: kind) { - return delegate - } - switch kind { - case .checkForUpdates: - return .checkForUpdates - case .openSettings: - return .openSettings - case .newWorktree: - return .newWorktree - case .openRepository: - return .openRepository - case .viewArchivedWorktrees: - return .viewArchivedWorktrees - case .refreshWorktrees: - return .refreshWorktrees - case .jumpToLatestUnread: - return .jumpToLatestUnread - case .installCLI: - return .installCLI - case .runScript: - return .runScript - case .stopRunScript: - return .stopRunScript - case .renameBranch: - return .renameBranch - default: - return nil - } -} - -private func navigationDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { - switch kind { - case .revealInFinder: - return .revealInFinder - case .copyPath: - return .copyPath - case .revealInSidebar: - return .revealInSidebar - default: - return nil - } -} - -private func viewDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { - switch kind { - case .toggleLeftSidebar: - return .toggleLeftSidebar - case .toggleActiveAgentsPanel: - return .toggleActiveAgentsPanel - case .toggleCanvas: - return .toggleCanvas - case .expandCanvasCard: - return .expandCanvasCard - case .arrangeCanvasCards: - return .arrangeCanvasCards - case .organizeCanvasCards: - return .organizeCanvasCards - case .selectAllCanvasCards: - return .selectAllCanvasCards - case .toggleShelf: - return .toggleShelf - case .showDiff: - return .showDiff - default: - return nil - } -} - -private func pullRequestDelegateAction( - for kind: CommandPaletteItem.Kind -) -> CommandPaletteFeature.Delegate? { - switch kind { - case .openPullRequest(let worktreeID), - .openRepositoryOnCodeHost(let worktreeID): - return .openPullRequest(worktreeID) - case .markPullRequestReady(let worktreeID): - return .markPullRequestReady(worktreeID) - case .mergePullRequest(let worktreeID): - return .mergePullRequest(worktreeID) - case .closePullRequest(let worktreeID): - return .closePullRequest(worktreeID) - case .copyFailingJobURL(let worktreeID): - return .copyFailingJobURL(worktreeID) - case .copyCiFailureLogs(let worktreeID): - return .copyCiFailureLogs(worktreeID) - case .rerunFailedJobs(let worktreeID): - return .rerunFailedJobs(worktreeID) - case .openFailingCheckDetails(let worktreeID): - return .openFailingCheckDetails(worktreeID) - case .worktreeSelect, - .checkForUpdates, - .openSettings, - .newWorktree, - .openRepository, - .viewArchivedWorktrees, - .refreshWorktrees, - .jumpToLatestUnread, - .installCLI, - .ghosttyCommand, - .changeFocusedTabIcon, - .toggleLeftSidebar, - .toggleActiveAgentsPanel, - .toggleCanvas, - .expandCanvasCard, - .arrangeCanvasCards, - .organizeCanvasCards, - .selectAllCanvasCards, - .toggleShelf, - .showDiff, - .revealInFinder, - .copyPath, - .revealInSidebar, - .runScript, - .stopRunScript, - .togglePinWorktree, - .renameBranch, - .deleteWorktree, - .openRepositorySettings, - .runCustomCommand: - return nil - #if DEBUG - case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: - return nil - #endif - } -} - -/// Ghostty action keys that should be hidden from Prowl's command palette. -/// -/// Includes: -/// - actions Prowl already exposes natively (`check_for_updates`) -/// - Ghostty actions intentionally unsupported by Prowl's architecture/platform -private let filteredGhosttyActionKeys: Set = [ - "check_for_updates", - "new_window", - "close_all_windows", - "goto_window", - "toggle_tab_overview", - "toggle_window_decorations", - "inspector", - "show_gtk_inspector", - "show_on_screen_keyboard", -] - -private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { - commands.compactMap { command in - guard !filteredGhosttyActionKeys.contains(command.actionKey) else { return nil } - return .ghosttyCommand(command) - } -} - -extension CommandPaletteItem { - /// Build a top-level command backed by an `AppShortcuts` hotkey. Defaults to - /// `defaultSuggestion: true` (since these are the kinds of actions worth - /// listing when the palette opens with no query) and uses no subtitle (the - /// hotkey hint and title already do the work). - static func appShortcut( - id: String, - title: String, - category: Category, - kind: Kind, - keywords: [String] = [], - priorityTier: Int = defaultPriorityTier - ) -> CommandPaletteItem { - CommandPaletteItem( - id: id, - title: title, - subtitle: nil, - kind: kind, - category: category, - defaultSuggestion: true, - keywords: keywords, - priorityTier: priorityTier - ) - } - - /// Build an item wrapping a Ghostty command exposed via the runtime. - /// Lives in `.terminal`, ranks below regular suggestions (priority +100), - /// and is search-only (never surfaces on empty query). - static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem { - let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) - return CommandPaletteItem( - id: CommandPaletteItemID.ghosttyCommand(command), - title: command.title, - subtitle: subtitle.isEmpty ? nil : subtitle, - kind: .ghosttyCommand(command.action), - category: .terminal, - defaultSuggestion: false, - priorityTier: CommandPaletteItem.defaultPriorityTier + 100 - ) - } -} - -private func loadRecency(into state: inout CommandPaletteFeature.State) { - @Shared(.appStorage("commandPaletteItemRecency")) var recency: [String: Double] = [:] - state.recencyByItemID = recency -} - -private func saveRecency(_ recencyByItemID: [CommandPaletteItem.ID: TimeInterval]) { - @Shared(.appStorage("commandPaletteItemRecency")) var recency: [String: Double] = [:] - $recency.withLock { - $0 = recencyByItemID - } -} - -private struct CommandPaletteFuzzyScorer { - private struct PreparedQueryPiece { - let normalized: String - let normalizedLowercase: String - let expectContiguousMatch: Bool - } - - private struct PreparedQuery { - let piece: PreparedQueryPiece - let values: [PreparedQueryPiece]? - } - - private struct Match { - var start: Int - var end: Int - } - - private struct ItemScore { - var score: Int - var labelMatch: [Match]? - var descriptionMatch: [Match]? - } - - private struct ScoredItem { - let item: CommandPaletteItem - let score: ItemScore - let recencyScore: Double - let index: Int - } - - private static let labelPrefixScoreThreshold = 1 << 17 - private static let labelScoreThreshold = 1 << 16 - - private let query: PreparedQuery - private let allowNonContiguousMatches: Bool - private let recencyByID: [CommandPaletteItem.ID: TimeInterval] - private let now: Date - - init( - query: String, - recencyByID: [CommandPaletteItem.ID: TimeInterval], - now: Date, - allowNonContiguousMatches: Bool = true - ) { - self.query = Self.prepareQuery(query) - self.allowNonContiguousMatches = allowNonContiguousMatches - self.recencyByID = recencyByID - self.now = now - } - - func rankedItems(from items: [CommandPaletteItem]) -> [CommandPaletteItem] { - let scoredItems = items.enumerated().compactMap { index, item in - let score = scoreItem(item) - return score.score > 0 - ? ScoredItem( - item: item, - score: score, - recencyScore: recencyScore(for: item), - index: index - ) - : nil - } - let sorted = scoredItems.sorted { compare($0, $1) < 0 } - return sorted.map(\.item) - } - - private func scoreItem(_ item: CommandPaletteItem) -> ItemScore { - guard !query.piece.normalized.isEmpty else { - return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) - } - - if let values = query.values, !values.isEmpty { - return scoreItemMultiple( - label: item.title, - description: item.subtitle, - keywords: item.keywords, - query: values - ) - } - - return scoreItemForPiece( - label: item.title, - description: item.subtitle, - keywords: item.keywords, - query: query.piece - ) - } - - private func scoreItemMultiple( - label: String, - description: String?, - keywords: [String], - query: [PreparedQueryPiece] - ) -> ItemScore { - var totalScore = 0 - var totalLabelMatches: [Match] = [] - var totalDescriptionMatches: [Match] = [] - - for piece in query { - let score = scoreItemForPiece(label: label, description: description, keywords: keywords, query: piece) - if score.score == 0 { - return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) - } - totalScore += score.score - if let labelMatch = score.labelMatch { - totalLabelMatches.append(contentsOf: labelMatch) - } - if let descriptionMatch = score.descriptionMatch { - totalDescriptionMatches.append(contentsOf: descriptionMatch) - } - } - - return ItemScore( - score: totalScore, - labelMatch: normalizeMatches(totalLabelMatches), - descriptionMatch: normalizeMatches(totalDescriptionMatches) - ) - } - - /// Score one query piece against the item's title (with description fallback) - /// and each keyword. Keywords act as alternative labels — when one outscores - /// the title path, we keep title-derived highlight positions so the UI never - /// paints offsets that don't exist in the visible string. - private func scoreItemForPiece( - label: String, - description: String?, - keywords: [String], - query: PreparedQueryPiece - ) -> ItemScore { - var best = scoreItemSingle(label: label, description: description, query: query) - for keyword in keywords { - let keywordScore = scoreItemSingle(label: keyword, description: nil, query: query) - if keywordScore.score > best.score { - best = ItemScore( - score: keywordScore.score, - labelMatch: best.labelMatch, - descriptionMatch: best.descriptionMatch - ) - } - } - return best - } - - private func scoreItemSingle( - label: String, - description: String?, - query: PreparedQueryPiece - ) -> ItemScore { - let (labelScore, labelPositions) = scoreFuzzy( - target: label, - query: query, - allowNonContiguousMatches: allowNonContiguousMatches && !query.expectContiguousMatch - ) - if labelScore > 0 { - let labelPrefixMatch = matchesPrefix(query: query.normalizedLowercase, target: label) - let baseScore: Int - if let labelPrefixMatch { - let prefixLengthBoost = Int( - (Double(query.normalized.count) / Double(label.count) * 100).rounded() - ) - baseScore = Self.labelPrefixScoreThreshold + prefixLengthBoost - return ItemScore( - score: baseScore + labelScore, - labelMatch: labelPrefixMatch, - descriptionMatch: nil - ) - } - baseScore = Self.labelScoreThreshold - return ItemScore( - score: baseScore + labelScore, - labelMatch: createMatches(labelPositions), - descriptionMatch: nil - ) - } - - if let description { - let descriptionPrefixLength = description.count - let descriptionAndLabel = description + label - let (labelDescriptionScore, labelDescriptionPositions) = scoreFuzzy( - target: descriptionAndLabel, - query: query, - allowNonContiguousMatches: allowNonContiguousMatches && !query.expectContiguousMatch - ) - if labelDescriptionScore > 0 { - let labelDescriptionMatches = createMatches(labelDescriptionPositions) - var labelMatch: [Match] = [] - var descriptionMatch: [Match] = [] - - for match in labelDescriptionMatches { - if match.start < descriptionPrefixLength && match.end > descriptionPrefixLength { - labelMatch.append(Match(start: 0, end: match.end - descriptionPrefixLength)) - descriptionMatch.append(Match(start: match.start, end: descriptionPrefixLength)) - } else if match.start >= descriptionPrefixLength { - labelMatch.append( - Match( - start: match.start - descriptionPrefixLength, - end: match.end - descriptionPrefixLength - ) - ) - } else { - descriptionMatch.append(match) - } - } - - return ItemScore( - score: labelDescriptionScore, - labelMatch: labelMatch, - descriptionMatch: descriptionMatch - ) - } - } - - return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) - } - - private func compare(_ itemA: ScoredItem, _ itemB: ScoredItem) -> Int { - let scoreA = itemA.score.score - let scoreB = itemB.score.score - - if scoreA > Self.labelScoreThreshold || scoreB > Self.labelScoreThreshold { - if scoreA != scoreB { - return scoreA > scoreB ? -1 : 1 - } - if scoreA < Self.labelPrefixScoreThreshold && scoreB < Self.labelPrefixScoreThreshold { - let comparedByMatchLength = compareByMatchLength(itemA.score.labelMatch, itemB.score.labelMatch) - if comparedByMatchLength != 0 { - return comparedByMatchLength - } - } - let labelA = itemA.item.title - let labelB = itemB.item.title - if labelA.count != labelB.count { - return labelA.count - labelB.count - } - } - - if scoreA != scoreB { - return scoreA > scoreB ? -1 : 1 - } - - let itemAHasLabelMatches = !(itemA.score.labelMatch?.isEmpty ?? true) - let itemBHasLabelMatches = !(itemB.score.labelMatch?.isEmpty ?? true) - if itemAHasLabelMatches && !itemBHasLabelMatches { - return -1 - } - if itemBHasLabelMatches && !itemAHasLabelMatches { - return 1 - } - - if let itemAMatchDistance = matchDistance(itemA), - let itemBMatchDistance = matchDistance(itemB), - itemAMatchDistance != itemBMatchDistance - { - return itemBMatchDistance > itemAMatchDistance ? -1 : 1 - } - - if itemA.item.priorityTier != itemB.item.priorityTier { - return itemA.item.priorityTier < itemB.item.priorityTier ? -1 : 1 - } - - if itemA.recencyScore != itemB.recencyScore { - return itemA.recencyScore > itemB.recencyScore ? -1 : 1 - } - - let fallback = fallbackCompare(itemA.item, itemB.item) - if fallback != 0 { - return fallback - } - - return itemA.index - itemB.index - } - - private func matchDistance(_ item: ScoredItem) -> Int? { - var matchStart = -1 - var matchEnd = -1 - - if let descriptionMatch = item.score.descriptionMatch, !descriptionMatch.isEmpty { - matchStart = descriptionMatch[0].start - } else if let labelMatch = item.score.labelMatch, !labelMatch.isEmpty { - matchStart = labelMatch[0].start - } - - if let labelMatch = item.score.labelMatch, !labelMatch.isEmpty { - matchEnd = labelMatch[labelMatch.count - 1].end - if let descriptionMatch = item.score.descriptionMatch, - !descriptionMatch.isEmpty, - let description = item.item.subtitle - { - matchEnd += description.count - } - } else if let descriptionMatch = item.score.descriptionMatch, !descriptionMatch.isEmpty { - matchEnd = descriptionMatch[descriptionMatch.count - 1].end - } - - guard matchStart != -1 else { return nil } - return matchEnd - matchStart - } - - private func compareByMatchLength(_ matchesA: [Match]?, _ matchesB: [Match]?) -> Int { - guard let matchesA, let matchesB else { return 0 } - if matchesA.isEmpty && matchesB.isEmpty { - return 0 - } - if matchesB.isEmpty { - return -1 - } - if matchesA.isEmpty { - return 1 - } - - let matchLengthA = matchesA[matchesA.count - 1].end - matchesA[0].start - let matchLengthB = matchesB[matchesB.count - 1].end - matchesB[0].start - - if matchLengthA == matchLengthB { - return 0 - } - return matchLengthB < matchLengthA ? 1 : -1 - } - - private func fallbackCompare(_ itemA: CommandPaletteItem, _ itemB: CommandPaletteItem) -> Int { - let labelA = itemA.title - let labelB = itemB.title - let descriptionA = itemA.subtitle - let descriptionB = itemB.subtitle - - let labelDescriptionALength = labelA.count + (descriptionA?.count ?? 0) - let labelDescriptionBLength = labelB.count + (descriptionB?.count ?? 0) - - if labelDescriptionALength != labelDescriptionBLength { - return labelDescriptionALength - labelDescriptionBLength - } - - if labelA != labelB { - return compareStrings(labelA, labelB) - } - - if let descriptionA, let descriptionB, descriptionA != descriptionB { - return compareStrings(descriptionA, descriptionB) - } - - return 0 - } - - private func compareStrings(_ stringA: String, _ stringB: String) -> Int { - switch stringA.localizedStandardCompare(stringB) { - case .orderedAscending: - return -1 - case .orderedDescending: - return 1 - case .orderedSame: - return 0 - } - } - - private func recencyScore(for item: CommandPaletteItem) -> Double { - commandPaletteRecencyScore(item, recencyByID: recencyByID, now: now) - } - - private func scoreFuzzy( - target: String, - query: PreparedQueryPiece, - allowNonContiguousMatches: Bool - ) -> (Int, [Int]) { - if target.isEmpty || query.normalized.isEmpty { - return (0, []) - } - - let targetChars = Array(target) - let queryChars = Array(query.normalized) - - if targetChars.count < queryChars.count { - return (0, []) - } - - let targetLower = Array(target.lowercased()) - let queryLower = Array(query.normalizedLowercase) - - return doScoreFuzzy( - query: queryChars, - queryLower: queryLower, - target: targetChars, - targetLower: targetLower, - allowNonContiguousMatches: allowNonContiguousMatches - ) - } - - private func doScoreFuzzy( - query: [Character], - queryLower: [Character], - target: [Character], - targetLower: [Character], - allowNonContiguousMatches: Bool - ) -> (Int, [Int]) { - let queryLength = query.count - let targetLength = target.count - let scores = Array(repeating: 0, count: queryLength * targetLength) - var mutableScores = scores - let matches = Array(repeating: 0, count: queryLength * targetLength) - var mutableMatches = matches - - for queryIndex in 0.. 0 - - let queryCharAtIndex = query[queryIndex] - let queryLowerCharAtIndex = queryLower[queryIndex] - - for targetIndex in 0.. 0 - - let currentIndex = queryIndexOffset + targetIndex - let leftIndex = currentIndex - 1 - let diagIndex = queryIndexPreviousOffset + targetIndex - 1 - - let leftScore = targetIndexGtNull ? mutableScores[leftIndex] : 0 - let diagScore = queryIndexGtNull && targetIndexGtNull ? mutableScores[diagIndex] : 0 - - let matchesSequenceLength = - queryIndexGtNull && targetIndexGtNull ? mutableMatches[diagIndex] : 0 - - let score: Int - let scoreContext = CharScoreContext( - queryChar: queryCharAtIndex, - queryLowerChar: queryLowerCharAtIndex, - target: target, - targetLower: targetLower, - targetIndex: targetIndex, - matchesSequenceLength: matchesSequenceLength - ) - if diagScore != 0 && queryIndexGtNull { - score = computeCharScore(scoreContext) - } else if queryIndexGtNull { - score = 0 - } else { - score = computeCharScore(scoreContext) - } - - let isValidScore = score > 0 && diagScore + score >= leftScore - - if isValidScore - && (allowNonContiguousMatches || queryIndexGtNull - || startsWith( - targetLower, - queryLower, - at: targetIndex - )) - { - mutableMatches[currentIndex] = matchesSequenceLength + 1 - mutableScores[currentIndex] = diagScore + score - } else { - mutableMatches[currentIndex] = 0 - mutableScores[currentIndex] = leftScore - } - } - } - - var positions: [Int] = [] - var queryIndex = queryLength - 1 - var targetIndex = targetLength - 1 - while queryIndex >= 0 && targetIndex >= 0 { - let currentIndex = queryIndex * targetLength + targetIndex - let match = mutableMatches[currentIndex] - if match == 0 { - targetIndex -= 1 - } else { - positions.append(targetIndex) - queryIndex -= 1 - targetIndex -= 1 - } - } - - positions.reverse() - let finalScore = mutableScores[queryLength * targetLength - 1] - return (finalScore, positions) - } - - private struct CharScoreContext { - let queryChar: Character - let queryLowerChar: Character - let target: [Character] - let targetLower: [Character] - let targetIndex: Int - let matchesSequenceLength: Int - } - - private func computeCharScore(_ context: CharScoreContext) -> Int { - if !considerAsEqual(context.queryLowerChar, context.targetLower[context.targetIndex]) { - return 0 - } - - var score = 1 - - if context.matchesSequenceLength > 0 { - score += (min(context.matchesSequenceLength, 3) * 6) - score += max(0, context.matchesSequenceLength - 3) * 3 - } - - if context.queryChar == context.target[context.targetIndex] { - score += 1 - } - - if context.targetIndex == 0 { - score += 8 - } else { - let separatorBonus = scoreSeparatorAtPos(context.target[context.targetIndex - 1]) - if separatorBonus > 0 { - score += separatorBonus - } else if isUpper(context.target[context.targetIndex]) && context.matchesSequenceLength == 0 { - score += 2 - } - } - - return score - } - - private func considerAsEqual(_ lhs: Character, _ rhs: Character) -> Bool { - if lhs == rhs { - return true - } - if lhs == "/" || lhs == "\\" { - return rhs == "/" || rhs == "\\" - } - return false - } - - private func scoreSeparatorAtPos(_ char: Character) -> Int { - switch char { - case "/", "\\": - return 5 - case "_", "-", ".", " ", "'", "\"", ":": - return 4 - default: - return 0 - } - } - - private func isUpper(_ char: Character) -> Bool { - guard let scalar = String(char).unicodeScalars.first else { return false } - return scalar.properties.isUppercase - } - - private func startsWith( - _ target: [Character], - _ query: [Character], - at index: Int - ) -> Bool { - guard index + query.count <= target.count else { return false } - for queryIndex in 0.. [Match] { - var matches: [Match] = [] - var lastMatch: Match? - - for position in offsets { - if var lastMatch, lastMatch.end == position { - lastMatch.end += 1 - matches[matches.count - 1] = lastMatch - } else { - let match = Match(start: position, end: position + 1) - matches.append(match) - lastMatch = match - } - } - - return matches - } - - private func normalizeMatches(_ matches: [Match]) -> [Match]? { - guard !matches.isEmpty else { return nil } - - let sortedMatches = matches.sorted { $0.start < $1.start } - var normalizedMatches: [Match] = [] - var currentMatch: Match? - - for match in sortedMatches { - if let existing = currentMatch, matchOverlaps(existing, match) { - let merged = Match( - start: min(existing.start, match.start), - end: max(existing.end, match.end) - ) - currentMatch = merged - normalizedMatches[normalizedMatches.count - 1] = merged - } else { - currentMatch = match - normalizedMatches.append(match) - } - } - - return normalizedMatches - } - - private func matchOverlaps(_ matchA: Match, _ matchB: Match) -> Bool { - if matchA.end < matchB.start { - return false - } - if matchB.end < matchA.start { - return false - } - return true - } - - private func matchesPrefix(query: String, target: String) -> [Match]? { - let targetLower = target.lowercased() - guard targetLower.hasPrefix(query) else { return nil } - return [Match(start: 0, end: query.count)] - } - - private static func prepareQuery(_ original: String) -> PreparedQuery { - let expectContiguousMatch = queryExpectsExactMatch(original) - let normalized = normalizeQuery(original) - let piece = PreparedQueryPiece( - normalized: normalized.normalized, - normalizedLowercase: normalized.normalizedLowercase, - expectContiguousMatch: expectContiguousMatch - ) - - let splitPieces = original.split(separator: " ") - var values: [PreparedQueryPiece] = [] - if splitPieces.count > 1 { - for pieceValue in splitPieces { - let value = String(pieceValue) - let expectExactMatchPiece = queryExpectsExactMatch(value) - let normalizedPiece = normalizeQuery(value) - if normalizedPiece.normalized.isEmpty { - continue - } - values.append( - PreparedQueryPiece( - normalized: normalizedPiece.normalized, - normalizedLowercase: normalizedPiece.normalizedLowercase, - expectContiguousMatch: expectExactMatchPiece - ) - ) - } - } - - return PreparedQuery( - piece: piece, - values: values.isEmpty ? nil : values - ) - } - - private static func normalizeQuery(_ original: String) -> (normalized: String, normalizedLowercase: String) { - var pathNormalized = String() - pathNormalized.reserveCapacity(original.count) - for char in original { - if char == "\\" { - pathNormalized.append("/") - } else { - pathNormalized.append(char) - } - } - - var normalized = String() - normalized.reserveCapacity(pathNormalized.count) - for char in pathNormalized { - if char == "*" || char == "…" || char == "\"" || char.isWhitespace { - continue - } - normalized.append(char) - } - - if normalized.count > 1, normalized.hasSuffix("#") { - normalized.removeLast() - } - - return (normalized, normalized.lowercased()) - } - - private static func queryExpectsExactMatch(_ query: String) -> Bool { - query.hasPrefix("\"") && query.hasSuffix("\"") - } -} diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFuzzyScorer.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFuzzyScorer.swift new file mode 100644 index 00000000..89b1a8e6 --- /dev/null +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFuzzyScorer.swift @@ -0,0 +1,680 @@ +import Foundation + +struct CommandPaletteFuzzyScorer { + struct PreparedQueryPiece { + let normalized: String + let normalizedLowercase: String + let expectContiguousMatch: Bool + } + + struct PreparedQuery { + let piece: PreparedQueryPiece + let values: [PreparedQueryPiece]? + } + + struct Match { + var start: Int + var end: Int + } + + struct ItemScore { + var score: Int + var labelMatch: [Match]? + var descriptionMatch: [Match]? + } + + struct ScoredItem { + let item: CommandPaletteItem + let score: ItemScore + let recencyScore: Double + let index: Int + } + + private static let labelPrefixScoreThreshold = 1 << 17 + private static let labelScoreThreshold = 1 << 16 + + let query: PreparedQuery + let allowNonContiguousMatches: Bool + let recencyByID: [CommandPaletteItem.ID: TimeInterval] + let now: Date + + init( + query: String, + recencyByID: [CommandPaletteItem.ID: TimeInterval], + now: Date, + allowNonContiguousMatches: Bool = true + ) { + self.query = Self.prepareQuery(query) + self.allowNonContiguousMatches = allowNonContiguousMatches + self.recencyByID = recencyByID + self.now = now + } + + func rankedItems(from items: [CommandPaletteItem]) -> [CommandPaletteItem] { + let scoredItems = items.enumerated().compactMap { index, item in + let score = scoreItem(item) + return score.score > 0 + ? ScoredItem( + item: item, + score: score, + recencyScore: recencyScore(for: item), + index: index + ) + : nil + } + let sorted = scoredItems.sorted { compare($0, $1) < 0 } + return sorted.map(\.item) + } + + func scoreItem(_ item: CommandPaletteItem) -> ItemScore { + guard !query.piece.normalized.isEmpty else { + return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) + } + + if let values = query.values, !values.isEmpty { + return scoreItemMultiple( + label: item.title, + description: item.subtitle, + keywords: item.keywords, + query: values + ) + } + + return scoreItemForPiece( + label: item.title, + description: item.subtitle, + keywords: item.keywords, + query: query.piece + ) + } + + func scoreItemMultiple( + label: String, + description: String?, + keywords: [String], + query: [PreparedQueryPiece] + ) -> ItemScore { + var totalScore = 0 + var totalLabelMatches: [Match] = [] + var totalDescriptionMatches: [Match] = [] + + for piece in query { + let score = scoreItemForPiece(label: label, description: description, keywords: keywords, query: piece) + if score.score == 0 { + return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) + } + totalScore += score.score + if let labelMatch = score.labelMatch { + totalLabelMatches.append(contentsOf: labelMatch) + } + if let descriptionMatch = score.descriptionMatch { + totalDescriptionMatches.append(contentsOf: descriptionMatch) + } + } + + return ItemScore( + score: totalScore, + labelMatch: normalizeMatches(totalLabelMatches), + descriptionMatch: normalizeMatches(totalDescriptionMatches) + ) + } + + /// Score one query piece against the item's title (with description fallback) + /// and each keyword. Keywords act as alternative labels — when one outscores + /// the title path, we keep title-derived highlight positions so the UI never + /// paints offsets that don't exist in the visible string. + func scoreItemForPiece( + label: String, + description: String?, + keywords: [String], + query: PreparedQueryPiece + ) -> ItemScore { + var best = scoreItemSingle(label: label, description: description, query: query) + for keyword in keywords { + let keywordScore = scoreItemSingle(label: keyword, description: nil, query: query) + if keywordScore.score > best.score { + best = ItemScore( + score: keywordScore.score, + labelMatch: best.labelMatch, + descriptionMatch: best.descriptionMatch + ) + } + } + return best + } + + func scoreItemSingle( + label: String, + description: String?, + query: PreparedQueryPiece + ) -> ItemScore { + let (labelScore, labelPositions) = scoreFuzzy( + target: label, + query: query, + allowNonContiguousMatches: allowNonContiguousMatches && !query.expectContiguousMatch + ) + if labelScore > 0 { + let labelPrefixMatch = matchesPrefix(query: query.normalizedLowercase, target: label) + let baseScore: Int + if let labelPrefixMatch { + let prefixLengthBoost = Int( + (Double(query.normalized.count) / Double(label.count) * 100).rounded() + ) + baseScore = Self.labelPrefixScoreThreshold + prefixLengthBoost + return ItemScore( + score: baseScore + labelScore, + labelMatch: labelPrefixMatch, + descriptionMatch: nil + ) + } + baseScore = Self.labelScoreThreshold + return ItemScore( + score: baseScore + labelScore, + labelMatch: createMatches(labelPositions), + descriptionMatch: nil + ) + } + + if let description { + let descriptionPrefixLength = description.count + let descriptionAndLabel = description + label + let (labelDescriptionScore, labelDescriptionPositions) = scoreFuzzy( + target: descriptionAndLabel, + query: query, + allowNonContiguousMatches: allowNonContiguousMatches && !query.expectContiguousMatch + ) + if labelDescriptionScore > 0 { + let labelDescriptionMatches = createMatches(labelDescriptionPositions) + var labelMatch: [Match] = [] + var descriptionMatch: [Match] = [] + + for match in labelDescriptionMatches { + if match.start < descriptionPrefixLength && match.end > descriptionPrefixLength { + labelMatch.append(Match(start: 0, end: match.end - descriptionPrefixLength)) + descriptionMatch.append(Match(start: match.start, end: descriptionPrefixLength)) + } else if match.start >= descriptionPrefixLength { + labelMatch.append( + Match( + start: match.start - descriptionPrefixLength, + end: match.end - descriptionPrefixLength + ) + ) + } else { + descriptionMatch.append(match) + } + } + + return ItemScore( + score: labelDescriptionScore, + labelMatch: labelMatch, + descriptionMatch: descriptionMatch + ) + } + } + + return ItemScore(score: 0, labelMatch: nil, descriptionMatch: nil) + } + + func compare(_ itemA: ScoredItem, _ itemB: ScoredItem) -> Int { + let scoreA = itemA.score.score + let scoreB = itemB.score.score + + if scoreA > Self.labelScoreThreshold || scoreB > Self.labelScoreThreshold { + if scoreA != scoreB { + return scoreA > scoreB ? -1 : 1 + } + if scoreA < Self.labelPrefixScoreThreshold && scoreB < Self.labelPrefixScoreThreshold { + let comparedByMatchLength = compareByMatchLength(itemA.score.labelMatch, itemB.score.labelMatch) + if comparedByMatchLength != 0 { + return comparedByMatchLength + } + } + let labelA = itemA.item.title + let labelB = itemB.item.title + if labelA.count != labelB.count { + return labelA.count - labelB.count + } + } + + if scoreA != scoreB { + return scoreA > scoreB ? -1 : 1 + } + + let itemAHasLabelMatches = !(itemA.score.labelMatch?.isEmpty ?? true) + let itemBHasLabelMatches = !(itemB.score.labelMatch?.isEmpty ?? true) + if itemAHasLabelMatches && !itemBHasLabelMatches { + return -1 + } + if itemBHasLabelMatches && !itemAHasLabelMatches { + return 1 + } + + if let itemAMatchDistance = matchDistance(itemA), + let itemBMatchDistance = matchDistance(itemB), + itemAMatchDistance != itemBMatchDistance + { + return itemBMatchDistance > itemAMatchDistance ? -1 : 1 + } + + if itemA.item.priorityTier != itemB.item.priorityTier { + return itemA.item.priorityTier < itemB.item.priorityTier ? -1 : 1 + } + + if itemA.recencyScore != itemB.recencyScore { + return itemA.recencyScore > itemB.recencyScore ? -1 : 1 + } + + let fallback = fallbackCompare(itemA.item, itemB.item) + if fallback != 0 { + return fallback + } + + return itemA.index - itemB.index + } + + func matchDistance(_ item: ScoredItem) -> Int? { + var matchStart = -1 + var matchEnd = -1 + + if let descriptionMatch = item.score.descriptionMatch, !descriptionMatch.isEmpty { + matchStart = descriptionMatch[0].start + } else if let labelMatch = item.score.labelMatch, !labelMatch.isEmpty { + matchStart = labelMatch[0].start + } + + if let labelMatch = item.score.labelMatch, !labelMatch.isEmpty { + matchEnd = labelMatch[labelMatch.count - 1].end + if let descriptionMatch = item.score.descriptionMatch, + !descriptionMatch.isEmpty, + let description = item.item.subtitle + { + matchEnd += description.count + } + } else if let descriptionMatch = item.score.descriptionMatch, !descriptionMatch.isEmpty { + matchEnd = descriptionMatch[descriptionMatch.count - 1].end + } + + guard matchStart != -1 else { return nil } + return matchEnd - matchStart + } + + func compareByMatchLength(_ matchesA: [Match]?, _ matchesB: [Match]?) -> Int { + guard let matchesA, let matchesB else { return 0 } + if matchesA.isEmpty && matchesB.isEmpty { + return 0 + } + if matchesB.isEmpty { + return -1 + } + if matchesA.isEmpty { + return 1 + } + + let matchLengthA = matchesA[matchesA.count - 1].end - matchesA[0].start + let matchLengthB = matchesB[matchesB.count - 1].end - matchesB[0].start + + if matchLengthA == matchLengthB { + return 0 + } + return matchLengthB < matchLengthA ? 1 : -1 + } + + func fallbackCompare(_ itemA: CommandPaletteItem, _ itemB: CommandPaletteItem) -> Int { + let labelA = itemA.title + let labelB = itemB.title + let descriptionA = itemA.subtitle + let descriptionB = itemB.subtitle + + let labelDescriptionALength = labelA.count + (descriptionA?.count ?? 0) + let labelDescriptionBLength = labelB.count + (descriptionB?.count ?? 0) + + if labelDescriptionALength != labelDescriptionBLength { + return labelDescriptionALength - labelDescriptionBLength + } + + if labelA != labelB { + return compareStrings(labelA, labelB) + } + + if let descriptionA, let descriptionB, descriptionA != descriptionB { + return compareStrings(descriptionA, descriptionB) + } + + return 0 + } + + func compareStrings(_ stringA: String, _ stringB: String) -> Int { + switch stringA.localizedStandardCompare(stringB) { + case .orderedAscending: + return -1 + case .orderedDescending: + return 1 + case .orderedSame: + return 0 + } + } + + func recencyScore(for item: CommandPaletteItem) -> Double { + commandPaletteRecencyScore(item, recencyByID: recencyByID, now: now) + } + + func scoreFuzzy( + target: String, + query: PreparedQueryPiece, + allowNonContiguousMatches: Bool + ) -> (Int, [Int]) { + if target.isEmpty || query.normalized.isEmpty { + return (0, []) + } + + let targetChars = Array(target) + let queryChars = Array(query.normalized) + + if targetChars.count < queryChars.count { + return (0, []) + } + + let targetLower = Array(target.lowercased()) + let queryLower = Array(query.normalizedLowercase) + + return doScoreFuzzy( + query: queryChars, + queryLower: queryLower, + target: targetChars, + targetLower: targetLower, + allowNonContiguousMatches: allowNonContiguousMatches + ) + } + + func doScoreFuzzy( + query: [Character], + queryLower: [Character], + target: [Character], + targetLower: [Character], + allowNonContiguousMatches: Bool + ) -> (Int, [Int]) { + let queryLength = query.count + let targetLength = target.count + let scores = Array(repeating: 0, count: queryLength * targetLength) + var mutableScores = scores + let matches = Array(repeating: 0, count: queryLength * targetLength) + var mutableMatches = matches + + for queryIndex in 0.. 0 + + let queryCharAtIndex = query[queryIndex] + let queryLowerCharAtIndex = queryLower[queryIndex] + + for targetIndex in 0.. 0 + + let currentIndex = queryIndexOffset + targetIndex + let leftIndex = currentIndex - 1 + let diagIndex = queryIndexPreviousOffset + targetIndex - 1 + + let leftScore = targetIndexGtNull ? mutableScores[leftIndex] : 0 + let diagScore = queryIndexGtNull && targetIndexGtNull ? mutableScores[diagIndex] : 0 + + let matchesSequenceLength = + queryIndexGtNull && targetIndexGtNull ? mutableMatches[diagIndex] : 0 + + let score: Int + let scoreContext = CharScoreContext( + queryChar: queryCharAtIndex, + queryLowerChar: queryLowerCharAtIndex, + target: target, + targetLower: targetLower, + targetIndex: targetIndex, + matchesSequenceLength: matchesSequenceLength + ) + if diagScore != 0 && queryIndexGtNull { + score = computeCharScore(scoreContext) + } else if queryIndexGtNull { + score = 0 + } else { + score = computeCharScore(scoreContext) + } + + let isValidScore = score > 0 && diagScore + score >= leftScore + + if isValidScore + && (allowNonContiguousMatches || queryIndexGtNull + || startsWith( + targetLower, + queryLower, + at: targetIndex + )) + { + mutableMatches[currentIndex] = matchesSequenceLength + 1 + mutableScores[currentIndex] = diagScore + score + } else { + mutableMatches[currentIndex] = 0 + mutableScores[currentIndex] = leftScore + } + } + } + + var positions: [Int] = [] + var queryIndex = queryLength - 1 + var targetIndex = targetLength - 1 + while queryIndex >= 0 && targetIndex >= 0 { + let currentIndex = queryIndex * targetLength + targetIndex + let match = mutableMatches[currentIndex] + if match == 0 { + targetIndex -= 1 + } else { + positions.append(targetIndex) + queryIndex -= 1 + targetIndex -= 1 + } + } + + positions.reverse() + let finalScore = mutableScores[queryLength * targetLength - 1] + return (finalScore, positions) + } + + struct CharScoreContext { + let queryChar: Character + let queryLowerChar: Character + let target: [Character] + let targetLower: [Character] + let targetIndex: Int + let matchesSequenceLength: Int + } + + func computeCharScore(_ context: CharScoreContext) -> Int { + if !considerAsEqual(context.queryLowerChar, context.targetLower[context.targetIndex]) { + return 0 + } + + var score = 1 + + if context.matchesSequenceLength > 0 { + score += (min(context.matchesSequenceLength, 3) * 6) + score += max(0, context.matchesSequenceLength - 3) * 3 + } + + if context.queryChar == context.target[context.targetIndex] { + score += 1 + } + + if context.targetIndex == 0 { + score += 8 + } else { + let separatorBonus = scoreSeparatorAtPos(context.target[context.targetIndex - 1]) + if separatorBonus > 0 { + score += separatorBonus + } else if isUpper(context.target[context.targetIndex]) && context.matchesSequenceLength == 0 { + score += 2 + } + } + + return score + } + + func considerAsEqual(_ lhs: Character, _ rhs: Character) -> Bool { + if lhs == rhs { + return true + } + if lhs == "/" || lhs == "\\" { + return rhs == "/" || rhs == "\\" + } + return false + } + + func scoreSeparatorAtPos(_ char: Character) -> Int { + switch char { + case "/", "\\": + return 5 + case "_", "-", ".", " ", "'", "\"", ":": + return 4 + default: + return 0 + } + } + + func isUpper(_ char: Character) -> Bool { + guard let scalar = String(char).unicodeScalars.first else { return false } + return scalar.properties.isUppercase + } + + func startsWith( + _ target: [Character], + _ query: [Character], + at index: Int + ) -> Bool { + guard index + query.count <= target.count else { return false } + for queryIndex in 0.. [Match] { + var matches: [Match] = [] + var lastMatch: Match? + + for position in offsets { + if var lastMatch, lastMatch.end == position { + lastMatch.end += 1 + matches[matches.count - 1] = lastMatch + } else { + let match = Match(start: position, end: position + 1) + matches.append(match) + lastMatch = match + } + } + + return matches + } + + func normalizeMatches(_ matches: [Match]) -> [Match]? { + guard !matches.isEmpty else { return nil } + + let sortedMatches = matches.sorted { $0.start < $1.start } + var normalizedMatches: [Match] = [] + var currentMatch: Match? + + for match in sortedMatches { + if let existing = currentMatch, matchOverlaps(existing, match) { + let merged = Match( + start: min(existing.start, match.start), + end: max(existing.end, match.end) + ) + currentMatch = merged + normalizedMatches[normalizedMatches.count - 1] = merged + } else { + currentMatch = match + normalizedMatches.append(match) + } + } + + return normalizedMatches + } + + func matchOverlaps(_ matchA: Match, _ matchB: Match) -> Bool { + if matchA.end < matchB.start { + return false + } + if matchB.end < matchA.start { + return false + } + return true + } + + func matchesPrefix(query: String, target: String) -> [Match]? { + let targetLower = target.lowercased() + guard targetLower.hasPrefix(query) else { return nil } + return [Match(start: 0, end: query.count)] + } + + private static func prepareQuery(_ original: String) -> PreparedQuery { + let expectContiguousMatch = queryExpectsExactMatch(original) + let normalized = normalizeQuery(original) + let piece = PreparedQueryPiece( + normalized: normalized.normalized, + normalizedLowercase: normalized.normalizedLowercase, + expectContiguousMatch: expectContiguousMatch + ) + + let splitPieces = original.split(separator: " ") + var values: [PreparedQueryPiece] = [] + if splitPieces.count > 1 { + for pieceValue in splitPieces { + let value = String(pieceValue) + let expectExactMatchPiece = queryExpectsExactMatch(value) + let normalizedPiece = normalizeQuery(value) + if normalizedPiece.normalized.isEmpty { + continue + } + values.append( + PreparedQueryPiece( + normalized: normalizedPiece.normalized, + normalizedLowercase: normalizedPiece.normalizedLowercase, + expectContiguousMatch: expectExactMatchPiece + ) + ) + } + } + + return PreparedQuery( + piece: piece, + values: values.isEmpty ? nil : values + ) + } + + private static func normalizeQuery(_ original: String) -> (normalized: String, normalizedLowercase: String) { + var pathNormalized = String() + pathNormalized.reserveCapacity(original.count) + for char in original { + if char == "\\" { + pathNormalized.append("/") + } else { + pathNormalized.append(char) + } + } + + var normalized = String() + normalized.reserveCapacity(pathNormalized.count) + for char in pathNormalized { + if char == "*" || char == "…" || char == "\"" || char.isWhitespace { + continue + } + normalized.append(char) + } + + if normalized.count > 1, normalized.hasSuffix("#") { + normalized.removeLast() + } + + return (normalized, normalized.lowercased()) + } + + private static func queryExpectsExactMatch(_ query: String) -> Bool { + query.hasPrefix("\"") && query.hasSuffix("\"") + } +} diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift new file mode 100644 index 00000000..cd9deae3 --- /dev/null +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift @@ -0,0 +1,412 @@ +import Foundation +import Sharing + +enum CommandPaletteItemID { + static let ghosttyPrefix = "ghostty." + static let globalCheckForUpdates = "global.check-for-updates" + static let globalOpenSettings = "global.open-settings" + static let globalOpenRepository = "global.open-repository" + static let globalNewWorktree = "global.new-worktree" + static let globalRefreshWorktrees = "global.refresh-worktrees" + static let globalJumpToLatestUnread = "global.jump-to-latest-unread" + static let globalViewArchivedWorktrees = "global.view-archived-worktrees" + static let globalInstallCLI = "global.install-cli" + static let globalToggleLeftSidebar = "global.toggle-left-sidebar" + static let globalToggleActiveAgentsPanel = "global.toggle-active-agents-panel" + static let globalToggleCanvas = "global.toggle-canvas" + static let globalExpandCanvasCard = "global.expand-canvas-card" + static let globalArrangeCanvasCards = "global.arrange-canvas-cards" + static let globalOrganizeCanvasCards = "global.organize-canvas-cards" + static let globalSelectAllCanvasCards = "global.select-all-canvas-cards" + static let globalToggleShelf = "global.toggle-shelf" + static let globalShowDiff = "global.show-diff" + static let globalRevealInFinder = "global.reveal-in-finder" + static let globalCopyPath = "global.copy-path" + static let globalRevealInSidebar = "global.reveal-in-sidebar" + static let globalRunScript = "global.run-script" + static let globalStopRunScript = "global.stop-run-script" + static let globalTogglePinWorktree = "global.toggle-pin-worktree" + static let globalRenameBranch = "global.rename-branch" + static let globalDeleteWorktree = "global.delete-worktree" + + static func openRepositorySettings(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "repo.\(repositoryID).open-settings" + } + + static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { + "custom-command.\(commandID)" + } + + static var globalIDs: [CommandPaletteItem.ID] { + [ + globalCheckForUpdates, + globalOpenSettings, + globalOpenRepository, + globalNewWorktree, + globalRefreshWorktrees, + globalJumpToLatestUnread, + globalViewArchivedWorktrees, + globalInstallCLI, + globalToggleLeftSidebar, + globalToggleActiveAgentsPanel, + globalToggleCanvas, + globalExpandCanvasCard, + globalArrangeCanvasCards, + globalOrganizeCanvasCards, + globalSelectAllCanvasCards, + globalToggleShelf, + globalShowDiff, + globalRevealInFinder, + globalCopyPath, + globalRevealInSidebar, + globalRunScript, + globalStopRunScript, + globalTogglePinWorktree, + globalRenameBranch, + globalDeleteWorktree, + ] + } + + static func worktreeSelect(_ worktreeID: Worktree.ID) -> CommandPaletteItem.ID { + "worktree.\(worktreeID).select" + } + + static func changeFocusedTabIcon(_ worktreeID: Worktree.ID) -> CommandPaletteItem.ID { + "terminal.\(worktreeID).change-focused-tab-icon" + } + + static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem.ID { + "\(ghosttyPrefix)\(command.action)|\(command.title)" + } + + static func pullRequestIDs(repositoryID: Repository.ID) -> [CommandPaletteItem.ID] { + [ + pullRequestOpen(repositoryID), + pullRequestReady(repositoryID), + pullRequestCopyFailingJobURL(repositoryID), + pullRequestCopyCiLogs(repositoryID), + pullRequestRerunFailedJobs(repositoryID), + pullRequestOpenFailingCheck(repositoryID), + pullRequestMerge(repositoryID), + pullRequestClose(repositoryID), + ] + } + + static func pullRequestOpen(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).open" + } + + static func pullRequestReady(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).ready" + } + + static func pullRequestCopyFailingJobURL(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).copy-failing-job-url" + } + + static func pullRequestCopyCiLogs(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).copy-ci-logs" + } + + static func pullRequestRerunFailedJobs(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).rerun-failed-jobs" + } + + static func pullRequestOpenFailingCheck(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).open-failing-check" + } + + static func pullRequestMerge(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).merge" + } + + static func pullRequestClose(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "pr.\(repositoryID).close" + } +} + +func commandPaletteRecencyScore( + _ item: CommandPaletteItem, + recencyByID: [CommandPaletteItem.ID: TimeInterval], + now: Date +) -> Double { + guard let lastActivated = recencyByID[item.id] else { return 0 } + let ageSeconds = max(0, now.timeIntervalSince1970 - lastActivated) + let ageDays = ageSeconds / 86_400 + let cappedAgeDays = min(ageDays, 30) + return pow(0.5, cappedAgeDays / 7) +} + +func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate { + if let appAction = appDelegateAction(for: kind) { + return appAction + } + switch kind { + case .worktreeSelect(let id): + return .selectWorktree(id) + case .deleteWorktree(let worktreeID, let repositoryID): + return .deleteWorktree(worktreeID, repositoryID) + case .ghosttyCommand(let action): + return .ghosttyCommand(action) + case .changeFocusedTabIcon(let worktreeID): + return .changeFocusedTabIcon(worktreeID) + case .togglePinWorktree(let worktreeID, let isCurrentlyPinned): + return .togglePinWorktree(worktreeID, isCurrentlyPinned: isCurrentlyPinned) + case .openRepositorySettings(let repositoryID): + return .openRepositorySettings(repositoryID) + case .runCustomCommand(let index, _, _): + return .runCustomCommand(index) + case .openPullRequest, + .openRepositoryOnCodeHost, + .markPullRequestReady, + .mergePullRequest, + .closePullRequest, + .copyFailingJobURL, + .copyCiFailureLogs, + .rerunFailedJobs, + .openFailingCheckDetails: + return pullRequestDelegateAction(for: kind)! + #if DEBUG + case .debugTestToast(let toast): + return .debugTestToast(toast) + case .debugSimulateUpdateFound: + return .debugSimulateUpdateFound + case .debugLightDockNotificationDot: + return .debugLightDockNotificationDot + #endif + case .checkForUpdates, + .openSettings, + .newWorktree, + .openRepository, + .viewArchivedWorktrees, + .refreshWorktrees, + .jumpToLatestUnread, + .installCLI, + .toggleLeftSidebar, + .toggleActiveAgentsPanel, + .toggleCanvas, + .expandCanvasCard, + .arrangeCanvasCards, + .organizeCanvasCards, + .selectAllCanvasCards, + .toggleShelf, + .showDiff, + .revealInFinder, + .copyPath, + .revealInSidebar, + .runScript, + .stopRunScript, + .renameBranch: + fatalError("appDelegateAction should handle app-level command palette actions") + } +} + +func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + if let delegate = navigationDelegateAction(for: kind) { + return delegate + } + if let delegate = viewDelegateAction(for: kind) { + return delegate + } + switch kind { + case .checkForUpdates: + return .checkForUpdates + case .openSettings: + return .openSettings + case .newWorktree: + return .newWorktree + case .openRepository: + return .openRepository + case .viewArchivedWorktrees: + return .viewArchivedWorktrees + case .refreshWorktrees: + return .refreshWorktrees + case .jumpToLatestUnread: + return .jumpToLatestUnread + case .installCLI: + return .installCLI + case .runScript: + return .runScript + case .stopRunScript: + return .stopRunScript + case .renameBranch: + return .renameBranch + default: + return nil + } +} + +func navigationDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .revealInFinder: + return .revealInFinder + case .copyPath: + return .copyPath + case .revealInSidebar: + return .revealInSidebar + default: + return nil + } +} + +func viewDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .toggleLeftSidebar: + return .toggleLeftSidebar + case .toggleActiveAgentsPanel: + return .toggleActiveAgentsPanel + case .toggleCanvas: + return .toggleCanvas + case .expandCanvasCard: + return .expandCanvasCard + case .arrangeCanvasCards: + return .arrangeCanvasCards + case .organizeCanvasCards: + return .organizeCanvasCards + case .selectAllCanvasCards: + return .selectAllCanvasCards + case .toggleShelf: + return .toggleShelf + case .showDiff: + return .showDiff + default: + return nil + } +} + +func pullRequestDelegateAction( + for kind: CommandPaletteItem.Kind +) -> CommandPaletteFeature.Delegate? { + switch kind { + case .openPullRequest(let worktreeID), + .openRepositoryOnCodeHost(let worktreeID): + return .openPullRequest(worktreeID) + case .markPullRequestReady(let worktreeID): + return .markPullRequestReady(worktreeID) + case .mergePullRequest(let worktreeID): + return .mergePullRequest(worktreeID) + case .closePullRequest(let worktreeID): + return .closePullRequest(worktreeID) + case .copyFailingJobURL(let worktreeID): + return .copyFailingJobURL(worktreeID) + case .copyCiFailureLogs(let worktreeID): + return .copyCiFailureLogs(worktreeID) + case .rerunFailedJobs(let worktreeID): + return .rerunFailedJobs(worktreeID) + case .openFailingCheckDetails(let worktreeID): + return .openFailingCheckDetails(worktreeID) + case .worktreeSelect, + .checkForUpdates, + .openSettings, + .newWorktree, + .openRepository, + .viewArchivedWorktrees, + .refreshWorktrees, + .jumpToLatestUnread, + .installCLI, + .ghosttyCommand, + .changeFocusedTabIcon, + .toggleLeftSidebar, + .toggleActiveAgentsPanel, + .toggleCanvas, + .expandCanvasCard, + .arrangeCanvasCards, + .organizeCanvasCards, + .selectAllCanvasCards, + .toggleShelf, + .showDiff, + .revealInFinder, + .copyPath, + .revealInSidebar, + .runScript, + .stopRunScript, + .togglePinWorktree, + .renameBranch, + .deleteWorktree, + .openRepositorySettings, + .runCustomCommand: + return nil + #if DEBUG + case .debugTestToast, .debugSimulateUpdateFound, .debugLightDockNotificationDot: + return nil + #endif + } +} + +/// Ghostty action keys that should be hidden from Prowl's command palette. +/// +/// Includes: +/// - actions Prowl already exposes natively (`check_for_updates`) +/// - Ghostty actions intentionally unsupported by Prowl's architecture/platform +let filteredGhosttyActionKeys: Set = [ + "check_for_updates", + "new_window", + "close_all_windows", + "goto_window", + "toggle_tab_overview", + "toggle_window_decorations", + "inspector", + "show_gtk_inspector", + "show_on_screen_keyboard", +] + +func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { + commands.compactMap { command in + guard !filteredGhosttyActionKeys.contains(command.actionKey) else { return nil } + return .ghosttyCommand(command) + } +} + +extension CommandPaletteItem { + /// Build a top-level command backed by an `AppShortcuts` hotkey. Defaults to + /// `defaultSuggestion: true` (since these are the kinds of actions worth + /// listing when the palette opens with no query) and uses no subtitle (the + /// hotkey hint and title already do the work). + static func appShortcut( + id: String, + title: String, + category: Category, + kind: Kind, + keywords: [String] = [], + priorityTier: Int = defaultPriorityTier + ) -> CommandPaletteItem { + CommandPaletteItem( + id: id, + title: title, + subtitle: nil, + kind: kind, + category: category, + defaultSuggestion: true, + keywords: keywords, + priorityTier: priorityTier + ) + } + + /// Build an item wrapping a Ghostty command exposed via the runtime. + /// Lives in `.terminal`, ranks below regular suggestions (priority +100), + /// and is search-only (never surfaces on empty query). + static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem { + let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) + return CommandPaletteItem( + id: CommandPaletteItemID.ghosttyCommand(command), + title: command.title, + subtitle: subtitle.isEmpty ? nil : subtitle, + kind: .ghosttyCommand(command.action), + category: .terminal, + defaultSuggestion: false, + priorityTier: CommandPaletteItem.defaultPriorityTier + 100 + ) + } +} + +func loadRecency(into state: inout CommandPaletteFeature.State) { + @Shared(.appStorage("commandPaletteItemRecency")) var recency: [String: Double] = [:] + state.recencyByItemID = recency +} + +func saveRecency(_ recencyByItemID: [CommandPaletteItem.ID: TimeInterval]) { + @Shared(.appStorage("commandPaletteItemRecency")) var recency: [String: Double] = [:] + $recency.withLock { + $0 = recencyByItemID + } +} -- 2.51.2 From 1d03a4db91611f721922062d9ade1c52b9fb29a7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 01:50:33 +0900 Subject: [PATCH 333/528] Split AppFeature reducer handlers --- .../Reducer/AppFeature+CommandPalette.swift | 205 ++++++ .../App/Reducer/AppFeature+Support.swift | 261 ++++++++ .../Reducer/AppFeature+TerminalEvents.swift | 158 +++++ .../Features/App/Reducer/AppFeature.swift | 625 +----------------- 4 files changed, 640 insertions(+), 609 deletions(-) create mode 100644 supacode/Features/App/Reducer/AppFeature+CommandPalette.swift create mode 100644 supacode/Features/App/Reducer/AppFeature+Support.swift create mode 100644 supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift new file mode 100644 index 00000000..0e6424d1 --- /dev/null +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -0,0 +1,205 @@ +import AppKit +import ComposableArchitecture + +extension AppFeature { + func reduceCommandPaletteAction( + _ action: CommandPaletteFeature.Action, + state: inout State + ) -> Effect { + switch action { + case .setPresented(false): + guard state.commandPalette.isPresented else { return .none } + return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) + + case .togglePresented: + guard state.commandPalette.isPresented else { return .none } + return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) + + case .delegate(.selectWorktree(let worktreeID)): + if state.repositories.isShowingCanvas { + if state.repositories.worktree(for: worktreeID) == nil, + state.repositories.repositories[id: worktreeID]?.kind == .plain + { + return .send(.repositories(.focusCanvasRepository(worktreeID))) + } + return .send(.repositories(.focusCanvasWorktree(worktreeID))) + } + return .send(.repositories(.selectWorktree(worktreeID))) + + case .delegate(.checkForUpdates): + return .send(.updates(.checkForUpdates)) + + case .delegate(.openSettings): + return .merge( + .send(.settings(.setSelection(.general))), + .run { _ in + await settingsWindowClient.show() + } + ) + + case .delegate(.newWorktree): + return .send(.repositories(.worktreeCreation(.createRandomWorktree))) + + case .delegate(.openRepository): + return .send(.repositories(.setOpenPanelPresented(true))) + + case .delegate(.deleteWorktree(let worktreeID, let repositoryID)): + return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) + + case .delegate(.viewArchivedWorktrees): + return .send(.repositories(.selectArchivedWorktrees)) + + case .delegate(.refreshWorktrees): + return .send(.repositories(.refreshWorktrees)) + + case .delegate(.jumpToLatestUnread): + return .send(.jumpToLatestUnread) + + case .delegate(.installCLI): + return .send(.settings(.installCLIButtonTapped(showAlert: false))) + + case .delegate(.toggleLeftSidebar): + return .send(.toggleLeftSidebar) + + case .delegate(.toggleActiveAgentsPanel): + return .send(.repositories(.activeAgents(.togglePanelVisibility))) + + case .delegate(.toggleCanvas): + return .send(.repositories(.toggleCanvas)) + + case .delegate(.expandCanvasCard): + return .send(.repositories(.requestCanvasCommand(.toggleExpand))) + + case .delegate(.arrangeCanvasCards): + return .send(.repositories(.requestCanvasCommand(.arrange))) + + case .delegate(.organizeCanvasCards): + return .send(.repositories(.requestCanvasCommand(.organize))) + + case .delegate(.selectAllCanvasCards): + return .send(.repositories(.requestCanvasCommand(.selectAll))) + + case .delegate(.toggleShelf): + return .send(.repositories(.toggleShelf)) + + case .delegate(.showDiff): + guard let worktreeID = state.repositories.selectedWorktreeID, + let worktree = state.repositories.worktree(for: worktreeID) + else { + return .none + } + let keybindings = state.resolvedKeybindings + return .run { _ in + await MainActor.run { + DiffWindowManager.shared.show( + worktreeURL: worktree.workingDirectory, + branchName: worktree.name, + resolvedKeybindings: keybindings + ) + } + } + + case .delegate(.revealInFinder): + return .send(.openWorktree(.finder)) + + case .delegate(.copyPath): + guard let worktree = state.repositories.selectedTerminalWorktree else { + return .none + } + let path = worktree.workingDirectory.path + return .run { _ in + await MainActor.run { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } + } + + case .delegate(.revealInSidebar): + guard state.repositories.selectedWorktreeID != nil else { return .none } + return .merge( + .send(.showLeftSidebar), + .send(.repositories(.revealSelectedWorktreeInSidebar)) + ) + + case .delegate(.runScript): + return .send(.runScript) + + case .delegate(.stopRunScript): + return .send(.stopRunScript) + + case .delegate(.renameBranch): + guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } + return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) + + case .delegate(.openRepositorySettings(let repositoryID)): + // Reuse the existing repo-side flow so the repo-existence guard and + // settingsWindowClient.show() live in one place. + return .send(.repositories(.repositoryManagement(.openRepositorySettings(repositoryID)))) + + case .delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned)): + if isCurrentlyPinned { + return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) + } + return .send(.repositories(.worktreeOrdering(.pinWorktree(worktreeID)))) + + case .delegate(.runCustomCommand(let index)): + return .send(.runCustomCommand(index)) + + case .delegate(.ghosttyCommand(let action)): + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { + return .none + } + return .run { _ in + await terminalClient.send(.performBindingAction(worktree, action: action)) + } + + case .delegate(.changeFocusedTabIcon(let worktreeID)): + guard let worktree = state.repositories.selectedTerminalWorktree, + worktree.id == worktreeID + else { + return .none + } + return .run { _ in + await terminalClient.send(.presentTabIconPicker(worktree)) + } + + case .delegate(.openPullRequest(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnCodeHost)))) + + case .delegate(.markPullRequestReady(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .markReadyForReview)))) + + case .delegate(.mergePullRequest(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .merge)))) + + case .delegate(.closePullRequest(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .close)))) + + case .delegate(.copyFailingJobURL(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyFailingJobURL)))) + + case .delegate(.copyCiFailureLogs(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyCiFailureLogs)))) + + case .delegate(.rerunFailedJobs(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .rerunFailedJobs)))) + + case .delegate(.openFailingCheckDetails(let worktreeID)): + return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openFailingCheckDetails)))) + + #if DEBUG + case .delegate(.debugTestToast(let toast)): + return .send(.repositories(.showToast(toast))) + + case .delegate(.debugSimulateUpdateFound): + return .send(.updates(.debugSimulateUpdateFound)) + + case .delegate(.debugLightDockNotificationDot): + return .run { _ in await dockClient.setNotificationBadge(1) } + #endif + + default: + return .none + } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift new file mode 100644 index 00000000..1a64a789 --- /dev/null +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -0,0 +1,261 @@ +import ComposableArchitecture +import Foundation + +let appLogger = SupaLogger("App") +let notificationJumpLogger = SupaLogger("NotificationJump") + +enum CancelID { + static let periodicRefresh = "app.periodicRefresh" +} + +func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> [Worktree] { + var worktrees: [Worktree] = [] + worktrees.reserveCapacity(repositories.reduce(0) { $0 + max(1, $1.worktrees.count) }) + for repository in repositories { + if repository.capabilities.supportsWorktrees { + worktrees.append(contentsOf: repository.worktrees) + continue + } + if repository.capabilities.supportsRunnableFolderActions { + worktrees.append( + Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + ) + } + } + return worktrees +} + +func openedWorktreeIDsForInfoWatcher( + from repositories: RepositoriesFeature.State +) -> Set { + let watchedIDs = Set(repositories.worktreesForInfoWatcher().map(\.id)) + return repositories.openedWorktreeIDs.intersection(watchedIDs) +} + +extension AppFeature { + func appQuitProperties(launchedAt: Date?) -> [String: Any]? { + guard let seconds = Self.sessionDurationSeconds(launchedAt: launchedAt, now: now) else { return nil } + return ["session_duration_seconds": seconds] + } + + func restoreCommandPaletteTerminalFocusEffect(repositories: RepositoriesFeature.State) -> Effect { + guard let worktree = commandPaletteTerminalFocusTarget(repositories: repositories) else { return .none } + return .run { _ in + await terminalClient.send(.focusSelectedTab(worktree)) + } + } + + /// Delegate actions that intentionally shift focus elsewhere (selection + /// changes, view changes that swap the focused terminal). These skip the + /// post-delegate focus restore so we don't fight the natural new focus. + static func commandPaletteDelegateChangesActiveSelection( + _ delegate: CommandPaletteFeature.Delegate + ) -> Bool { + switch delegate { + case .selectWorktree, .jumpToLatestUnread, .viewArchivedWorktrees, + .newWorktree, .toggleCanvas, .renameBranch: + return true + default: + return false + } + } + + func commandPaletteTerminalFocusTarget(repositories: RepositoriesFeature.State) -> Worktree? { + if let worktree = repositories.selectedTerminalWorktree { + return worktree + } + return canvasFocusedTerminalWorktree(repositories: repositories) + } + + func actionTargetWorktree(repositories: RepositoriesFeature.State) -> Worktree? { + if let worktree = repositories.selectedTerminalWorktree { + return worktree + } + return canvasFocusedTerminalWorktree(repositories: repositories) + } + + func canvasFocusedTerminalWorktree(repositories: RepositoriesFeature.State) -> Worktree? { + guard repositories.isShowingCanvas, + let worktreeID = terminalClient.canvasFocusedWorktreeID() + else { + return nil + } + return terminalWorktree(for: worktreeID, repositories: repositories) + } + + func terminalWorktree( + for worktreeID: Worktree.ID, + repositories: RepositoriesFeature.State + ) -> Worktree? { + if let worktree = repositories.worktree(for: worktreeID) { + return worktree + } + guard let repository = repositories.repositories[id: worktreeID], + repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + else { + return nil + } + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + + static func sessionDurationSeconds(launchedAt: Date?, now: Date) -> Int? { + guard let launchedAt else { return nil } + return max(0, Int(now.timeIntervalSince(launchedAt))) + } + + func resolvedKeybindings( + settings: SettingsFeature.State, + customCommands: [UserCustomCommand] + ) -> ResolvedKeybindingMap { + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: customCommands) + var resolved = KeybindingResolver.resolve( + schema: .appResolverSchema(customCommands: customCommands), + userOverrides: settings.keybindingUserOverrides, + migratedOverrides: migration.overrides + ) + let customCommandIDs = customCommands.map { command in + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + } + let customCommandBindings = customCommandIDs.compactMap { resolved.keybinding(for: $0) } + guard !customCommandBindings.isEmpty else { + return resolved + } + for binding in AppShortcuts.bindings where binding.scope == .configurableAppAction { + guard let resolvedBinding = resolved.binding(for: binding.id), + let shortcut = resolvedBinding.binding, + customCommandBindings.contains(shortcut) + else { + continue + } + resolved.bindingsByCommandID[binding.id] = ResolvedKeybinding( + command: resolvedBinding.command, + binding: nil, + source: resolvedBinding.source + ) + } + return resolved + } + + /// Applies a worktree's repository settings (open action, run script) into + /// state. Shared by the normal `worktreeSettingsLoaded` action and the Canvas + /// focus path so both stay in sync. + func applyWorktreeSettings(_ settings: RepositorySettings, into state: inout State) { + @Shared(.settingsFile) var settingsFile + let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( + settingsFile.global.defaultEditorID + ) + state.openActionSelection = OpenWorktreeAction.fromSettingsID( + settings.openActionID, + defaultEditorID: normalizedDefaultEditorID + ) + state.selectedRunScript = settings.runScript + } + + /// Applies a worktree's user settings (custom commands, keybindings) into + /// state and returns the effect that re-registers custom shortcuts. Shared by + /// the normal `worktreeUserSettingsLoaded` action and the Canvas focus path. + func applyWorktreeUserSettings( + _ settings: UserRepositorySettings, + into state: inout State + ) -> Effect { + state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) + let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) + let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in + let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut + } + return .run { _ in + let logger = SupaLogger("Shortcuts") + for conflict in userOverrideConflicts { + logger.warning( + "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " + + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " + + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" + ) + } + await customShortcutRegistryClient.setShortcuts(shortcuts) + } + } + + func applyDefaultViewMode(into state: inout State) -> Effect { + guard !state.hasAppliedInitialViewMode else { return .none } + state.hasAppliedInitialViewMode = true + + @Shared(.settingsFile) var settingsFile + let shouldEnterShelf = + settingsFile.global.defaultViewMode == .shelf + && !state.repositories.isShelfActive + let shouldEnterCanvas = + settingsFile.global.defaultViewMode == .canvas + && !state.repositories.isShowingCanvas + + var effects: [Effect] = [] + if shouldEnterShelf { + effects.append(.send(.repositories(.toggleShelf))) + } + // Enter Canvas after the selection effects so `.selectCanvas` + // records the just-selected worktree as the pre-Canvas anchor. + if shouldEnterCanvas { + if state.repositories.selectedTerminalWorktree == nil, + let targetID = defaultViewFallbackSelectionID(in: state.repositories) + { + effects.append(defaultViewSelectionEffect(for: targetID, repositories: state.repositories)) + } + effects.append(.send(.repositories(.toggleCanvas))) + } + return effects.isEmpty ? .none : .concatenate(effects) + } + + func defaultViewFallbackSelectionID(in repositories: RepositoriesFeature.State) -> Worktree.ID? { + let candidateIDs = + [repositories.lastFocusedWorktreeID].compactMap(\.self) + + repositories.orderedWorktreeRows().map(\.id) + return candidateIDs.first { id in + repositories.worktree(for: id) != nil + || repositories.repositories[id: id]?.kind == .plain + } + } + + func defaultViewSelectionEffect( + for targetID: Worktree.ID, + repositories: RepositoriesFeature.State + ) -> Effect { + if repositories.worktree(for: targetID) == nil, + repositories.repositories[id: targetID]?.kind == .plain + { + return .send(.repositories(.selectRepository(targetID))) + } + return .send(.repositories(.selectWorktree(targetID))) + } +} + +// Renders Custom Command run duration for status toasts. +// Sub-second runs show ms; short runs show one decimal; long runs reuse the +// whole-seconds formatter used by other command-finished notifications. +func formatCustomCommandDuration(_ durationMs: Int) -> String { + if durationMs < 1_000 { + return "\(max(durationMs, 0))ms" + } + let seconds = Double(durationMs) / 1_000.0 + if seconds < 10 { + return String(format: "%.1fs", seconds) + } + return WorktreeTerminalState.formatDuration(Int(seconds)) +} diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift new file mode 100644 index 00000000..4fce045b --- /dev/null +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -0,0 +1,158 @@ +import ComposableArchitecture + +extension AppFeature { + func reduceTerminalEvent( + _ event: TerminalClient.Event, + state: inout State + ) -> Effect { + switch event { + case .customCommandSucceeded(_, let name, let durationMs): + let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" + return .send(.repositories(.showToast(.success(message)))) + + case .notificationReceived(let worktreeID, let surfaceID, let title, let body): + var effects: [Effect] = [ + .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) + ] + if state.settings.systemNotificationsEnabled { + effects.append( + .run { _ in + await systemNotificationClient.send(title, body, worktreeID, surfaceID) + } + ) + } + if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { + effects.append( + .run { _ in + await notificationSoundClient.play() + } + ) + } + let bounceMode = state.settings.dockBounceMode + if bounceMode != .off { + effects.append( + .run { _ in + await dockClient.bounce(bounceMode) + } + ) + } + return .merge(effects) + + case .notificationIndicatorChanged(let count): + state.notificationIndicatorCount = count + let badgeCount = state.settings.showNotificationDotOnDock ? count : 0 + return .run { _ in + await dockClient.setNotificationBadge(badgeCount) + } + + case .runScriptStatusChanged(let worktreeID, let isRunning): + if isRunning { + state.runScriptStatusByWorktreeID[worktreeID] = true + } else { + state.runScriptStatusByWorktreeID.removeValue(forKey: worktreeID) + } + return .none + + case .agentEntryChanged(let entry): + return .send( + .repositories( + .activeAgents( + .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) + ) + ) + ) + + case .agentEntryRemoved(let id): + return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) + + case .commandPaletteToggleRequested(let worktreeID): + if state.commandPalette.isPresented { + return .send(.commandPalette(.setPresented(false))) + } + if state.repositories.worktree(for: worktreeID) != nil { + return .merge( + .send(.repositories(.selectWorktree(worktreeID))), + .send(.commandPalette(.setPresented(true))) + ) + } + if state.repositories.repositories[id: worktreeID]?.kind == .plain { + return .merge( + .send(.repositories(.selectRepository(worktreeID))), + .send(.commandPalette(.setPresented(true))) + ) + } + return .send(.commandPalette(.setPresented(true))) + + case .setupScriptConsumed(let worktreeID): + return .send(.repositories(.worktreeCreation(.consumeSetupScript(worktreeID)))) + + case .fontSizeChanged(let fontSize): + return .send(.settings(.setTerminalFontSize(fontSize))) + + case .layoutRestored(let selectedWorktreeID): + appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") + // Layout restore has settled: tabs are re-created, selection is set. + // Now apply the default view preference, which was deferred in + // `repositoriesChanged` (via `shouldDeferDefaultView`) to avoid + // stray spines and a selection flash. + var effects: [Effect] = [] + if let selectedWorktreeID { + // Plain folders use .repository selection, not .worktree + if let repo = state.repositories.repositories[id: selectedWorktreeID], + repo.kind == .plain + { + effects.append(.send(.repositories(.selectRepository(selectedWorktreeID)))) + } else { + effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) + } + } + return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) + + case .layoutRestoreFailed(let message): + appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") + return .merge( + .send(.repositories(.showToast(.warning(message)))), + applyDefaultViewMode(into: &state) + ) + + case .tabCreated(let worktreeID): + // Every tab creation (user +, CLI open, layout restore, ...) + // marks its worktree as Shelf-visible. Layout restore in + // particular only calls `selectWorktree` for the one active + // worktree; other restored worktrees only surface here. + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + if state.repositories.worktree(for: worktreeID) != nil { + openedWorktreeIDs.insert(worktreeID) + } + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeOpened(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) + + case .tabClosed(let worktreeID, let remainingTabs): + // Closing the last tab retires the book from the Shelf. Other + // closes are routine and need no Reducer-side bookkeeping. + guard remainingTabs == 0 else { return .none } + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + openedWorktreeIDs.remove(worktreeID) + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeClosed(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) + + case .focusChanged(_, let surfaceID): + // Keep the Active Agents panel's keyboard-navigation anchor in sync with + // the surface that actually has focus, so control-option-up/down steps from the right place. + return .send(.repositories(.activeAgents(.focusedSurfaceChanged(surfaceID)))) + + default: + return .none + } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 5b85ec15..7674c999 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -4,43 +4,6 @@ import Foundation import PostHog import SwiftUI -private let appLogger = SupaLogger("App") -private let notificationJumpLogger = SupaLogger("NotificationJump") - -private enum CancelID { - static let periodicRefresh = "app.periodicRefresh" -} - -private func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> [Worktree] { - var worktrees: [Worktree] = [] - worktrees.reserveCapacity(repositories.reduce(0) { $0 + max(1, $1.worktrees.count) }) - for repository in repositories { - if repository.capabilities.supportsWorktrees { - worktrees.append(contentsOf: repository.worktrees) - continue - } - if repository.capabilities.supportsRunnableFolderActions { - worktrees.append( - Worktree( - id: repository.id, - name: repository.name, - detail: repository.rootURL.path(percentEncoded: false), - workingDirectory: repository.rootURL, - repositoryRootURL: repository.rootURL - ) - ) - } - } - return worktrees -} - -private func openedWorktreeIDsForInfoWatcher( - from repositories: RepositoriesFeature.State -) -> Set { - let watchedIDs = Set(repositories.worktreesForInfoWatcher().map(\.id)) - return repositories.openedWorktreeIDs.intersection(watchedIDs) -} - @Reducer struct AppFeature { @ObservableState @@ -122,224 +85,18 @@ struct AppFeature { case confirmQuit } - @Dependency(AnalyticsClient.self) private var analyticsClient - @Dependency(\.date.now) private var now - @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence - @Dependency(WorkspaceClient.self) private var workspaceClient - @Dependency(SettingsWindowClient.self) private var settingsWindowClient - @Dependency(AppLifecycleClient.self) private var appLifecycleClient - @Dependency(NotificationSoundClient.self) private var notificationSoundClient - @Dependency(SystemNotificationClient.self) private var systemNotificationClient - @Dependency(DockClient.self) private var dockClient - @Dependency(TerminalClient.self) private var terminalClient - @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher - @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient - - private func appQuitProperties(launchedAt: Date?) -> [String: Any]? { - guard let seconds = Self.sessionDurationSeconds(launchedAt: launchedAt, now: now) else { return nil } - return ["session_duration_seconds": seconds] - } - - private func restoreCommandPaletteTerminalFocusEffect(repositories: RepositoriesFeature.State) -> Effect { - guard let worktree = commandPaletteTerminalFocusTarget(repositories: repositories) else { return .none } - return .run { _ in - await terminalClient.send(.focusSelectedTab(worktree)) - } - } - - /// Delegate actions that intentionally shift focus elsewhere (selection - /// changes, view changes that swap the focused terminal). These skip the - /// post-delegate focus restore so we don't fight the natural new focus. - private static func commandPaletteDelegateChangesActiveSelection( - _ delegate: CommandPaletteFeature.Delegate - ) -> Bool { - switch delegate { - case .selectWorktree, .jumpToLatestUnread, .viewArchivedWorktrees, - .newWorktree, .toggleCanvas, .renameBranch: - return true - default: - return false - } - } - - private func commandPaletteTerminalFocusTarget(repositories: RepositoriesFeature.State) -> Worktree? { - if let worktree = repositories.selectedTerminalWorktree { - return worktree - } - return canvasFocusedTerminalWorktree(repositories: repositories) - } - - private func actionTargetWorktree(repositories: RepositoriesFeature.State) -> Worktree? { - if let worktree = repositories.selectedTerminalWorktree { - return worktree - } - return canvasFocusedTerminalWorktree(repositories: repositories) - } - - private func canvasFocusedTerminalWorktree(repositories: RepositoriesFeature.State) -> Worktree? { - guard repositories.isShowingCanvas, - let worktreeID = terminalClient.canvasFocusedWorktreeID() - else { - return nil - } - return terminalWorktree(for: worktreeID, repositories: repositories) - } - - private func terminalWorktree( - for worktreeID: Worktree.ID, - repositories: RepositoriesFeature.State - ) -> Worktree? { - if let worktree = repositories.worktree(for: worktreeID) { - return worktree - } - guard let repository = repositories.repositories[id: worktreeID], - repository.capabilities.supportsRunnableFolderActions, - !repository.capabilities.supportsWorktrees - else { - return nil - } - return Worktree( - id: repository.id, - name: repository.name, - detail: repository.rootURL.path(percentEncoded: false), - workingDirectory: repository.rootURL, - repositoryRootURL: repository.rootURL - ) - } - - static func sessionDurationSeconds(launchedAt: Date?, now: Date) -> Int? { - guard let launchedAt else { return nil } - return max(0, Int(now.timeIntervalSince(launchedAt))) - } - - private func resolvedKeybindings( - settings: SettingsFeature.State, - customCommands: [UserCustomCommand] - ) -> ResolvedKeybindingMap { - let migration = LegacyCustomCommandShortcutMigration.migrate(commands: customCommands) - var resolved = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: customCommands), - userOverrides: settings.keybindingUserOverrides, - migratedOverrides: migration.overrides - ) - let customCommandIDs = customCommands.map { command in - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - } - let customCommandBindings = customCommandIDs.compactMap { resolved.keybinding(for: $0) } - guard !customCommandBindings.isEmpty else { - return resolved - } - for binding in AppShortcuts.bindings where binding.scope == .configurableAppAction { - guard let resolvedBinding = resolved.binding(for: binding.id), - let shortcut = resolvedBinding.binding, - customCommandBindings.contains(shortcut) - else { - continue - } - resolved.bindingsByCommandID[binding.id] = ResolvedKeybinding( - command: resolvedBinding.command, - binding: nil, - source: resolvedBinding.source - ) - } - return resolved - } - - /// Applies a worktree's repository settings (open action, run script) into - /// state. Shared by the normal `worktreeSettingsLoaded` action and the Canvas - /// focus path so both stay in sync. - private func applyWorktreeSettings(_ settings: RepositorySettings, into state: inout State) { - @Shared(.settingsFile) var settingsFile - let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( - settingsFile.global.defaultEditorID - ) - state.openActionSelection = OpenWorktreeAction.fromSettingsID( - settings.openActionID, - defaultEditorID: normalizedDefaultEditorID - ) - state.selectedRunScript = settings.runScript - } - - /// Applies a worktree's user settings (custom commands, keybindings) into - /// state and returns the effect that re-registers custom shortcuts. Shared by - /// the normal `worktreeUserSettingsLoaded` action and the Canvas focus path. - private func applyWorktreeUserSettings( - _ settings: UserRepositorySettings, - into state: inout State - ) -> Effect { - state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) - state.resolvedKeybindings = resolvedKeybindings( - settings: state.settings, - customCommands: state.selectedCustomCommands - ) - let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) - let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in - let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut - } - return .run { _ in - let logger = SupaLogger("Shortcuts") - for conflict in userOverrideConflicts { - logger.warning( - "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " - + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " - + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" - ) - } - await customShortcutRegistryClient.setShortcuts(shortcuts) - } - } - - private func applyDefaultViewMode(into state: inout State) -> Effect { - guard !state.hasAppliedInitialViewMode else { return .none } - state.hasAppliedInitialViewMode = true - - @Shared(.settingsFile) var settingsFile - let shouldEnterShelf = - settingsFile.global.defaultViewMode == .shelf - && !state.repositories.isShelfActive - let shouldEnterCanvas = - settingsFile.global.defaultViewMode == .canvas - && !state.repositories.isShowingCanvas - - var effects: [Effect] = [] - if shouldEnterShelf { - effects.append(.send(.repositories(.toggleShelf))) - } - // Enter Canvas after the selection effects so `.selectCanvas` - // records the just-selected worktree as the pre-Canvas anchor. - if shouldEnterCanvas { - if state.repositories.selectedTerminalWorktree == nil, - let targetID = defaultViewFallbackSelectionID(in: state.repositories) - { - effects.append(defaultViewSelectionEffect(for: targetID, repositories: state.repositories)) - } - effects.append(.send(.repositories(.toggleCanvas))) - } - return effects.isEmpty ? .none : .concatenate(effects) - } - - private func defaultViewFallbackSelectionID(in repositories: RepositoriesFeature.State) -> Worktree.ID? { - let candidateIDs = - [repositories.lastFocusedWorktreeID].compactMap(\.self) - + repositories.orderedWorktreeRows().map(\.id) - return candidateIDs.first { id in - repositories.worktree(for: id) != nil - || repositories.repositories[id: id]?.kind == .plain - } - } - - private func defaultViewSelectionEffect( - for targetID: Worktree.ID, - repositories: RepositoriesFeature.State - ) -> Effect { - if repositories.worktree(for: targetID) == nil, - repositories.repositories[id: targetID]?.kind == .plain - { - return .send(.repositories(.selectRepository(targetID))) - } - return .send(.repositories(.selectWorktree(targetID))) - } + @Dependency(AnalyticsClient.self) var analyticsClient + @Dependency(\.date.now) var now + @Dependency(RepositoryPersistenceClient.self) var repositoryPersistence + @Dependency(WorkspaceClient.self) var workspaceClient + @Dependency(SettingsWindowClient.self) var settingsWindowClient + @Dependency(AppLifecycleClient.self) var appLifecycleClient + @Dependency(NotificationSoundClient.self) var notificationSoundClient + @Dependency(SystemNotificationClient.self) var systemNotificationClient + @Dependency(DockClient.self) var dockClient + @Dependency(TerminalClient.self) var terminalClient + @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher + @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient var body: some Reducer { let core = Reduce { state, action in @@ -1147,347 +904,11 @@ struct AppFeature { case .updates: return .none - case .commandPalette(.setPresented(false)): - guard state.commandPalette.isPresented else { return .none } - return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - - case .commandPalette(.togglePresented): - guard state.commandPalette.isPresented else { return .none } - return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - - case .commandPalette(.delegate(.selectWorktree(let worktreeID))): - if state.repositories.isShowingCanvas { - if state.repositories.worktree(for: worktreeID) == nil, - state.repositories.repositories[id: worktreeID]?.kind == .plain - { - return .send(.repositories(.focusCanvasRepository(worktreeID))) - } - return .send(.repositories(.focusCanvasWorktree(worktreeID))) - } - return .send(.repositories(.selectWorktree(worktreeID))) - - case .commandPalette(.delegate(.checkForUpdates)): - return .send(.updates(.checkForUpdates)) - - case .commandPalette(.delegate(.openSettings)): - return .merge( - .send(.settings(.setSelection(.general))), - .run { _ in - await settingsWindowClient.show() - } - ) - - case .commandPalette(.delegate(.newWorktree)): - return .send(.repositories(.worktreeCreation(.createRandomWorktree))) - - case .commandPalette(.delegate(.openRepository)): - return .send(.repositories(.setOpenPanelPresented(true))) - - case .commandPalette(.delegate(.deleteWorktree(let worktreeID, let repositoryID))): - return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) - - case .commandPalette(.delegate(.viewArchivedWorktrees)): - return .send(.repositories(.selectArchivedWorktrees)) - - case .commandPalette(.delegate(.refreshWorktrees)): - return .send(.repositories(.refreshWorktrees)) - - case .commandPalette(.delegate(.jumpToLatestUnread)): - return .send(.jumpToLatestUnread) - - case .commandPalette(.delegate(.installCLI)): - return .send(.settings(.installCLIButtonTapped(showAlert: false))) - - case .commandPalette(.delegate(.toggleLeftSidebar)): - return .send(.toggleLeftSidebar) - - case .commandPalette(.delegate(.toggleActiveAgentsPanel)): - return .send(.repositories(.activeAgents(.togglePanelVisibility))) - - case .commandPalette(.delegate(.toggleCanvas)): - return .send(.repositories(.toggleCanvas)) - - case .commandPalette(.delegate(.expandCanvasCard)): - return .send(.repositories(.requestCanvasCommand(.toggleExpand))) - - case .commandPalette(.delegate(.arrangeCanvasCards)): - return .send(.repositories(.requestCanvasCommand(.arrange))) - - case .commandPalette(.delegate(.organizeCanvasCards)): - return .send(.repositories(.requestCanvasCommand(.organize))) - - case .commandPalette(.delegate(.selectAllCanvasCards)): - return .send(.repositories(.requestCanvasCommand(.selectAll))) - - case .commandPalette(.delegate(.toggleShelf)): - return .send(.repositories(.toggleShelf)) - - case .commandPalette(.delegate(.showDiff)): - guard let worktreeID = state.repositories.selectedWorktreeID, - let worktree = state.repositories.worktree(for: worktreeID) - else { - return .none - } - let keybindings = state.resolvedKeybindings - return .run { _ in - await MainActor.run { - DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - resolvedKeybindings: keybindings - ) - } - } - - case .commandPalette(.delegate(.revealInFinder)): - return .send(.openWorktree(.finder)) - - case .commandPalette(.delegate(.copyPath)): - guard let worktree = state.repositories.selectedTerminalWorktree else { - return .none - } - let path = worktree.workingDirectory.path - return .run { _ in - await MainActor.run { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(path, forType: .string) - } - } - - case .commandPalette(.delegate(.revealInSidebar)): - guard state.repositories.selectedWorktreeID != nil else { return .none } - return .merge( - .send(.showLeftSidebar), - .send(.repositories(.revealSelectedWorktreeInSidebar)) - ) - - case .commandPalette(.delegate(.runScript)): - return .send(.runScript) - - case .commandPalette(.delegate(.stopRunScript)): - return .send(.stopRunScript) - - case .commandPalette(.delegate(.renameBranch)): - guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } - return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) - - case .commandPalette(.delegate(.openRepositorySettings(let repositoryID))): - // Reuse the existing repo-side flow so the repo-existence guard and - // settingsWindowClient.show() live in one place - // (.repositories(.delegate(.openRepositorySettings(_))) at line ~441). - return .send(.repositories(.repositoryManagement(.openRepositorySettings(repositoryID)))) - - case .commandPalette(.delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned))): - if isCurrentlyPinned { - return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) - } - return .send(.repositories(.worktreeOrdering(.pinWorktree(worktreeID)))) - - case .commandPalette(.delegate(.runCustomCommand(let index))): - return .send(.runCustomCommand(index)) - - case .commandPalette(.delegate(.ghosttyCommand(let action))): - guard let worktree = actionTargetWorktree(repositories: state.repositories) else { - return .none - } - return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: action)) - } - - case .commandPalette(.delegate(.changeFocusedTabIcon(let worktreeID))): - guard let worktree = state.repositories.selectedTerminalWorktree, - worktree.id == worktreeID - else { - return .none - } - return .run { _ in - await terminalClient.send(.presentTabIconPicker(worktree)) - } - - case .commandPalette(.delegate(.openPullRequest(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnCodeHost)))) - - case .commandPalette(.delegate(.markPullRequestReady(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .markReadyForReview)))) - - case .commandPalette(.delegate(.mergePullRequest(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .merge)))) - - case .commandPalette(.delegate(.closePullRequest(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .close)))) - - case .commandPalette(.delegate(.copyFailingJobURL(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyFailingJobURL)))) - - case .commandPalette(.delegate(.copyCiFailureLogs(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyCiFailureLogs)))) - - case .commandPalette(.delegate(.rerunFailedJobs(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .rerunFailedJobs)))) - - case .commandPalette(.delegate(.openFailingCheckDetails(let worktreeID))): - return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openFailingCheckDetails)))) - - #if DEBUG - case .commandPalette(.delegate(.debugTestToast(let toast))): - return .send(.repositories(.showToast(toast))) - - case .commandPalette(.delegate(.debugSimulateUpdateFound)): - return .send(.updates(.debugSimulateUpdateFound)) - - case .commandPalette(.delegate(.debugLightDockNotificationDot)): - return .run { _ in await dockClient.setNotificationBadge(1) } - #endif - - case .commandPalette: - return .none - - case .terminalEvent(.customCommandSucceeded(_, let name, let durationMs)): - let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" - return .send(.repositories(.showToast(.success(message)))) + case .commandPalette(let action): + return reduceCommandPaletteAction(action, state: &state) - case .terminalEvent(.notificationReceived(let worktreeID, let surfaceID, let title, let body)): - var effects: [Effect] = [ - .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) - ] - if state.settings.systemNotificationsEnabled { - effects.append( - .run { _ in - await systemNotificationClient.send(title, body, worktreeID, surfaceID) - } - ) - } - if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { - effects.append( - .run { _ in - await notificationSoundClient.play() - } - ) - } - let bounceMode = state.settings.dockBounceMode - if bounceMode != .off { - effects.append( - .run { _ in - await dockClient.bounce(bounceMode) - } - ) - } - return .merge(effects) - - case .terminalEvent(.notificationIndicatorChanged(let count)): - state.notificationIndicatorCount = count - let badgeCount = state.settings.showNotificationDotOnDock ? count : 0 - return .run { _ in - await dockClient.setNotificationBadge(badgeCount) - } - - case .terminalEvent(.runScriptStatusChanged(let worktreeID, let isRunning)): - if isRunning { - state.runScriptStatusByWorktreeID[worktreeID] = true - } else { - state.runScriptStatusByWorktreeID.removeValue(forKey: worktreeID) - } - return .none - - case .terminalEvent(.agentEntryChanged(let entry)): - return .send( - .repositories( - .activeAgents( - .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) - ) - ) - ) - - case .terminalEvent(.agentEntryRemoved(let id)): - return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) - - case .terminalEvent(.commandPaletteToggleRequested(let worktreeID)): - if state.commandPalette.isPresented { - return .send(.commandPalette(.setPresented(false))) - } - if state.repositories.worktree(for: worktreeID) != nil { - return .merge( - .send(.repositories(.selectWorktree(worktreeID))), - .send(.commandPalette(.setPresented(true))) - ) - } - if state.repositories.repositories[id: worktreeID]?.kind == .plain { - return .merge( - .send(.repositories(.selectRepository(worktreeID))), - .send(.commandPalette(.setPresented(true))) - ) - } - return .send(.commandPalette(.setPresented(true))) - case .terminalEvent(.setupScriptConsumed(let worktreeID)): - return .send(.repositories(.worktreeCreation(.consumeSetupScript(worktreeID)))) - - case .terminalEvent(.fontSizeChanged(let fontSize)): - return .send(.settings(.setTerminalFontSize(fontSize))) - - case .terminalEvent(.layoutRestored(let selectedWorktreeID)): - appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") - // Layout restore has settled: tabs are re-created, selection is set. - // Now apply the default view preference, which was deferred in - // `repositoriesChanged` (via `shouldDeferDefaultView`) to avoid - // stray spines and a selection flash. - var effects: [Effect] = [] - if let selectedWorktreeID { - // Plain folders use .repository selection, not .worktree - if let repo = state.repositories.repositories[id: selectedWorktreeID], - repo.kind == .plain - { - effects.append(.send(.repositories(.selectRepository(selectedWorktreeID)))) - } else { - effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) - } - } - return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) - - case .terminalEvent(.layoutRestoreFailed(let message)): - appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") - return .merge( - .send(.repositories(.showToast(.warning(message)))), - applyDefaultViewMode(into: &state) - ) - - case .terminalEvent(.tabCreated(let worktreeID)): - // Every tab creation (user +, CLI open, layout restore, …) - // marks its worktree as Shelf-visible. Layout restore in - // particular only calls `selectWorktree` for the one active - // worktree; other restored worktrees only surface here. - var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) - if state.repositories.worktree(for: worktreeID) != nil { - openedWorktreeIDs.insert(worktreeID) - } - let syncedOpenedWorktreeIDs = openedWorktreeIDs - return .merge( - .send(.repositories(.markWorktreeOpened(worktreeID))), - .run { _ in - await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) - } - ) - - case .terminalEvent(.tabClosed(let worktreeID, let remainingTabs)): - // Closing the last tab retires the book from the Shelf. Other - // closes are routine and need no Reducer-side bookkeeping. - guard remainingTabs == 0 else { return .none } - var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) - openedWorktreeIDs.remove(worktreeID) - let syncedOpenedWorktreeIDs = openedWorktreeIDs - return .merge( - .send(.repositories(.markWorktreeClosed(worktreeID))), - .run { _ in - await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) - } - ) - - case .terminalEvent(.focusChanged(_, let surfaceID)): - // Keep the Active Agents panel's keyboard-navigation anchor in sync with - // the surface that actually has focus, so ⌃⌥↑/↓ steps from the right place. - return .send(.repositories(.activeAgents(.focusedSurfaceChanged(surfaceID)))) - - case .terminalEvent: - return .none + case .terminalEvent(let event): + return reduceTerminalEvent(event, state: &state) } } core @@ -1517,17 +938,3 @@ struct AppFeature { } } } - -// Renders Custom Command run duration for status toasts. -// Sub-second runs show ms; short runs show one decimal; long runs reuse the -// whole-seconds formatter used by other command-finished notifications. -func formatCustomCommandDuration(_ durationMs: Int) -> String { - if durationMs < 1_000 { - return "\(max(durationMs, 0))ms" - } - let seconds = Double(durationMs) / 1_000.0 - if seconds < 10 { - return String(format: "%.1fs", seconds) - } - return WorktreeTerminalState.formatDuration(Int(seconds)) -} -- 2.51.2 From b66343729da52d90eafcea34333e443171fe87ac Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 02:08:21 +0900 Subject: [PATCH 334/528] Split worktree terminal state helpers --- ...WorktreeTerminalState+AgentDetection.swift | 254 +++ .../Models/WorktreeTerminalState+CLI.swift | 130 ++ ...WorktreeTerminalState+LayoutSnapshot.swift | 289 +++ .../WorktreeTerminalState+Notifications.swift | 157 ++ .../WorktreeTerminalState+Surfaces.swift | 868 ++++++++ .../WorktreeTerminalState+TabIcons.swift | 98 + .../Models/WorktreeTerminalState.swift | 1847 +---------------- 7 files changed, 1833 insertions(+), 1810 deletions(-) create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift create mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState+TabIcons.swift diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift new file mode 100644 index 00000000..431c3a58 --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -0,0 +1,254 @@ +import Foundation +import GhosttyKit + +extension WorktreeTerminalState { + func setAgentDetectionEnabled(_ enabled: Bool) { + guard agentDetectionEnabled != enabled else { return } + agentDetectionEnabled = enabled + + if enabled { + for (surfaceID, view) in surfaces { + guard let tabId = tabId(containing: surfaceID) else { continue } + startAgentDetection(for: view, tabId: tabId) + } + } else { + cleanupAllAgentDetectionState() + } + } + + func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { + guard agentDetectionEnabled else { return } + agentDetectionTasks[view.id]?.cancel() + surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) + agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in + while !Task.isCancelled { + guard let self, let view, self.surfaces[view.id] != nil else { return } + await self.detectAgentState(for: view, tabId: tabId) + let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil + try? await Task.sleep(for: hasAgent ? activeAgentDetectionInterval : idleAgentDetectionInterval) + } + } + } + + func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async { + let surfaceID = view.id + let childPID = view.bridge.childPID() + let processGroupID = view.bridge.foregroundProcessGroupID() + let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) + guard surfaces[surfaceID] != nil else { return } + + let identified = job.flatMap { identifyAgentInJob($0) } + let probedAgent = identified?.agent + + var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() + let agent = presence.update(detectedAgent: probedAgent) + agentDetectionPresenceBySurface[surfaceID] = presence + + guard let agent else { + // Only log the moment we lose a previously detected agent; pre-agent + // shells churn process lists every command and would otherwise spam. + if surfaceAgentStates[surfaceID]?.detectedAgent != nil { + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + processGroupID: processGroupID, + job: job, + identified: identified, + retainedAgent: nil, + raw: nil, + stabilized: nil + ) + ) + } + removeAgentEntryIfNeeded(surfaceID: surfaceID) + return + } + + let now = Date() + let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) + let activeText = view.bridge.readActiveText() ?? "" + // `detectState` is a `nonisolated` pure function that runs in well under a + // millisecond on a terminal-sized active screen, so the prior `Task.detached` hop + // bought nothing but allocator churn. In long sessions, each detection + // tick (300 ms or 2 s per surface) was leaving a task stack + closure + // capture behind that never reached ARC; over a 24 h session this added + // up to hundreds of MB of unreferenced allocations. + let raw = agent.detectState(in: activeText) + guard surfaces[surfaceID] != nil else { return } + + var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] + let stabilized = stabilizeAgentState( + agent: agent, + previous: previous.state, + raw: raw, + now: now, + lastClaudeWorkingAt: &lastClaudeWorkingAt + ) + lastClaudeWorkingAtBySurface[surfaceID] = lastClaudeWorkingAt + + let isForeground = isSelected() && isFocusedSurface(surfaceID) + let becameIdleFromActive = + (previous.state == .working || previous.state == .blocked) + && stabilized == .idle + let seen: Bool + if isForeground || stabilized == .blocked { + seen = true + } else if becameIdleFromActive { + seen = false + } else { + seen = previous.seen + } + let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt + let next = PaneAgentState( + detectedAgent: agent, + fallbackState: raw, + state: stabilized, + seen: seen, + lastChangedAt: lastChangedAt + ) + // Limit logging to meaningful transitions - agent identity or + // stabilized state changes. Raw oscillation and `seen` flips are + // routine and would otherwise dominate the log stream. + if previous.detectedAgent != agent || previous.state != stabilized { + logAgentDetectionDiagnostic( + surfaceID: surfaceID, + diagnostic: AgentDetectionDiagnostic( + tabId: tabId, + childPID: childPID, + processGroupID: processGroupID, + job: job, + identified: identified, + retainedAgent: agent, + raw: raw, + stabilized: stabilized + ) + ) + } + guard next != previous else { return } + surfaceAgentStates[surfaceID] = next + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) + } + + func markAgentSeen(surfaceID: UUID) { + guard var state = surfaceAgentStates[surfaceID], !state.seen else { return } + state.seen = true + state.lastChangedAt = Date() + surfaceAgentStates[surfaceID] = state + guard let tabId = tabId(containing: surfaceID) else { return } + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + } + + func removeAgentEntryIfNeeded(surfaceID: UUID) { + guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } + surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) + lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceID) + onAgentEntryRemoved?(surfaceID) + } + + /// Re-emit Active Agents entries for every pane in `tabId` so the panel picks + /// up a fresh tab-title snapshot. Title changes (OSC-2, focus sync, manual + /// rename) don't move agent detection state, so without this nudge the + /// subtitle only refreshes on the next agent state transition. + func refreshAgentEntriesForTitleChange(in tabId: TerminalTabID) { + let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] + for surfaceID in surfaceIDs { + guard let state = surfaceAgentStates[surfaceID], + state.detectedAgent != nil, + state.state != .unknown + else { continue } + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + } + } + + func emitAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) { + guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { + onAgentEntryRemoved?(surfaceID) + return + } + onAgentEntryChanged?(entry) + } + + func activeAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) -> ActiveAgentEntry? { + guard let agent = state.detectedAgent, state.state != .unknown else { return nil } + let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] + let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 + let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" + // Resolve the displayed repository/branch from where the agent actually runs, not the tab's + // owning worktree: a user may `cd` into a different repo before launching the agent. Falls + // back to the surface's launch directory when the shell hasn't reported a pwd. + let workingDirectory = inheritedSurfaceConfig( + fromSurfaceId: surfaceID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory + return ActiveAgentEntry( + id: surfaceID, + worktreeID: worktree.id, + worktreeName: worktree.name, + workingDirectory: workingDirectory, + tabID: tabId, + tabTitle: tabTitle, + surfaceID: surfaceID, + paneIndex: paneIndex, + agent: agent, + rawState: state.fallbackState, + displayState: state.displayState, + lastChangedAt: state.lastChangedAt + ) + } + + func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { + agentDetectionTasks[surfaceId]?.cancel() + agentDetectionTasks.removeValue(forKey: surfaceId) + surfaceAgentStates.removeValue(forKey: surfaceId) + agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) + lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) + lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) + onAgentEntryRemoved?(surfaceId) + } + + func cleanupAllAgentDetectionState() { + for task in agentDetectionTasks.values { + task.cancel() + } + let removedIDs = Array(surfaceAgentStates.keys) + agentDetectionTasks.removeAll() + surfaceAgentStates.removeAll() + agentDetectionPresenceBySurface.removeAll() + lastClaudeWorkingAtBySurface.removeAll() + lastAgentDetectionDiagnosticsBySurface.removeAll() + for id in removedIDs { + onAgentEntryRemoved?(id) + } + } + + func agentDetectionDiagnosticMessage(_ diagnostic: AgentDetectionDiagnostic) -> String { + let processSummary = + diagnostic.job?.processes + .map { "\($0.pid):\($0.argv0 ?? $0.name)" } + .joined(separator: ",") ?? "none" + return [ + "tab=\(diagnostic.tabId.rawValue.uuidString.prefix(8))", + "childPID=\(diagnostic.childPID.map(String.init) ?? "nil")", + "ptyPGID=\(diagnostic.processGroupID.map(String.init) ?? "nil")", + "fgPGID=\(diagnostic.job.map { String($0.processGroupID) } ?? "nil")", + "processes=\(processSummary)", + "identified=\(diagnostic.identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", + "retained=\(diagnostic.retainedAgent?.rawValue ?? "nil")", + "raw=\(diagnostic.raw?.rawValue ?? "nil")", + "state=\(diagnostic.stabilized?.rawValue ?? "nil")", + ].joined(separator: " ") + } + + func logAgentDetectionDiagnostic(surfaceID: UUID, diagnostic: AgentDetectionDiagnostic) { + #if DEBUG + let message = agentDetectionDiagnosticMessage(diagnostic) + guard lastAgentDetectionDiagnosticsBySurface[surfaceID] != message else { return } + lastAgentDetectionDiagnosticsBySurface[surfaceID] = message + terminalStateLogger.debug( + "agent detection worktree=\(worktree.name) surface=\(surfaceID.uuidString.prefix(8)) \(message)" + ) + #endif + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift new file mode 100644 index 00000000..e5db9e30 --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+CLI.swift @@ -0,0 +1,130 @@ +import Foundation +import GhosttyKit + +struct CLIWorktreeTerminalSnapshot: Sendable { + let tabs: [CLITerminalTabSnapshot] + let taskStatus: ListCommandTask.Status? +} + +struct CLITerminalTabSnapshot: Sendable { + let id: UUID + let title: String + let selected: Bool + let focusedPaneID: UUID? + let panes: [CLITerminalPaneSnapshot] +} + +struct CLITerminalPaneSnapshot: Sendable { + let id: UUID + let title: String + let cwd: String? +} + +extension WorktreeTerminalState { + func makeCLIListSnapshot() -> CLIWorktreeTerminalSnapshot { + let selectedTabID = tabManager.selectedTabId + + let tabs: [CLITerminalTabSnapshot] = tabManager.tabs.map { tab in + let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] + let panes = paneIDs.map { paneID in + let cwd = inheritedSurfaceConfig( + fromSurfaceId: paneID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory?.path(percentEncoded: false) + + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.displayTitle) + return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) + } + + return CLITerminalTabSnapshot( + id: tab.id.rawValue, + title: tab.displayTitle, + selected: tab.id == selectedTabID, + focusedPaneID: focusedSurfaceIdByTab[tab.id], + panes: panes + ) + } + + return CLIWorktreeTerminalSnapshot( + tabs: tabs, + taskStatus: taskStatus == .running ? .running : .idle + ) + } + + func paneTitle(surfaceID: UUID, fallbackTabTitle: String) -> String { + let rawTitle = surfaces[surfaceID]?.bridge.state.title?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + + if let rawTitle, !rawTitle.isEmpty { + return rawTitle + } + + return fallbackTabTitle + } +} + +// MARK: - CLI Command Finished Waiting + +extension WorktreeTerminalState { + /// Returns an `AsyncStream` that yields exactly once when the command finishes + /// on the given surface. The caller should race this against a timeout. + func waitForCommandFinished(surfaceID: UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)> { + // Cancel any existing waiter for this surface. + commandFinishedWaiters[surfaceID]?.finish() + commandFinishedWaiters.removeValue(forKey: surfaceID) + + return AsyncStream { continuation in + commandFinishedWaiters[surfaceID] = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.commandFinishedWaiters.removeValue(forKey: surfaceID) + } + } + } + } +} + +// MARK: - CLI Send Snapshot + +struct CLISendTabSnapshot { + let focusedPaneID: UUID? + let panes: [TargetResolutionSnapshot.Pane] +} + +extension WorktreeTerminalState { + func makeCLISendSnapshot(for tabId: TerminalTabID) -> CLISendTabSnapshot? { + let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] + guard !paneIDs.isEmpty else { return nil } + + let focusedPaneID = focusedSurfaceIdByTab[tabId] + let panes: [TargetResolutionSnapshot.Pane] = paneIDs.compactMap { paneID in + guard let surfaceView = surfaces[paneID] else { return nil } + let cwd = inheritedSurfaceConfig( + fromSurfaceId: paneID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory?.path(percentEncoded: false) + let title = paneTitle(surfaceID: paneID, fallbackTabTitle: "") + return TargetResolutionSnapshot.Pane( + id: paneID, + title: title, + cwd: cwd, + isFocusedInTab: paneID == focusedPaneID, + surfaceView: surfaceView + ) + } + + return CLISendTabSnapshot(focusedPaneID: focusedPaneID, panes: panes) + } +} + +nonisolated func makeCommandInput(script: String) -> String? { + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + "\n" +} + +nonisolated func makeBlockingScriptInput(script: String) -> String? { + guard let input = makeCommandInput(script: script) else { return nil } + return input + "exit\n" +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift new file mode 100644 index 00000000..c7d82919 --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+LayoutSnapshot.swift @@ -0,0 +1,289 @@ +import Foundation +import GhosttyKit + +extension WorktreeTerminalState { + func makeLayoutSnapshotWorktree() -> TerminalLayoutSnapshotPayload.SnapshotWorktree? { + terminalStateLogger.info( + "[LayoutRestore] makeSnapshot: worktree=\(worktree.id) tabs=\(tabManager.tabs.count)" + ) + guard !tabManager.tabs.isEmpty else { + terminalStateLogger.info("[LayoutRestore] makeSnapshot: no tabs, returning nil") + return nil + } + + var snapshotTabs: [TerminalLayoutSnapshotPayload.SnapshotTab] = [] + snapshotTabs.reserveCapacity(tabManager.tabs.count) + for tab in tabManager.tabs { + guard let tree = trees[tab.id], let root = tree.root else { + terminalStateLogger.warning( + "[LayoutRestore] makeSnapshot: no tree/root for tab \(tab.id.rawValue.uuidString)" + ) + return nil + } + guard let splitRoot = makeLayoutSnapshotNode(from: root) else { + terminalStateLogger.warning( + "[LayoutRestore] makeSnapshot: failed to snapshot split tree for tab \(tab.id.rawValue.uuidString)" + ) + return nil + } + // Skip title/icon for blocking-script tabs as they are transient. + // Persist the icon only when the user has explicitly overridden it; otherwise + // restore should pick up the current default ("terminal") or auto-detection. + let isBlockingScriptTab = tab.id == runScriptTabId + let snapshotIcon: String? = (isBlockingScriptTab || tab.iconLock != .user) ? nil : tab.icon + snapshotTabs.append( + TerminalLayoutSnapshotPayload.SnapshotTab( + tabID: tab.id.rawValue.uuidString, + title: isBlockingScriptTab ? nil : tab.title, + customTitle: isBlockingScriptTab ? nil : tab.customTitle, + icon: snapshotIcon, + splitRoot: splitRoot + ) + ) + } + + let result = TerminalLayoutSnapshotPayload.SnapshotWorktree( + worktreeID: worktree.id, + selectedTabID: tabManager.selectedTabId?.rawValue.uuidString, + tabs: snapshotTabs + ) + terminalStateLogger.info( + "[LayoutRestore] makeSnapshot: success, \(snapshotTabs.count) tab(s) captured" + ) + return result + } + + func applyLayoutSnapshot(_ snapshot: TerminalLayoutSnapshotPayload.SnapshotWorktree) -> Bool { + terminalStateLogger.info( + "[LayoutRestore] applySnapshot: worktree=\(worktree.id)" + + " snapshotWorktreeID=\(snapshot.worktreeID) tabs=\(snapshot.tabs.count)" + ) + guard snapshot.worktreeID == worktree.id else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: worktreeID mismatch") + return false + } + + // Validate snapshot structure before creating any surfaces. + var validatedTabs: [(tabID: TerminalTabID, snapshotTab: TerminalLayoutSnapshotPayload.SnapshotTab)] = [] + var seenTabIDs: Set = [] + for snapshotTab in snapshot.tabs { + guard let tabUUID = UUID(uuidString: snapshotTab.tabID) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid tab UUID \(snapshotTab.tabID)") + return false + } + let tabID = TerminalTabID(rawValue: tabUUID) + guard seenTabIDs.insert(tabID).inserted else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: duplicate tab ID \(snapshotTab.tabID)") + return false + } + validatedTabs.append((tabID: tabID, snapshotTab: snapshotTab)) + } + + let selectedTabID: TerminalTabID? + if let selectedTabRaw = snapshot.selectedTabID { + guard let selectedUUID = UUID(uuidString: selectedTabRaw) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid selectedTab UUID \(selectedTabRaw)") + return false + } + let candidate = TerminalTabID(rawValue: selectedUUID) + guard seenTabIDs.contains(candidate) else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: selectedTab not in restored tabs") + return false + } + selectedTabID = candidate + } else { + selectedTabID = validatedTabs.first?.tabID + } + + // Close existing surfaces BEFORE creating new ones so new surfaces + // don't get destroyed by closeAllSurfaces(). + terminalStateLogger.info("[LayoutRestore] applySnapshot: closing existing surfaces before restore") + closeAllSurfaces() + + // Now create new surfaces into the clean state. + var restoredTabs: [TerminalTabItem] = [] + var restoredTrees: [TerminalTabID: SplitTree] = [:] + var restoredFocusedSurfaceIDs: [TerminalTabID: UUID] = [:] + + for (index, entry) in validatedTabs.enumerated() { + terminalStateLogger.info( + "[LayoutRestore] applySnapshot: restoring tab[\(index)] id=\(entry.snapshotTab.tabID)" + ) + guard + let rootNode = restoreSplitNode(from: entry.snapshotTab.splitRoot, tabID: entry.tabID, isRoot: true) + else { + terminalStateLogger.warning("[LayoutRestore] applySnapshot: restoreSplitNode failed for tab[\(index)]") + closeAllSurfaces() + return false + } + let tree = SplitTree.restored(root: rootNode) + restoredTrees[entry.tabID] = tree + restoredFocusedSurfaceIDs[entry.tabID] = rootNode.leftmostLeaf().id + restoredTabs.append( + TerminalTabItem( + id: entry.tabID, + title: entry.snapshotTab.title ?? "\(worktree.name) \(index + 1)", + customTitle: entry.snapshotTab.customTitle, + icon: entry.snapshotTab.icon ?? "terminal", + isTitleLocked: false, + iconLock: entry.snapshotTab.icon != nil ? .user : .auto + ) + ) + } + + trees = restoredTrees + focusedSurfaceIdByTab = restoredFocusedSurfaceIDs + tabIsRunningById = Dictionary(uniqueKeysWithValues: restoredTabs.map { ($0.id, false) }) + tabManager.tabs = restoredTabs + tabManager.selectedTabId = selectedTabID + setRunScriptTabId(nil) + + // Explicitly unfocus all restored surfaces so only the focused one blinks. + for surface in surfaces.values { + surface.focusDidChange(false) + } + if let selectedTabID { + focusSurface(in: selectedTabID) + } else { + lastEmittedFocusSurfaceId = nil + } + emitTaskStatusIfChanged() + // Signal "this worktree now has tabs" so downstream Shelf + // bookkeeping (`markWorktreeOpened` via `terminalEvent(.tabCreated)`) + // adds the restored worktree to `openedWorktreeIDs`. Without this + // emit, only the active worktree (which goes through + // `.selectWorktree` on `.layoutRestored`) shows as a book on the + // Shelf - every other restored worktree is missing, even though + // the sidebar lists it and its terminal state is live. + if !restoredTabs.isEmpty { + onTabCreated?() + } + terminalStateLogger.info( + "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)" + + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")" + ) + return true + } + + static func resolveSnapshotWorkingDirectory( + from snapshotPath: String?, + worktreeRoot: URL, + fileManager: FileManager = .default + ) -> URL? { + guard let snapshotPath, + let normalizedPath = PathPolicy.normalizePath(snapshotPath, relativeTo: worktreeRoot) + else { + return nil + } + + let normalizedURL = URL(fileURLWithPath: normalizedPath).standardizedFileURL + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), isDirectory.boolValue else { + return nil + } + guard PathPolicy.contains(normalizedURL, in: worktreeRoot) else { + return nil + } + return normalizedURL + } + + func makeLayoutSnapshotNode( + from node: SplitTree.Node + ) -> TerminalLayoutSnapshotPayload.SnapshotSplitNode? { + switch node { + case .leaf(let view): + let cwdPath = inheritedSurfaceConfig( + fromSurfaceId: view.id, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory?.path(percentEncoded: false) + return .leaf(surfaceID: view.id.uuidString, cwdPath: cwdPath) + case .split(let split): + guard let left = makeLayoutSnapshotNode(from: split.left) else { + return nil + } + guard let right = makeLayoutSnapshotNode(from: split.right) else { + return nil + } + return .split( + direction: snapshotSplitDirection(from: split.direction), + ratio: split.ratio, + children: [left, right] + ) + } + } + + func restoreSplitNode( + from snapshotNode: TerminalLayoutSnapshotPayload.SnapshotSplitNode, + tabID: TerminalTabID, + isRoot: Bool + ) -> SplitTree.Node? { + switch snapshotNode.kind { + case .leaf: + guard let surfaceID = snapshotNode.surfaceID, + !surfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + let context: ghostty_surface_context_e = isRoot ? GHOSTTY_SURFACE_CONTEXT_TAB : GHOSTTY_SURFACE_CONTEXT_SPLIT + let restoredWorkingDirectory = Self.resolveSnapshotWorkingDirectory( + from: snapshotNode.cwdPath, + worktreeRoot: worktree.workingDirectory + ) + let view = createSurface( + tabId: tabID, + initialInput: nil, + inheritingFromSurfaceId: nil, + workingDirectoryOverride: restoredWorkingDirectory, + context: context + ) + return .leaf(view: view) + case .split: + guard let direction = snapshotNode.direction else { + return nil + } + guard let ratio = snapshotNode.ratio, ratio > 0, ratio < 1 else { + return nil + } + let clampedRatio = max(0.1, min(0.9, ratio)) + guard let children = snapshotNode.children, children.count == 2 else { + return nil + } + guard let left = restoreSplitNode(from: children[0], tabID: tabID, isRoot: false) else { + return nil + } + guard let right = restoreSplitNode(from: children[1], tabID: tabID, isRoot: false) else { + return nil + } + return .split( + .init( + direction: splitDirection(from: direction), + ratio: clampedRatio, + left: left, + right: right + ) + ) + } + } + + func snapshotSplitDirection( + from direction: SplitTree.Direction + ) -> TerminalLayoutSnapshotSplitDirection { + switch direction { + case .horizontal: + .horizontal + case .vertical: + .vertical + } + } + + func splitDirection( + from direction: TerminalLayoutSnapshotSplitDirection + ) -> SplitTree.Direction { + switch direction { + case .horizontal: + .horizontal + case .vertical: + .vertical + } + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift new file mode 100644 index 00000000..d70dfd27 --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift @@ -0,0 +1,157 @@ +import Foundation + +extension WorktreeTerminalState { + /// How recently the user must have typed for us to consider the exit user-initiated. + static let recentInteractionWindow: Duration = .seconds(3) + + func setNotificationsEnabled(_ enabled: Bool) { + notificationsEnabled = enabled + if !enabled { + markAllNotificationsRead() + } + } + + func setCommandFinishedNotification(enabled: Bool, threshold: Int) { + commandFinishedNotificationEnabled = enabled + commandFinishedNotificationThreshold = threshold + } + + func clearNotificationIndicator() { + markAllNotificationsRead() + } + + func markAllNotificationsRead() { + let previousHasUnseen = hasUnseenNotification + for index in notifications.indices { + notifications[index].isRead = true + } + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func markNotificationsRead(forSurfaceID surfaceID: UUID) { + let previousHasUnseen = hasUnseenNotification + for index in notifications.indices where notifications[index].surfaceId == surfaceID { + notifications[index].isRead = true + } + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func markNotificationRead(id notificationID: WorktreeTerminalNotification.ID) { + let previousHasUnseen = hasUnseenNotification + guard let index = notifications.firstIndex(where: { $0.id == notificationID }) else { + return + } + notifications[index].isRead = true + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { + let previousHasUnseen = hasUnseenNotification + notifications.removeAll { $0.id == notificationID } + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func dismissAllNotifications() { + let previousHasUnseen = hasUnseenNotification + notifications.removeAll() + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func recordKeyInput(forSurfaceID surfaceId: UUID) { + lastKeyInputTimeBySurface[surfaceId] = .now + } + + func handleCommandFinished(exitCode: Int?, durationNs: UInt64, surfaceId: UUID) { + // Notify CLI waiters unconditionally before applying notification filters. + if let continuation = commandFinishedWaiters.removeValue(forKey: surfaceId) { + let durationMs = Int(durationNs / 1_000_000) + continuation.yield((exitCode: exitCode, durationMs: durationMs)) + continuation.finish() + } + + surfaceRunningStartedAtById.removeValue(forKey: surfaceId) + noteCommandFinishedForCommandDetection(surfaceId: surfaceId) + + // Custom command success toast. One-shot: removed regardless of outcome. + if let commandName = pendingCustomCommands.removeValue(forKey: surfaceId), exitCode == 0 { + let durationMs = Int(durationNs / 1_000_000) + onCustomCommandSucceeded?(commandName, durationMs) + } + + // Auto-close on success (exit 0). One-shot: the id is removed regardless of outcome. + if autoCloseSurfaceIds.remove(surfaceId) != nil { + if exitCode == 0, surfaces[surfaceId] != nil { + scheduleAutoClose(surfaceId: surfaceId) + return + } + } + + guard commandFinishedNotificationEnabled else { return } + let durationSeconds = Int(durationNs / 1_000_000_000) + guard durationSeconds >= commandFinishedNotificationThreshold else { return } + // Skip user-initiated termination (Ctrl+C / kill signal) + if let code = exitCode, code == 130 || code == 143 { return } + // Skip if the user was recently typing in this surface (e.g. /exit, quit) + if let lastInput = lastKeyInputTimeBySurface[surfaceId], + ContinuousClock.now - lastInput < Self.recentInteractionWindow + { + return + } + + let title = (exitCode == nil || exitCode == 0) ? "Command finished" : "Command failed" + let formattedDuration = Self.formatDuration(durationSeconds) + let body: String + if let code = exitCode, code != 0 { + body = "Failed (exit code \(code)) after \(formattedDuration)" + } else { + body = "Completed in \(formattedDuration)" + } + appendNotification(title: title, body: body, surfaceId: surfaceId) + } + + func appendNotification(title: String, body: String, surfaceId: UUID) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } + if notificationsEnabled { + let previousHasUnseen = hasUnseenNotification + let isRead = isSelected() && isFocusedSurface(surfaceId) + notifications.insert( + WorktreeTerminalNotification( + surfaceId: surfaceId, + title: trimmedTitle, + body: trimmedBody, + createdAt: Date(), + isRead: isRead + ), + at: 0 + ) + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody) + } + + static func formatDuration(_ seconds: Int) -> String { + if seconds < 60 { + return "\(seconds)s" + } + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + if minutes < 60 { + return remainingSeconds > 0 ? "\(minutes)m \(remainingSeconds)s" : "\(minutes)m" + } + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + return remainingMinutes > 0 ? "\(hours)h \(remainingMinutes)m" : "\(hours)h" + } + + /// Removes every notification that originated from `surfaceID`, regardless of + /// read state. Pure so the teardown behavior can be unit-tested without a + /// live Ghostty surface. + static func prunedNotifications( + from notifications: [WorktreeTerminalNotification], + removingSurfaceID surfaceID: UUID + ) -> [WorktreeTerminalNotification] { + notifications.filter { $0.surfaceId != surfaceID } + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift new file mode 100644 index 00000000..0763c1bd --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -0,0 +1,868 @@ +import AppKit +import CoreGraphics +import Foundation +import GhosttyKit + +extension WorktreeTerminalState { + func confirmCloseIfNeeded( + tabIds: [TerminalTabID], + mode: TerminalCloseConfirmationMode + ) -> Bool { + let surfaceIDs = tabIds.flatMap { tabId in + trees[tabId]?.leaves().map(\.id) ?? [] + } + return confirmCloseIfNeeded(surfaceIDs: surfaceIDs, mode: mode) + } + + func confirmCloseIfNeeded( + surfaceIDs: [UUID], + mode: TerminalCloseConfirmationMode + ) -> Bool { + guard !surfaceIDs.isEmpty else { return true } + switch mode { + case .skip: + return true + case .prompt(let target): + let candidates = closeProtectionCandidates(surfaceIDs: surfaceIDs) + let decision = TerminalCloseConfirmationPolicy.decision(for: candidates) + guard decision.requiresConfirmation else { return true } + return presentCloseConfirmation(target: target, decision: decision) + } + } + + func closeProtectionCandidates(surfaceIDs: [UUID]) -> [TerminalCloseProtectionCandidate] { + let now = Date() + return surfaceIDs.map { surfaceID in + let agentState = surfaceAgentStates[surfaceID] + let runningDuration = surfaceRunningStartedAtById[surfaceID].map { now.timeIntervalSince($0) } + return TerminalCloseProtectionCandidate( + hasAgent: agentState?.detectedAgent != nil, + agentDisplayState: agentState?.displayState, + commandRunningDuration: runningDuration + ) + } + } + + func presentCloseConfirmation( + target: TerminalCloseConfirmationTarget, + decision: TerminalCloseConfirmationDecision + ) -> Bool { + let alert = NSAlert() + alert.messageText = target.messageText + alert.informativeText = closeConfirmationMessage(for: decision) + alert.alertStyle = .warning + alert.addButton(withTitle: target.confirmButtonTitle) + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + + func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { + let paneText = decision.protectedPaneCount == 1 ? "pane" : "panes" + let reasonText: String + if decision.reasons == Set([.agentActive]) { + reasonText = "active agent work or an unseen agent result" + } else if decision.reasons == Set([.longRunningCommand]) { + reasonText = "a command that has been running for at least 10 seconds" + } else { + reasonText = "active agent work, unseen agent results, or long-running commands" + } + return "This will close \(decision.protectedPaneCount) \(paneText) with \(reasonText)." + } + + func splitTree( + for tabId: TerminalTabID, + inheritingFromSurfaceId: UUID? = nil, + initialInput: String? = nil, + workingDirectoryOverride: URL? = nil, + context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB + ) -> SplitTree { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { + return SplitTree() + } + if let existing = trees[tabId] { + return existing + } + let surface = createSurface( + tabId: tabId, + initialInput: initialInput, + inheritingFromSurfaceId: inheritingFromSurfaceId, + workingDirectoryOverride: workingDirectoryOverride, + context: context + ) + let tree = SplitTree(view: surface) + trees[tabId] = tree + focusedSurfaceIdByTab[tabId] = surface.id + return tree + } + + /// Splits the currently focused surface and seeds the new pane with `initialInput`. + /// Returns the new surface id, or nil if the split could not be created. + @discardableResult + func createSplitOnFocusedSurface( + direction: UserCustomSplitDirection, + initialInput: String + ) -> UUID? { + guard let tabId = tabManager.selectedTabId, + let parentSurfaceId = focusedSurfaceIdByTab[tabId], + let tree = trees[tabId], + let parentSurface = surfaces[parentSurfaceId] + else { + return nil + } + let newSurface = createSurface( + tabId: tabId, + initialInput: runScriptInput(initialInput), + inheritingFromSurfaceId: parentSurfaceId, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + do { + let newTree = try tree.inserting( + view: newSurface, + at: parentSurface, + direction: mapUserSplitDirection(direction) + ) + updateTree(newTree, for: tabId) + if isCanvasManaged { + newSurface.setOcclusion(true) + } + focusSurface(newSurface, in: tabId) + return newSurface.id + } catch { + newSurface.closeSurface() + surfaces.removeValue(forKey: newSurface.id) + surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) + cleanupCommandDetectorState(forSurfaceId: newSurface.id) + cleanupAgentDetectionState(forSurfaceId: newSurface.id) + return nil + } + } + + /// Returns the focused surface id for a given tab, if any. + func focusedSurfaceId(in tabId: TerminalTabID) -> UUID? { + focusedSurfaceIdByTab[tabId] + } + + func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { + focusedSurfaceIdByTab[tabId] + } + + /// Marks a surface so that its next successful `command_finished` event (exit 0) + /// will trigger a one-shot close of that surface. + func markSurfaceForAutoClose(_ surfaceId: UUID) { + autoCloseSurfaceIds.insert(surfaceId) + } + + func isMarkedForAutoClose(_ surfaceId: UUID) -> Bool { + autoCloseSurfaceIds.contains(surfaceId) + } + + /// Records the user-facing Custom Command name associated with a freshly created surface, + /// so a success toast can be emitted when that surface's next command exits with code 0. + func markSurfaceForCustomCommand(_ surfaceId: UUID, name: String) { + pendingCustomCommands[surfaceId] = name + } + + /// Pin a Custom Command's configured icon onto its host tab so the + /// auto-detector can't swap it out when the script's OSC-2 title + /// matches a known command. Yields to a user-set icon lock — manual + /// picker selections always win. + func applyCustomCommandIcon(_ icon: String, surfaceId: UUID) { + let trimmed = icon.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard let tabId = tabId(containing: surfaceId) else { return } + tabManager.setScriptIcon(tabId, icon: trimmed) + } + + // Short delay lets the user see the final output before the pane disappears. + static let autoCloseDelay: Duration = .milliseconds(800) + + func scheduleAutoClose(surfaceId: UUID) { + Task { [weak self] in + try? await Task.sleep(for: Self.autoCloseDelay) + guard let self else { return } + guard let view = self.surfaces[surfaceId] else { return } + self.handleCloseRequest(for: view, processAlive: false) + } + } + + func performSplitAction(_ action: GhosttySplitAction, for surfaceId: UUID) -> Bool { + guard let tabId = tabId(containing: surfaceId), var tree = trees[tabId] else { + return false + } + guard let targetNode = tree.find(id: surfaceId) else { return false } + guard let targetSurface = surfaces[surfaceId] else { return false } + + switch action { + case .newSplit(let direction): + let newSurface = createSurface( + tabId: tabId, + initialInput: nil, + inheritingFromSurfaceId: surfaceId, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + do { + let newTree = try tree.inserting( + view: newSurface, + at: targetSurface, + direction: mapSplitDirection(direction) + ) + updateTree(newTree, for: tabId) + // Canvas manages occlusion directly; ensure the new pane renders. + if isCanvasManaged { + newSurface.setOcclusion(true) + } + focusSurface(newSurface, in: tabId) + return true + } catch { + newSurface.closeSurface() + surfaces.removeValue(forKey: newSurface.id) + surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) + cleanupCommandDetectorState(forSurfaceId: newSurface.id) + cleanupAgentDetectionState(forSurfaceId: newSurface.id) + + return false + } + + case .gotoSplit(let direction): + let focusDirection = mapFocusDirection(direction) + guard let nextSurface = tree.focusTarget(for: focusDirection, from: targetNode) else { + return false + } + if tree.zoomed != nil { + tree = tree.settingZoomed(nil) + trees[tabId] = tree + } + focusSurface(nextSurface, in: tabId) + syncFocusIfNeeded() + return true + + case .resizeSplit(let direction, let amount): + let spatialDirection = mapResizeDirection(direction) + do { + let newTree = try tree.resizing( + node: targetNode, + by: amount, + in: spatialDirection, + with: CGRect(origin: .zero, size: tree.viewBounds()) + ) + updateTree(newTree, for: tabId) + return true + } catch { + return false + } + + case .equalizeSplits: + updateTree(tree.equalized(), for: tabId) + return true + + case .toggleSplitZoom: + guard tree.isSplit else { return false } + let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode + updateTree(tree.settingZoomed(newZoomed), for: tabId) + focusSurface(targetSurface, in: tabId) + return true + } + } + + func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TerminalTabID) { + guard var tree = trees[tabId] else { return } + + switch operation { + case .resize(let node, let ratio): + let resizedNode = node.resizing(to: ratio) + do { + tree = try tree.replacing(node: node, with: resizedNode) + updateTree(tree, for: tabId) + } catch { + return + } + + case .drop(let payloadId, let destinationId, let zone): + guard let payload = surfaces[payloadId] else { return } + guard let destination = surfaces[destinationId] else { return } + if payload === destination { return } + guard let sourceNode = tree.root?.node(view: payload) else { return } + let treeWithoutSource = tree.removing(sourceNode) + if treeWithoutSource.isEmpty { return } + do { + let newTree = try treeWithoutSource.inserting( + view: payload, + at: destination, + direction: mapDropZone(zone) + ) + updateTree(newTree, for: tabId) + focusSurface(payload, in: tabId) + } catch { + return + } + + case .equalize: + updateTree(tree.equalized(), for: tabId) + } + } + + func setAllSurfacesOccluded() { + for surface in surfaces.values { + surface.setOcclusion(false) + surface.focusDidChange(false) + } + } + + func closeAllSurfaces() { + for surface in surfaces.values { + surface.closeSurface() + } + surfaces.removeAll() + trees.removeAll() + focusedSurfaceIdByTab.removeAll() + cleanupAllAgentDetectionState() + tabIsRunningById.removeAll() + autoCloseSurfaceIds.removeAll() + pendingCustomCommands.removeAll() + setRunScriptTabId(nil) + tabManager.closeAll() + } + + func createSurface( + tabId: TerminalTabID, + initialInput: String?, + inheritingFromSurfaceId: UUID?, + workingDirectoryOverride: URL? = nil, + context: ghostty_surface_context_e + ) -> GhosttySurfaceView { + let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) + let resolvedFontSize = Self.resolvedFontSizeForNewSurface( + defaultFontSize: defaultFontSize, + inheritedFontSize: inherited.fontSize, + context: context + ) + let view = GhosttySurfaceView( + runtime: runtime, + workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, + initialInput: initialInput, + fontSize: resolvedFontSize, + context: context, + environment: worktree.scriptEnvironment + ) + // Sending a no-op font size action marks the Ghostty surface as + // "font_size_adjusted", which prevents config reloads (triggered by + // keybind changes on worktree switch) from resetting the font to the + // config default. + if resolvedFontSize != nil { + view.performBindingAction("increase_font_size:0") + } + configureBridgeCallbacks(for: view, tabId: tabId) + configureSurfaceCallbacks(for: view, tabId: tabId) + surfaces[view.id] = view + startAgentDetection(for: view, tabId: tabId) + return view + } + + func configureBridgeCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { + view.bridge.onTitleChange = { [weak self, weak view] title in + guard let self, let view else { return } + if self.focusedSurfaceIdByTab[tabId] == view.id { + if self.tabManager.updateTitle(tabId, title: title) { + self.refreshAgentEntriesForTitleChange(in: tabId) + } + } + self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) + } + view.bridge.onSplitAction = { [weak self, weak view] action in + guard let self, let view else { return false } + return self.performSplitAction(action, for: view.id) + } + view.bridge.onNewTab = { [weak self, weak view] in + guard let self, let view else { return false } + return self.createTab(inheritingFromSurfaceId: view.id) != nil + } + view.bridge.onCloseTab = { [weak self] _ in + guard let self else { return false } + return self.closeTab(tabId) + } + view.bridge.onGotoTab = { [weak self] target in + guard let self else { return false } + return self.handleGotoTabRequest(target) + } + view.bridge.onCommandPaletteToggle = { [weak self] in + guard let self else { return false } + self.onCommandPaletteToggle?() + return true + } + view.bridge.onProgressReport = { [weak self] _ in + guard let self else { return } + self.updateRunningState(for: tabId) + } + view.bridge.onDesktopNotification = { [weak self, weak view] title, body in + guard let self, let view else { return } + self.appendNotification(title: title, body: body, surfaceId: view.id) + } + view.bridge.onCommandFinished = { [weak self, weak view] exitCode, durationNs in + guard let self, let view else { return } + self.handleCommandFinished(exitCode: exitCode, durationNs: durationNs, surfaceId: view.id) + } + view.bridge.onCloseRequest = { [weak self, weak view] processAlive in + guard let self, let view else { return } + self.handleCloseRequest(for: view, processAlive: processAlive) + } + view.bridge.onPromptTitle = { [weak self] promptType in + guard let self else { return } + self.handlePromptTitle(promptType, tabId: tabId) + } + } + + func configureSurfaceCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { + view.onFocusChange = { [weak self, weak view] focused in + guard let self, let view, focused else { return } + self.recordActiveSurface(view, in: tabId) + self.emitTaskStatusIfChanged() + } + view.onKeyInput = { [weak self, weak view] in + guard let self, let view else { return } + self.recordKeyInput(forSurfaceID: view.id) + self.markNotificationsRead(forSurfaceID: view.id) + } + view.onFontSizeShortcut = { [weak self] in + guard let self else { return } + self.onFontSizeAdjusted?() + } + } + + static func resolvedFontSizeForNewSurface( + defaultFontSize: Float32?, + inheritedFontSize: Float32?, + context: ghostty_surface_context_e + ) -> Float32? { + if context == GHOSTTY_SURFACE_CONTEXT_SPLIT { + return inheritedFontSize ?? defaultFontSize + } + return defaultFontSize + } + + struct InheritedSurfaceConfig: Equatable { + let workingDirectory: URL? + let fontSize: Float32? + } + + func inheritedSurfaceConfig( + fromSurfaceId surfaceId: UUID?, + context: ghostty_surface_context_e + ) -> InheritedSurfaceConfig { + guard let surfaceId, + let view = surfaces[surfaceId], + let sourceSurface = view.surface + else { + return InheritedSurfaceConfig(workingDirectory: nil, fontSize: nil) + } + + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + let fontSize = inherited.font_size == 0 ? nil : inherited.font_size + let workingDirectory = inherited.working_directory.flatMap { ptr -> URL? in + let path = String(cString: ptr) + if path.isEmpty { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true) + } + return InheritedSurfaceConfig(workingDirectory: workingDirectory, fontSize: fontSize) + } + + func currentFocusedSurfaceId() -> UUID? { + guard let selectedTabId = tabManager.selectedTabId else { return nil } + return focusedSurfaceIdByTab[selectedTabId] + } + + func handlePromptTitle( + _ promptType: ghostty_action_prompt_title_e, + tabId: TerminalTabID + ) { + guard let surfaceId = focusedSurfaceIdByTab[tabId], + let window = surfaces[surfaceId]?.window + else { return } + switch promptType { + case GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB: + // Prowl is a single-window app so there is no per-surface window title to set. + // Both surface and tab title prompts are treated as tab title changes for now. + // Consider removing GHOSTTY_PROMPT_TITLE_SURFACE support entirely. + promptTabTitle(for: tabId, in: window) + default: + break + } + } + + func promptChangeTabTitle(_ tabId: TerminalTabID) { + let surfaceWindow = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0]?.window } + guard let window = surfaceWindow ?? NSApp.keyWindow else { return } + promptTabTitle(for: tabId, in: window) + } + + func presentIconPicker(for tabId: TerminalTabID) { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return } + iconPickerTabId = tabId + } + + func presentIconPickerForFocusedTab() { + guard let tabId = tabManager.selectedTabId else { return } + presentIconPicker(for: tabId) + } + + func dismissIconPicker() { + iconPickerTabId = nil + } + + /// Default SF Symbol used for a tab when the user has not set an override. + func defaultIcon(for tabId: TerminalTabID) -> String { + tabId == runScriptTabId ? "play.fill" : "terminal" + } + + /// Apply an icon change for `tabId`. Pass `nil` to clear the override and + /// restore the tab's default icon. + func applyIconChange(_ tabId: TerminalTabID, icon newIcon: String?) { + if let newIcon, !newIcon.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + tabManager.overrideIcon(tabId, icon: newIcon) + } else { + tabManager.clearIconOverride(tabId) + tabManager.updateIcon(tabId, icon: defaultIcon(for: tabId)) + } + } + + func promptTabTitle(for tabId: TerminalTabID, in window: NSWindow) { + guard let tabIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = tabManager.tabs[tabIndex].displayTitle + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + MainActor.assumeIsolated { + guard response == .alertFirstButtonReturn else { return } + guard let self else { return } + let newTitle = textField.stringValue + if self.tabManager.setCustomTitle(tabId, title: newTitle) { + self.refreshAgentEntriesForTitleChange(in: tabId) + } + } + } + } + + func updateTabTitle(for tabId: TerminalTabID) { + guard let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId], + let title = surface.bridge.state.title + else { return } + if tabManager.updateTitle(tabId, title: title) { + refreshAgentEntriesForTitleChange(in: tabId) + } + } + + func focusSurface(in tabId: TerminalTabID) { + if let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] { + focusSurface(surface, in: tabId) + return + } + let tree = splitTree(for: tabId) + if let surface = tree.visibleLeaves().first { + focusSurface(surface, in: tabId) + } + } + + func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { + let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } + recordActiveSurface(surface, in: tabId) + guard tabId == tabManager.selectedTabId else { return } + let fromSurface = (previousSurface === surface) ? nil : previousSurface + GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) + } + + func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { + focusedSurfaceIdByTab[tabId] = surface.id + markAgentSeen(surfaceID: surface.id) + markNotificationsRead(forSurfaceID: surface.id) + updateTabTitle(for: tabId) + emitFocusChangedIfNeeded(surface.id) + } + + /// Drops all per-surface bookkeeping for a surface that has been torn down, + /// including any notifications it produced. A notification is keyed by its + /// originating surface and is only cleared when that surface is focused or + /// typed into; once the surface is gone there is no way to mark it read, so + /// without dropping them here the worktree's unseen indicator (bell + Dock + /// badge) would stay lit until the user manually dismisses everything. + func forgetSurface(_ surfaceID: UUID) { + surfaces.removeValue(forKey: surfaceID) + surfaceRunningStartedAtById.removeValue(forKey: surfaceID) + autoCloseSurfaceIds.remove(surfaceID) + pendingCustomCommands.removeValue(forKey: surfaceID) + cleanupCommandDetectorState(forSurfaceId: surfaceID) + cleanupAgentDetectionState(forSurfaceId: surfaceID) + let previousHasUnseen = hasUnseenNotification + notifications = Self.prunedNotifications(from: notifications, removingSurfaceID: surfaceID) + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + } + + func removeTree(for tabId: TerminalTabID) { + guard let tree = trees.removeValue(forKey: tabId) else { return } + for surface in tree.leaves() { + surface.closeSurface() + forgetSurface(surface.id) + } + focusedSurfaceIdByTab.removeValue(forKey: tabId) + tabIsRunningById.removeValue(forKey: tabId) + } + + func tabID(containing surfaceId: UUID) -> TerminalTabID? { + for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { + return tabId + } + return nil + } + + func tabId(containing surfaceId: UUID) -> TerminalTabID? { + tabID(containing: surfaceId) + } + + func isFocusedSurface(_ surfaceId: UUID) -> Bool { + guard let selectedTabId = tabManager.selectedTabId else { + return false + } + return focusedSurfaceIdByTab[selectedTabId] == surfaceId + } + + func updateRunningState(for tabId: TerminalTabID) { + guard let tree = trees[tabId] else { return } + let now = Date() + var isRunningNow = false + for surface in tree.leaves() { + if isRunningProgressState(surface.bridge.state.progressState) { + isRunningNow = true + if surfaceRunningStartedAtById[surface.id] == nil { + surfaceRunningStartedAtById[surface.id] = now + } + } else { + surfaceRunningStartedAtById.removeValue(forKey: surface.id) + } + } + tabIsRunningById[tabId] = isRunningNow + tabManager.updateDirty(tabId, isDirty: isRunningNow) + emitTaskStatusIfChanged() + } + + func emitTaskStatusIfChanged() { + let newStatus = taskStatus + if newStatus != lastReportedTaskStatus { + lastReportedTaskStatus = newStatus + onTaskStatusChanged?(newStatus) + } + } + + func emitFocusChangedIfNeeded(_ surfaceId: UUID) { + guard surfaceId != lastEmittedFocusSurfaceId else { return } + lastEmittedFocusSurfaceId = surfaceId + onFocusChanged?(surfaceId) + } + + func emitNotificationIndicatorIfNeeded(previousHasUnseen: Bool) { + if previousHasUnseen != hasUnseenNotification { + onNotificationIndicatorChanged?() + } + } + + func syncFocusIfNeeded() { + guard !isCanvasManaged else { return } + guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } + applySurfaceActivity() + } + + func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { + trees[tabId] = tree + syncFocusIfNeeded() + } + + func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { + switch state { + case .some(GHOSTTY_PROGRESS_STATE_SET), + .some(GHOSTTY_PROGRESS_STATE_INDETERMINATE), + .some(GHOSTTY_PROGRESS_STATE_PAUSE), + .some(GHOSTTY_PROGRESS_STATE_ERROR): + return true + default: + return false + } + } + + func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) + -> SplitTree.NewDirection + { + switch direction { + case .left: + return .left + case .right: + return .right + case .top: + return .top + case .down: + return .down + } + } + + func mapUserSplitDirection(_ direction: UserCustomSplitDirection) + -> SplitTree.NewDirection + { + switch direction { + case .left: + return .left + case .right: + return .right + case .top: + return .top + case .down: + return .down + } + } + + func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) + -> SplitTree.FocusDirection + { + switch direction { + case .previous: + return .previous + case .next: + return .next + case .left: + return .spatial(.left) + case .right: + return .spatial(.right) + case .top: + return .spatial(.top) + case .down: + return .spatial(.down) + } + } + + func mapResizeDirection(_ direction: GhosttySplitAction.ResizeDirection) + -> SplitTree.SpatialDirection + { + switch direction { + case .left: + return .left + case .right: + return .right + case .top: + return .top + case .down: + return .down + } + } + + func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { + guard surfaces[view.id] != nil else { return } + if processAlive { + guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } + } + guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { + view.closeSurface() + forgetSurface(view.id) + return + } + guard let node = tree.find(id: view.id) else { + view.closeSurface() + forgetSurface(view.id) + return + } + let nextSurface = + focusedSurfaceIdByTab[tabId] == view.id + ? tree.focusTargetAfterClosing(node) + : nil + let newTree = tree.removing(node) + view.closeSurface() + forgetSurface(view.id) + if newTree.isEmpty { + trees.removeValue(forKey: tabId) + focusedSurfaceIdByTab.removeValue(forKey: tabId) + tabManager.closeTab(tabId) + if tabId == runScriptTabId { + setRunScriptTabId(nil) + } + // Mirror `state.closeTab(_:)`'s `onTabClosed` emit: this path + // fires when the shell process exits (ghostty-driven close) + // and historically skipped the callback, which meant the + // Shelf's "retire the book when its last tab closes" logic + // never saw this very common path. + onTabClosed?() + return + } + updateTree(newTree, for: tabId) + updateRunningState(for: tabId) + if focusedSurfaceIdByTab[tabId] == view.id { + if let nextSurface { + focusSurface(nextSurface, in: tabId) + } else { + focusedSurfaceIdByTab.removeValue(forKey: tabId) + } + } + } + + func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { + let tabs = tabManager.tabs + guard !tabs.isEmpty else { return false } + let raw = Int(target.rawValue) + let selectedIndex = tabManager.selectedTabId.flatMap { selected in + tabs.firstIndex { $0.id == selected } + } + let targetIndex: Int + if raw <= 0 { + switch raw { + case Int(GHOSTTY_GOTO_TAB_PREVIOUS.rawValue): + let current = selectedIndex ?? 0 + targetIndex = (current - 1 + tabs.count) % tabs.count + case Int(GHOSTTY_GOTO_TAB_NEXT.rawValue): + let current = selectedIndex ?? 0 + targetIndex = (current + 1) % tabs.count + case Int(GHOSTTY_GOTO_TAB_LAST.rawValue): + targetIndex = tabs.count - 1 + default: + return false + } + } else { + targetIndex = min(raw - 1, tabs.count - 1) + } + selectTab(tabs[targetIndex].id) + return true + } + + func mapDropZone(_ zone: TerminalSplitTreeView.DropZone) + -> SplitTree.NewDirection + { + switch zone { + case .top: + return .top + case .bottom: + return .down + case .left: + return .left + case .right: + return .right + } + } + + func nextTabIndex() -> Int { + let prefix = "\(worktree.name) " + var maxIndex = 0 + for tab in tabManager.tabs { + guard tab.title.hasPrefix(prefix) else { continue } + let suffix = tab.title.dropFirst(prefix.count) + guard let value = Int(suffix) else { continue } + maxIndex = max(maxIndex, value) + } + return maxIndex + 1 + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+TabIcons.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+TabIcons.swift new file mode 100644 index 00000000..5826cbd9 --- /dev/null +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+TabIcons.swift @@ -0,0 +1,98 @@ +import Foundation + +extension WorktreeTerminalState { + // MARK: - Tab Icon Auto-Detection + // + // Strategy: each OSC 2 title change is matched against + // `CommandIconMap` (substring rules first, then first-token). A hit + // applies the icon immediately - no debounce. Rationale: the + // mapping is a curated allow-list, so a hit is by definition a + // command we're happy to brand the tab with; a miss leaves the + // existing icon untouched (selection-2 semantics). + // + // Idle-prompt suppression keeps the lookup focused on real + // commands: the first title after each `command_finished` is the + // shell's `precmd`-set prompt, and gets memorised into a learned- + // idle set so we never reach the mapping with a `user@host`-style + // string. Shape heuristics (`isLikelyIdleTitleByShape`) cover the + // bootstrap window before the learner has seen anything. + // + // The mapping-hit-equals-apply rule also unblocks short-lived + // commands (`git status`, `cd foo`) and TUIs that immediately + // overwrite their preexec title (`codex` -> repo name) - both used + // to slip past a debounce-based detector. + + func noteTitleForCommandDetection(_ rawTitle: String, surfaceId: UUID, tabId: TerminalTabID) { + let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return } + // Learn this surface's idle prompt: the first title after + // `command_finished` is reliably the precmd-set one. + if awaitingIdleTitleLearningBySurface.remove(surfaceId) != nil { + learnedIdleTitlesBySurface[surfaceId, default: []].insert(title) + } + // Drop idle prompts so they can't reach the mapping lookup. + if Self.isLikelyIdleTitleByShape(title) { return } + if learnedIdleTitlesBySurface[surfaceId]?.contains(title) == true { return } + guard let icon = CommandIconMap.iconForFirstToken(title) else { return } + applyResolvedIcon(icon, surfaceId: surfaceId, tabId: tabId) + } + + func noteCommandFinishedForCommandDetection(surfaceId: UUID) { + // Arm the idle-prompt learner: the next title arrival is the + // precmd-set prompt and should join the learned-idle set. + awaitingIdleTitleLearningBySurface.insert(surfaceId) + } + + /// Drop the per-surface detector state. Called when a surface is + /// closed or its parent tab is torn down so we don't retain + /// learned-idle sets keyed by ids that will never emit again. + func cleanupCommandDetectorState(forSurfaceId surfaceId: UUID) { + learnedIdleTitlesBySurface.removeValue(forKey: surfaceId) + awaitingIdleTitleLearningBySurface.remove(surfaceId) + } + + /// Heuristic shape-only detection for shell idle prompts. The + /// bootstrap filter - before `awaitingIdleTitleLearning` has caught + /// the precmd-set prompt at least once on this surface - for two + /// common forms: + /// 1. `user@host[:path]` - contains `@` plus `:` or `/`, no spaces. + /// 2. Pure path - starts with `~`, `/`, or `…`, no spaces. + /// Real commands typically contain a space (program + args) or a + /// short single token (`ls`, `claude`, `vim`) that doesn't match + /// either shape, so the false-negative risk is small. + /// + /// Exposed (`internal static`) for direct unit testing - does not + /// touch instance state. + static func isLikelyIdleTitleByShape(_ title: String) -> Bool { + guard !title.contains(" ") else { return false } + if title.contains("@"), title.contains(":") || title.contains("/") { + return true + } + if title.hasPrefix("~") || title.hasPrefix("/") || title.hasPrefix("…") { + return true + } + return false + } + + /// Apply an already-resolved icon to the tab. Honours focus, the user + /// icon lock, and the Run Script / Custom Command override; encodes + /// the icon through `storageString` so `assetName`-bearing entries + /// pick up the `@asset:` marker the renderers parse via + /// `ResolvedTabIcon`. + func applyResolvedIcon( + _ icon: TabIconSource, + surfaceId: UUID, + tabId: TerminalTabID + ) { + // Per-tab UI is single-headed: only the focused surface in a + // multi-split tab gets to drive its tab's icon. Stops a + // background split's command from silently overriding what the + // user is currently looking at. + guard focusedSurfaceIdByTab[tabId] == surfaceId else { return } + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + guard tab.iconLock == .auto else { return } + let serialised = icon.storageString + guard tab.icon != serialised else { return } + tabManager.updateIcon(tabId, icon: serialised) + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index c06fa971..f0b68355 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -5,16 +5,16 @@ import GhosttyKit import Observation import Sharing -private let terminalStateLogger = SupaLogger("TerminalState") -private let activeAgentDetectionInterval: Duration = .milliseconds(300) -private let idleAgentDetectionInterval: Duration = .seconds(2) +let terminalStateLogger = SupaLogger("TerminalState") +let activeAgentDetectionInterval: Duration = .milliseconds(300) +let idleAgentDetectionInterval: Duration = .seconds(2) -private enum TerminalCloseConfirmationMode { +enum TerminalCloseConfirmationMode { case prompt(TerminalCloseConfirmationTarget) case skip } -private enum TerminalCloseConfirmationTarget { +enum TerminalCloseConfirmationTarget { case pane case tab case tabs(count: Int) @@ -42,7 +42,7 @@ private enum TerminalCloseConfirmationTarget { } } -private struct AgentDetectionDiagnostic { +struct AgentDetectionDiagnostic { let tabId: TerminalTabID let childPID: pid_t? let processGroupID: pid_t? @@ -62,29 +62,29 @@ final class WorktreeTerminalState { } let tabManager: TerminalTabManager - private let runtime: GhosttyRuntime - private let worktree: Worktree + let runtime: GhosttyRuntime + let worktree: Worktree @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings - private var trees: [TerminalTabID: SplitTree] = [:] - private var surfaces: [UUID: GhosttySurfaceView] = [:] - private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] - private(set) var surfaceAgentStates: [UUID: PaneAgentState] = [:] - private var agentDetectionTasks: [UUID: Task] = [:] - private var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] - private var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] - private var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] - private var agentDetectionEnabled = true + var trees: [TerminalTabID: SplitTree] = [:] + var surfaces: [UUID: GhosttySurfaceView] = [:] + var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] + var surfaceAgentStates: [UUID: PaneAgentState] = [:] + var agentDetectionTasks: [UUID: Task] = [:] + var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] + var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] + var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] + var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] - private var surfaceRunningStartedAtById: [UUID: Date] = [:] - private var runScriptTabId: TerminalTabID? - private var pendingSetupScript: Bool - private var defaultFontSize: Float32? - private var isEnsuringInitialTab = false - private var lastReportedTaskStatus: WorktreeTaskStatus? - private var lastEmittedFocusSurfaceId: UUID? - private var lastWindowIsKey: Bool? - private var lastWindowIsVisible: Bool? + var surfaceRunningStartedAtById: [UUID: Date] = [:] + var runScriptTabId: TerminalTabID? + var pendingSetupScript: Bool + var defaultFontSize: Float32? + var isEnsuringInitialTab = false + var lastReportedTaskStatus: WorktreeTaskStatus? + var lastEmittedFocusSurfaceId: UUID? + var lastWindowIsKey: Bool? + var lastWindowIsVisible: Bool? /// When `true`, Canvas owns occlusion management for this state's surfaces. /// `syncFocusIfNeeded` skips `applySurfaceActivity` to avoid overriding /// Canvas-set occlusion with stale normal-mode window activity values. @@ -93,27 +93,27 @@ final class WorktreeTerminalState { var iconPickerTabId: TerminalTabID? var notifications: [WorktreeTerminalNotification] = [] var notificationsEnabled = true - private var commandFinishedNotificationEnabled = true - private var commandFinishedNotificationThreshold = 10 - private var lastKeyInputTimeBySurface: [UUID: ContinuousClock.Instant] = [:] - private var commandFinishedWaiters: [UUID: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation] = [:] + var commandFinishedNotificationEnabled = true + var commandFinishedNotificationThreshold = 10 + var lastKeyInputTimeBySurface: [UUID: ContinuousClock.Instant] = [:] + var commandFinishedWaiters: [UUID: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation] = [:] /// Surfaces that should auto-close on the next `command_finished` event with exit code 0. /// Populated by `markSurfaceForAutoClose` and consumed (one-shot) in `handleCommandFinished`. - private var autoCloseSurfaceIds: Set = [] + var autoCloseSurfaceIds: Set = [] /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success /// toast when the command exits with code 0. One-shot: removed on the first finish event. - private var pendingCustomCommands: [UUID: String] = [:] + var pendingCustomCommands: [UUID: String] = [:] /// Per-surface set of titles known to be the shell's idle prompt /// (the title `precmd` restores between commands). Populated by /// observing the first title that arrives after each /// `command_finished` — reliably the precmd-set prompt. Subsequent /// occurrences are skipped so they can't clobber the icon set by a /// real command. - private var learnedIdleTitlesBySurface: [UUID: Set] = [:] + var learnedIdleTitlesBySurface: [UUID: Set] = [:] /// Surfaces whose next title-change should be added to /// `learnedIdleTitlesBySurface`. Armed by `command_finished`, /// consumed by the next title arrival. - private var awaitingIdleTitleLearningBySurface: Set = [] + var awaitingIdleTitleLearningBySurface: Set = [] var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } @@ -436,7 +436,7 @@ final class WorktreeTerminalState { } } - private func applySurfaceActivity() { + func applySurfaceActivity() { terminalStateLogger.interval("applySurfaceActivity") { applySurfaceActivityImpl() } @@ -586,555 +586,6 @@ final class WorktreeTerminalState { } } - private func confirmCloseIfNeeded( - tabIds: [TerminalTabID], - mode: TerminalCloseConfirmationMode - ) -> Bool { - let surfaceIDs = tabIds.flatMap { tabId in - trees[tabId]?.leaves().map(\.id) ?? [] - } - return confirmCloseIfNeeded(surfaceIDs: surfaceIDs, mode: mode) - } - - private func confirmCloseIfNeeded( - surfaceIDs: [UUID], - mode: TerminalCloseConfirmationMode - ) -> Bool { - guard !surfaceIDs.isEmpty else { return true } - switch mode { - case .skip: - return true - case .prompt(let target): - let candidates = closeProtectionCandidates(surfaceIDs: surfaceIDs) - let decision = TerminalCloseConfirmationPolicy.decision(for: candidates) - guard decision.requiresConfirmation else { return true } - return presentCloseConfirmation(target: target, decision: decision) - } - } - - private func closeProtectionCandidates(surfaceIDs: [UUID]) -> [TerminalCloseProtectionCandidate] { - let now = Date() - return surfaceIDs.map { surfaceID in - let agentState = surfaceAgentStates[surfaceID] - let runningDuration = surfaceRunningStartedAtById[surfaceID].map { now.timeIntervalSince($0) } - return TerminalCloseProtectionCandidate( - hasAgent: agentState?.detectedAgent != nil, - agentDisplayState: agentState?.displayState, - commandRunningDuration: runningDuration - ) - } - } - - private func presentCloseConfirmation( - target: TerminalCloseConfirmationTarget, - decision: TerminalCloseConfirmationDecision - ) -> Bool { - let alert = NSAlert() - alert.messageText = target.messageText - alert.informativeText = closeConfirmationMessage(for: decision) - alert.alertStyle = .warning - alert.addButton(withTitle: target.confirmButtonTitle) - alert.addButton(withTitle: "Cancel") - return alert.runModal() == .alertFirstButtonReturn - } - - private func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { - let paneText = decision.protectedPaneCount == 1 ? "pane" : "panes" - let reasonText: String - if decision.reasons == Set([.agentActive]) { - reasonText = "active agent work or an unseen agent result" - } else if decision.reasons == Set([.longRunningCommand]) { - reasonText = "a command that has been running for at least 10 seconds" - } else { - reasonText = "active agent work, unseen agent results, or long-running commands" - } - return "This will close \(decision.protectedPaneCount) \(paneText) with \(reasonText)." - } - - func splitTree( - for tabId: TerminalTabID, - inheritingFromSurfaceId: UUID? = nil, - initialInput: String? = nil, - workingDirectoryOverride: URL? = nil, - context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB - ) -> SplitTree { - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { - return SplitTree() - } - if let existing = trees[tabId] { - return existing - } - let surface = createSurface( - tabId: tabId, - initialInput: initialInput, - inheritingFromSurfaceId: inheritingFromSurfaceId, - workingDirectoryOverride: workingDirectoryOverride, - context: context - ) - let tree = SplitTree(view: surface) - trees[tabId] = tree - focusedSurfaceIdByTab[tabId] = surface.id - return tree - } - - /// Splits the currently focused surface and seeds the new pane with `initialInput`. - /// Returns the new surface id, or nil if the split could not be created. - @discardableResult - func createSplitOnFocusedSurface( - direction: UserCustomSplitDirection, - initialInput: String - ) -> UUID? { - guard let tabId = tabManager.selectedTabId, - let parentSurfaceId = focusedSurfaceIdByTab[tabId], - let tree = trees[tabId], - let parentSurface = surfaces[parentSurfaceId] - else { - return nil - } - let newSurface = createSurface( - tabId: tabId, - initialInput: runScriptInput(initialInput), - inheritingFromSurfaceId: parentSurfaceId, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - do { - let newTree = try tree.inserting( - view: newSurface, - at: parentSurface, - direction: mapUserSplitDirection(direction) - ) - updateTree(newTree, for: tabId) - if isCanvasManaged { - newSurface.setOcclusion(true) - } - focusSurface(newSurface, in: tabId) - return newSurface.id - } catch { - newSurface.closeSurface() - surfaces.removeValue(forKey: newSurface.id) - surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) - cleanupCommandDetectorState(forSurfaceId: newSurface.id) - cleanupAgentDetectionState(forSurfaceId: newSurface.id) - return nil - } - } - - /// Returns the focused surface id for a given tab, if any. - func focusedSurfaceId(in tabId: TerminalTabID) -> UUID? { - focusedSurfaceIdByTab[tabId] - } - - func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { - focusedSurfaceIdByTab[tabId] - } - - /// Marks a surface so that its next successful `command_finished` event (exit 0) - /// will trigger a one-shot close of that surface. - func markSurfaceForAutoClose(_ surfaceId: UUID) { - autoCloseSurfaceIds.insert(surfaceId) - } - - func isMarkedForAutoClose(_ surfaceId: UUID) -> Bool { - autoCloseSurfaceIds.contains(surfaceId) - } - - /// Records the user-facing Custom Command name associated with a freshly created surface, - /// so a success toast can be emitted when that surface's next command exits with code 0. - func markSurfaceForCustomCommand(_ surfaceId: UUID, name: String) { - pendingCustomCommands[surfaceId] = name - } - - /// Pin a Custom Command's configured icon onto its host tab so the - /// auto-detector can't swap it out when the script's OSC-2 title - /// matches a known command. Yields to a user-set icon lock — manual - /// picker selections always win. - func applyCustomCommandIcon(_ icon: String, surfaceId: UUID) { - let trimmed = icon.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard let tabId = tabId(containing: surfaceId) else { return } - tabManager.setScriptIcon(tabId, icon: trimmed) - } - - // Short delay lets the user see the final output before the pane disappears. - private static let autoCloseDelay: Duration = .milliseconds(800) - - private func scheduleAutoClose(surfaceId: UUID) { - Task { [weak self] in - try? await Task.sleep(for: Self.autoCloseDelay) - guard let self else { return } - guard let view = self.surfaces[surfaceId] else { return } - self.handleCloseRequest(for: view, processAlive: false) - } - } - - func performSplitAction(_ action: GhosttySplitAction, for surfaceId: UUID) -> Bool { - guard let tabId = tabId(containing: surfaceId), var tree = trees[tabId] else { - return false - } - guard let targetNode = tree.find(id: surfaceId) else { return false } - guard let targetSurface = surfaces[surfaceId] else { return false } - - switch action { - case .newSplit(let direction): - let newSurface = createSurface( - tabId: tabId, - initialInput: nil, - inheritingFromSurfaceId: surfaceId, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - do { - let newTree = try tree.inserting( - view: newSurface, - at: targetSurface, - direction: mapSplitDirection(direction) - ) - updateTree(newTree, for: tabId) - // Canvas manages occlusion directly; ensure the new pane renders. - if isCanvasManaged { - newSurface.setOcclusion(true) - } - focusSurface(newSurface, in: tabId) - return true - } catch { - newSurface.closeSurface() - surfaces.removeValue(forKey: newSurface.id) - surfaceRunningStartedAtById.removeValue(forKey: newSurface.id) - cleanupCommandDetectorState(forSurfaceId: newSurface.id) - cleanupAgentDetectionState(forSurfaceId: newSurface.id) - - return false - } - - case .gotoSplit(let direction): - let focusDirection = mapFocusDirection(direction) - guard let nextSurface = tree.focusTarget(for: focusDirection, from: targetNode) else { - return false - } - if tree.zoomed != nil { - tree = tree.settingZoomed(nil) - trees[tabId] = tree - } - focusSurface(nextSurface, in: tabId) - syncFocusIfNeeded() - return true - - case .resizeSplit(let direction, let amount): - let spatialDirection = mapResizeDirection(direction) - do { - let newTree = try tree.resizing( - node: targetNode, - by: amount, - in: spatialDirection, - with: CGRect(origin: .zero, size: tree.viewBounds()) - ) - updateTree(newTree, for: tabId) - return true - } catch { - return false - } - - case .equalizeSplits: - updateTree(tree.equalized(), for: tabId) - return true - - case .toggleSplitZoom: - guard tree.isSplit else { return false } - let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode - updateTree(tree.settingZoomed(newZoomed), for: tabId) - focusSurface(targetSurface, in: tabId) - return true - } - } - - func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TerminalTabID) { - guard var tree = trees[tabId] else { return } - - switch operation { - case .resize(let node, let ratio): - let resizedNode = node.resizing(to: ratio) - do { - tree = try tree.replacing(node: node, with: resizedNode) - updateTree(tree, for: tabId) - } catch { - return - } - - case .drop(let payloadId, let destinationId, let zone): - guard let payload = surfaces[payloadId] else { return } - guard let destination = surfaces[destinationId] else { return } - if payload === destination { return } - guard let sourceNode = tree.root?.node(view: payload) else { return } - let treeWithoutSource = tree.removing(sourceNode) - if treeWithoutSource.isEmpty { return } - do { - let newTree = try treeWithoutSource.inserting( - view: payload, - at: destination, - direction: mapDropZone(zone) - ) - updateTree(newTree, for: tabId) - focusSurface(payload, in: tabId) - } catch { - return - } - - case .equalize: - updateTree(tree.equalized(), for: tabId) - } - } - - func setAllSurfacesOccluded() { - for surface in surfaces.values { - surface.setOcclusion(false) - surface.focusDidChange(false) - } - } - - func closeAllSurfaces() { - for surface in surfaces.values { - surface.closeSurface() - } - surfaces.removeAll() - trees.removeAll() - focusedSurfaceIdByTab.removeAll() - cleanupAllAgentDetectionState() - tabIsRunningById.removeAll() - autoCloseSurfaceIds.removeAll() - pendingCustomCommands.removeAll() - setRunScriptTabId(nil) - tabManager.closeAll() - } - - func makeLayoutSnapshotWorktree() -> TerminalLayoutSnapshotPayload.SnapshotWorktree? { - terminalStateLogger.info( - "[LayoutRestore] makeSnapshot: worktree=\(worktree.id) tabs=\(tabManager.tabs.count)" - ) - guard !tabManager.tabs.isEmpty else { - terminalStateLogger.info("[LayoutRestore] makeSnapshot: no tabs, returning nil") - return nil - } - - var snapshotTabs: [TerminalLayoutSnapshotPayload.SnapshotTab] = [] - snapshotTabs.reserveCapacity(tabManager.tabs.count) - for tab in tabManager.tabs { - guard let tree = trees[tab.id], let root = tree.root else { - terminalStateLogger.warning( - "[LayoutRestore] makeSnapshot: no tree/root for tab \(tab.id.rawValue.uuidString)" - ) - return nil - } - guard let splitRoot = makeLayoutSnapshotNode(from: root) else { - terminalStateLogger.warning( - "[LayoutRestore] makeSnapshot: failed to snapshot split tree for tab \(tab.id.rawValue.uuidString)" - ) - return nil - } - // Skip title/icon for blocking-script tabs as they are transient. - // Persist the icon only when the user has explicitly overridden it; otherwise - // restore should pick up the current default ("terminal") or auto-detection. - let isBlockingScriptTab = tab.id == runScriptTabId - let snapshotIcon: String? = (isBlockingScriptTab || tab.iconLock != .user) ? nil : tab.icon - snapshotTabs.append( - TerminalLayoutSnapshotPayload.SnapshotTab( - tabID: tab.id.rawValue.uuidString, - title: isBlockingScriptTab ? nil : tab.title, - customTitle: isBlockingScriptTab ? nil : tab.customTitle, - icon: snapshotIcon, - splitRoot: splitRoot - ) - ) - } - - let result = TerminalLayoutSnapshotPayload.SnapshotWorktree( - worktreeID: worktree.id, - selectedTabID: tabManager.selectedTabId?.rawValue.uuidString, - tabs: snapshotTabs - ) - terminalStateLogger.info( - "[LayoutRestore] makeSnapshot: success, \(snapshotTabs.count) tab(s) captured" - ) - return result - } - - func applyLayoutSnapshot(_ snapshot: TerminalLayoutSnapshotPayload.SnapshotWorktree) -> Bool { - terminalStateLogger.info( - "[LayoutRestore] applySnapshot: worktree=\(worktree.id)" - + " snapshotWorktreeID=\(snapshot.worktreeID) tabs=\(snapshot.tabs.count)" - ) - guard snapshot.worktreeID == worktree.id else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: worktreeID mismatch") - return false - } - - // Validate snapshot structure before creating any surfaces. - var validatedTabs: [(tabID: TerminalTabID, snapshotTab: TerminalLayoutSnapshotPayload.SnapshotTab)] = [] - var seenTabIDs: Set = [] - for snapshotTab in snapshot.tabs { - guard let tabUUID = UUID(uuidString: snapshotTab.tabID) else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid tab UUID \(snapshotTab.tabID)") - return false - } - let tabID = TerminalTabID(rawValue: tabUUID) - guard seenTabIDs.insert(tabID).inserted else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: duplicate tab ID \(snapshotTab.tabID)") - return false - } - validatedTabs.append((tabID: tabID, snapshotTab: snapshotTab)) - } - - let selectedTabID: TerminalTabID? - if let selectedTabRaw = snapshot.selectedTabID { - guard let selectedUUID = UUID(uuidString: selectedTabRaw) else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: invalid selectedTab UUID \(selectedTabRaw)") - return false - } - let candidate = TerminalTabID(rawValue: selectedUUID) - guard seenTabIDs.contains(candidate) else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: selectedTab not in restored tabs") - return false - } - selectedTabID = candidate - } else { - selectedTabID = validatedTabs.first?.tabID - } - - // Close existing surfaces BEFORE creating new ones so new surfaces - // don't get destroyed by closeAllSurfaces(). - terminalStateLogger.info("[LayoutRestore] applySnapshot: closing existing surfaces before restore") - closeAllSurfaces() - - // Now create new surfaces into the clean state. - var restoredTabs: [TerminalTabItem] = [] - var restoredTrees: [TerminalTabID: SplitTree] = [:] - var restoredFocusedSurfaceIDs: [TerminalTabID: UUID] = [:] - - for (index, entry) in validatedTabs.enumerated() { - terminalStateLogger.info( - "[LayoutRestore] applySnapshot: restoring tab[\(index)] id=\(entry.snapshotTab.tabID)" - ) - guard - let rootNode = restoreSplitNode(from: entry.snapshotTab.splitRoot, tabID: entry.tabID, isRoot: true) - else { - terminalStateLogger.warning("[LayoutRestore] applySnapshot: restoreSplitNode failed for tab[\(index)]") - closeAllSurfaces() - return false - } - let tree = SplitTree.restored(root: rootNode) - restoredTrees[entry.tabID] = tree - restoredFocusedSurfaceIDs[entry.tabID] = rootNode.leftmostLeaf().id - restoredTabs.append( - TerminalTabItem( - id: entry.tabID, - title: entry.snapshotTab.title ?? "\(worktree.name) \(index + 1)", - customTitle: entry.snapshotTab.customTitle, - icon: entry.snapshotTab.icon ?? "terminal", - isTitleLocked: false, - iconLock: entry.snapshotTab.icon != nil ? .user : .auto - ) - ) - } - - trees = restoredTrees - focusedSurfaceIdByTab = restoredFocusedSurfaceIDs - tabIsRunningById = Dictionary(uniqueKeysWithValues: restoredTabs.map { ($0.id, false) }) - tabManager.tabs = restoredTabs - tabManager.selectedTabId = selectedTabID - setRunScriptTabId(nil) - - // Explicitly unfocus all restored surfaces so only the focused one blinks. - for surface in surfaces.values { - surface.focusDidChange(false) - } - if let selectedTabID { - focusSurface(in: selectedTabID) - } else { - lastEmittedFocusSurfaceId = nil - } - emitTaskStatusIfChanged() - // Signal "this worktree now has tabs" so downstream Shelf - // bookkeeping (`markWorktreeOpened` via `terminalEvent(.tabCreated)`) - // adds the restored worktree to `openedWorktreeIDs`. Without this - // emit, only the active worktree (which goes through - // `.selectWorktree` on `.layoutRestored`) shows as a book on the - // Shelf — every other restored worktree is missing, even though - // the sidebar lists it and its terminal state is live. - if !restoredTabs.isEmpty { - onTabCreated?() - } - terminalStateLogger.info( - "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)" - + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")" - ) - return true - } - - func setNotificationsEnabled(_ enabled: Bool) { - notificationsEnabled = enabled - if !enabled { - markAllNotificationsRead() - } - } - - func setCommandFinishedNotification(enabled: Bool, threshold: Int) { - commandFinishedNotificationEnabled = enabled - commandFinishedNotificationThreshold = threshold - } - - func setAgentDetectionEnabled(_ enabled: Bool) { - guard agentDetectionEnabled != enabled else { return } - agentDetectionEnabled = enabled - - if enabled { - for (surfaceID, view) in surfaces { - guard let tabId = tabId(containing: surfaceID) else { continue } - startAgentDetection(for: view, tabId: tabId) - } - } else { - cleanupAllAgentDetectionState() - } - } - - func clearNotificationIndicator() { - markAllNotificationsRead() - } - - func markAllNotificationsRead() { - let previousHasUnseen = hasUnseenNotification - for index in notifications.indices { - notifications[index].isRead = true - } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - - func markNotificationsRead(forSurfaceID surfaceID: UUID) { - let previousHasUnseen = hasUnseenNotification - for index in notifications.indices where notifications[index].surfaceId == surfaceID { - notifications[index].isRead = true - } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - - func markNotificationRead(id notificationID: WorktreeTerminalNotification.ID) { - let previousHasUnseen = hasUnseenNotification - guard let index = notifications.firstIndex(where: { $0.id == notificationID }) else { - return - } - notifications[index].isRead = true - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - - func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { - let previousHasUnseen = hasUnseenNotification - notifications.removeAll { $0.id == notificationID } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - - func dismissAllNotifications() { - let previousHasUnseen = hasUnseenNotification - notifications.removeAll() - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - func needsSetupScript() -> Bool { pendingSetupScript } @@ -1160,7 +611,7 @@ final class WorktreeTerminalState { makeCommandInput(script: script) } - private func runScriptInput(_ script: String) -> String? { + func runScriptInput(_ script: String) -> String? { formatCommandInput(script) } @@ -1172,7 +623,7 @@ final class WorktreeTerminalState { makeBlockingScriptInput(script: script) } - private func setRunScriptTabId(_ tabId: TerminalTabID?) { + func setRunScriptTabId(_ tabId: TerminalTabID?) { let wasRunning = runScriptTabId != nil runScriptTabId = tabId let isRunning = tabId != nil @@ -1181,1228 +632,4 @@ final class WorktreeTerminalState { } } - private func createSurface( - tabId: TerminalTabID, - initialInput: String?, - inheritingFromSurfaceId: UUID?, - workingDirectoryOverride: URL? = nil, - context: ghostty_surface_context_e - ) -> GhosttySurfaceView { - let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) - let resolvedFontSize = Self.resolvedFontSizeForNewSurface( - defaultFontSize: defaultFontSize, - inheritedFontSize: inherited.fontSize, - context: context - ) - let view = GhosttySurfaceView( - runtime: runtime, - workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, - initialInput: initialInput, - fontSize: resolvedFontSize, - context: context, - environment: worktree.scriptEnvironment - ) - // Sending a no-op font size action marks the Ghostty surface as - // "font_size_adjusted", which prevents config reloads (triggered by - // keybind changes on worktree switch) from resetting the font to the - // config default. - if resolvedFontSize != nil { - view.performBindingAction("increase_font_size:0") - } - configureBridgeCallbacks(for: view, tabId: tabId) - configureSurfaceCallbacks(for: view, tabId: tabId) - surfaces[view.id] = view - startAgentDetection(for: view, tabId: tabId) - return view - } - - private func configureBridgeCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { - view.bridge.onTitleChange = { [weak self, weak view] title in - guard let self, let view else { return } - if self.focusedSurfaceIdByTab[tabId] == view.id { - if self.tabManager.updateTitle(tabId, title: title) { - self.refreshAgentEntriesForTitleChange(in: tabId) - } - } - self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) - } - view.bridge.onSplitAction = { [weak self, weak view] action in - guard let self, let view else { return false } - return self.performSplitAction(action, for: view.id) - } - view.bridge.onNewTab = { [weak self, weak view] in - guard let self, let view else { return false } - return self.createTab(inheritingFromSurfaceId: view.id) != nil - } - view.bridge.onCloseTab = { [weak self] _ in - guard let self else { return false } - return self.closeTab(tabId) - } - view.bridge.onGotoTab = { [weak self] target in - guard let self else { return false } - return self.handleGotoTabRequest(target) - } - view.bridge.onCommandPaletteToggle = { [weak self] in - guard let self else { return false } - self.onCommandPaletteToggle?() - return true - } - view.bridge.onProgressReport = { [weak self] _ in - guard let self else { return } - self.updateRunningState(for: tabId) - } - view.bridge.onDesktopNotification = { [weak self, weak view] title, body in - guard let self, let view else { return } - self.appendNotification(title: title, body: body, surfaceId: view.id) - } - view.bridge.onCommandFinished = { [weak self, weak view] exitCode, durationNs in - guard let self, let view else { return } - self.handleCommandFinished(exitCode: exitCode, durationNs: durationNs, surfaceId: view.id) - } - view.bridge.onCloseRequest = { [weak self, weak view] processAlive in - guard let self, let view else { return } - self.handleCloseRequest(for: view, processAlive: processAlive) - } - view.bridge.onPromptTitle = { [weak self] promptType in - guard let self else { return } - self.handlePromptTitle(promptType, tabId: tabId) - } - } - - private func configureSurfaceCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { - view.onFocusChange = { [weak self, weak view] focused in - guard let self, let view, focused else { return } - self.recordActiveSurface(view, in: tabId) - self.emitTaskStatusIfChanged() - } - view.onKeyInput = { [weak self, weak view] in - guard let self, let view else { return } - self.recordKeyInput(forSurfaceID: view.id) - self.markNotificationsRead(forSurfaceID: view.id) - } - view.onFontSizeShortcut = { [weak self] in - guard let self else { return } - self.onFontSizeAdjusted?() - } - } - - static func resolvedFontSizeForNewSurface( - defaultFontSize: Float32?, - inheritedFontSize: Float32?, - context: ghostty_surface_context_e - ) -> Float32? { - if context == GHOSTTY_SURFACE_CONTEXT_SPLIT { - return inheritedFontSize ?? defaultFontSize - } - return defaultFontSize - } - - static func resolveSnapshotWorkingDirectory( - from snapshotPath: String?, - worktreeRoot: URL, - fileManager: FileManager = .default - ) -> URL? { - guard let snapshotPath, - let normalizedPath = PathPolicy.normalizePath(snapshotPath, relativeTo: worktreeRoot) - else { - return nil - } - - let normalizedURL = URL(fileURLWithPath: normalizedPath).standardizedFileURL - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: normalizedPath, isDirectory: &isDirectory), isDirectory.boolValue else { - return nil - } - guard PathPolicy.contains(normalizedURL, in: worktreeRoot) else { - return nil - } - return normalizedURL - } - - private struct InheritedSurfaceConfig: Equatable { - let workingDirectory: URL? - let fontSize: Float32? - } - - private func inheritedSurfaceConfig( - fromSurfaceId surfaceId: UUID?, - context: ghostty_surface_context_e - ) -> InheritedSurfaceConfig { - guard let surfaceId, - let view = surfaces[surfaceId], - let sourceSurface = view.surface - else { - return InheritedSurfaceConfig(workingDirectory: nil, fontSize: nil) - } - - let inherited = ghostty_surface_inherited_config(sourceSurface, context) - let fontSize = inherited.font_size == 0 ? nil : inherited.font_size - let workingDirectory = inherited.working_directory.flatMap { ptr -> URL? in - let path = String(cString: ptr) - if path.isEmpty { - return nil - } - return URL(fileURLWithPath: path, isDirectory: true) - } - return InheritedSurfaceConfig(workingDirectory: workingDirectory, fontSize: fontSize) - } - - private func currentFocusedSurfaceId() -> UUID? { - guard let selectedTabId = tabManager.selectedTabId else { return nil } - return focusedSurfaceIdByTab[selectedTabId] - } - - private func handlePromptTitle( - _ promptType: ghostty_action_prompt_title_e, - tabId: TerminalTabID - ) { - guard let surfaceId = focusedSurfaceIdByTab[tabId], - let window = surfaces[surfaceId]?.window - else { return } - switch promptType { - case GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB: - // Prowl is a single-window app so there is no per-surface window title to set. - // Both surface and tab title prompts are treated as tab title changes for now. - // Consider removing GHOSTTY_PROMPT_TITLE_SURFACE support entirely. - promptTabTitle(for: tabId, in: window) - default: - break - } - } - - func promptChangeTabTitle(_ tabId: TerminalTabID) { - let surfaceWindow = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0]?.window } - guard let window = surfaceWindow ?? NSApp.keyWindow else { return } - promptTabTitle(for: tabId, in: window) - } - - func presentIconPicker(for tabId: TerminalTabID) { - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return } - iconPickerTabId = tabId - } - - func presentIconPickerForFocusedTab() { - guard let tabId = tabManager.selectedTabId else { return } - presentIconPicker(for: tabId) - } - - func dismissIconPicker() { - iconPickerTabId = nil - } - - /// Default SF Symbol used for a tab when the user has not set an override. - func defaultIcon(for tabId: TerminalTabID) -> String { - tabId == runScriptTabId ? "play.fill" : "terminal" - } - - /// Apply an icon change for `tabId`. Pass `nil` to clear the override and - /// restore the tab's default icon. - func applyIconChange(_ tabId: TerminalTabID, icon newIcon: String?) { - if let newIcon, !newIcon.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - tabManager.overrideIcon(tabId, icon: newIcon) - } else { - tabManager.clearIconOverride(tabId) - tabManager.updateIcon(tabId, icon: defaultIcon(for: tabId)) - } - } - - private func promptTabTitle(for tabId: TerminalTabID, in window: NSWindow) { - guard let tabIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - - let alert = NSAlert() - alert.messageText = "Change Tab Title" - alert.informativeText = "Leave blank to restore the default." - alert.alertStyle = .informational - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) - textField.stringValue = tabManager.tabs[tabIndex].displayTitle - alert.accessoryView = textField - - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - alert.window.initialFirstResponder = textField - - alert.beginSheetModal(for: window) { [weak self] response in - MainActor.assumeIsolated { - guard response == .alertFirstButtonReturn else { return } - guard let self else { return } - let newTitle = textField.stringValue - if self.tabManager.setCustomTitle(tabId, title: newTitle) { - self.refreshAgentEntriesForTitleChange(in: tabId) - } - } - } - } - - private func updateTabTitle(for tabId: TerminalTabID) { - guard let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId], - let title = surface.bridge.state.title - else { return } - if tabManager.updateTitle(tabId, title: title) { - refreshAgentEntriesForTitleChange(in: tabId) - } - } - - private func focusSurface(in tabId: TerminalTabID) { - if let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] { - focusSurface(surface, in: tabId) - return - } - let tree = splitTree(for: tabId) - if let surface = tree.visibleLeaves().first { - focusSurface(surface, in: tabId) - } - } - - private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { - let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } - recordActiveSurface(surface, in: tabId) - guard tabId == tabManager.selectedTabId else { return } - let fromSurface = (previousSurface === surface) ? nil : previousSurface - GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) - } - - private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { - focusedSurfaceIdByTab[tabId] = surface.id - markAgentSeen(surfaceID: surface.id) - markNotificationsRead(forSurfaceID: surface.id) - updateTabTitle(for: tabId) - emitFocusChangedIfNeeded(surface.id) - } - - private func appendNotification(title: String, body: String, surfaceId: UUID) { - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } - if notificationsEnabled { - let previousHasUnseen = hasUnseenNotification - let isRead = isSelected() && isFocusedSurface(surfaceId) - notifications.insert( - WorktreeTerminalNotification( - surfaceId: surfaceId, - title: trimmedTitle, - body: trimmedBody, - createdAt: Date(), - isRead: isRead - ), - at: 0 - ) - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody) - } - - /// How recently the user must have typed for us to consider the exit user-initiated. - static let recentInteractionWindow: Duration = .seconds(3) - - private func makeLayoutSnapshotNode( - from node: SplitTree.Node - ) -> TerminalLayoutSnapshotPayload.SnapshotSplitNode? { - switch node { - case .leaf(let view): - let cwdPath = inheritedSurfaceConfig( - fromSurfaceId: view.id, - context: GHOSTTY_SURFACE_CONTEXT_TAB - ).workingDirectory?.path(percentEncoded: false) - return .leaf(surfaceID: view.id.uuidString, cwdPath: cwdPath) - case .split(let split): - guard let left = makeLayoutSnapshotNode(from: split.left) else { - return nil - } - guard let right = makeLayoutSnapshotNode(from: split.right) else { - return nil - } - return .split( - direction: snapshotSplitDirection(from: split.direction), - ratio: split.ratio, - children: [left, right] - ) - } - } - - private func restoreSplitNode( - from snapshotNode: TerminalLayoutSnapshotPayload.SnapshotSplitNode, - tabID: TerminalTabID, - isRoot: Bool - ) -> SplitTree.Node? { - switch snapshotNode.kind { - case .leaf: - guard let surfaceID = snapshotNode.surfaceID, - !surfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return nil - } - let context: ghostty_surface_context_e = isRoot ? GHOSTTY_SURFACE_CONTEXT_TAB : GHOSTTY_SURFACE_CONTEXT_SPLIT - let restoredWorkingDirectory = Self.resolveSnapshotWorkingDirectory( - from: snapshotNode.cwdPath, - worktreeRoot: worktree.workingDirectory - ) - let view = createSurface( - tabId: tabID, - initialInput: nil, - inheritingFromSurfaceId: nil, - workingDirectoryOverride: restoredWorkingDirectory, - context: context - ) - return .leaf(view: view) - case .split: - guard let direction = snapshotNode.direction else { - return nil - } - guard let ratio = snapshotNode.ratio, ratio > 0, ratio < 1 else { - return nil - } - let clampedRatio = max(0.1, min(0.9, ratio)) - guard let children = snapshotNode.children, children.count == 2 else { - return nil - } - guard let left = restoreSplitNode(from: children[0], tabID: tabID, isRoot: false) else { - return nil - } - guard let right = restoreSplitNode(from: children[1], tabID: tabID, isRoot: false) else { - return nil - } - return .split( - .init( - direction: splitDirection(from: direction), - ratio: clampedRatio, - left: left, - right: right - ) - ) - } - } - - private func snapshotSplitDirection( - from direction: SplitTree.Direction - ) -> TerminalLayoutSnapshotSplitDirection { - switch direction { - case .horizontal: - .horizontal - case .vertical: - .vertical - } - } - - private func splitDirection( - from direction: TerminalLayoutSnapshotSplitDirection - ) -> SplitTree.Direction { - switch direction { - case .horizontal: - .horizontal - case .vertical: - .vertical - } - } - - func recordKeyInput(forSurfaceID surfaceId: UUID) { - lastKeyInputTimeBySurface[surfaceId] = .now - } - - func handleCommandFinished(exitCode: Int?, durationNs: UInt64, surfaceId: UUID) { - // Notify CLI waiters unconditionally before applying notification filters. - if let continuation = commandFinishedWaiters.removeValue(forKey: surfaceId) { - let durationMs = Int(durationNs / 1_000_000) - continuation.yield((exitCode: exitCode, durationMs: durationMs)) - continuation.finish() - } - - surfaceRunningStartedAtById.removeValue(forKey: surfaceId) - noteCommandFinishedForCommandDetection(surfaceId: surfaceId) - - // Custom command success toast. One-shot: removed regardless of outcome. - if let commandName = pendingCustomCommands.removeValue(forKey: surfaceId), exitCode == 0 { - let durationMs = Int(durationNs / 1_000_000) - onCustomCommandSucceeded?(commandName, durationMs) - } - - // Auto-close on success (exit 0). One-shot: the id is removed regardless of outcome. - if autoCloseSurfaceIds.remove(surfaceId) != nil { - if exitCode == 0, surfaces[surfaceId] != nil { - scheduleAutoClose(surfaceId: surfaceId) - return - } - } - - guard commandFinishedNotificationEnabled else { return } - let durationSeconds = Int(durationNs / 1_000_000_000) - guard durationSeconds >= commandFinishedNotificationThreshold else { return } - // Skip user-initiated termination (Ctrl+C / kill signal) - if let code = exitCode, code == 130 || code == 143 { return } - // Skip if the user was recently typing in this surface (e.g. /exit, quit) - if let lastInput = lastKeyInputTimeBySurface[surfaceId], - ContinuousClock.now - lastInput < Self.recentInteractionWindow - { - return - } - - let title = (exitCode == nil || exitCode == 0) ? "Command finished" : "Command failed" - let formattedDuration = Self.formatDuration(durationSeconds) - let body: String - if let code = exitCode, code != 0 { - body = "Failed (exit code \(code)) after \(formattedDuration)" - } else { - body = "Completed in \(formattedDuration)" - } - appendNotification(title: title, body: body, surfaceId: surfaceId) - } - - // MARK: - Tab Icon Auto-Detection - // - // Strategy: each OSC 2 title change is matched against - // `CommandIconMap` (substring rules first, then first-token). A hit - // applies the icon immediately — no debounce. Rationale: the - // mapping is a curated allow-list, so a hit is by definition a - // command we're happy to brand the tab with; a miss leaves the - // existing icon untouched (selection-2 semantics). - // - // Idle-prompt suppression keeps the lookup focused on real - // commands: the first title after each `command_finished` is the - // shell's `precmd`-set prompt, and gets memorised into a learned- - // idle set so we never reach the mapping with a `user@host`-style - // string. Shape heuristics (`isLikelyIdleTitleByShape`) cover the - // bootstrap window before the learner has seen anything. - // - // The mapping-hit-equals-apply rule also unblocks short-lived - // commands (`git status`, `cd foo`) and TUIs that immediately - // overwrite their preexec title (`codex` → repo name) — both used - // to slip past a debounce-based detector. - - func noteTitleForCommandDetection(_ rawTitle: String, surfaceId: UUID, tabId: TerminalTabID) { - let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty else { return } - // Learn this surface's idle prompt: the first title after - // `command_finished` is reliably the precmd-set one. - if awaitingIdleTitleLearningBySurface.remove(surfaceId) != nil { - learnedIdleTitlesBySurface[surfaceId, default: []].insert(title) - } - // Drop idle prompts so they can't reach the mapping lookup. - if Self.isLikelyIdleTitleByShape(title) { return } - if learnedIdleTitlesBySurface[surfaceId]?.contains(title) == true { return } - guard let icon = CommandIconMap.iconForFirstToken(title) else { return } - applyResolvedIcon(icon, surfaceId: surfaceId, tabId: tabId) - } - - func noteCommandFinishedForCommandDetection(surfaceId: UUID) { - // Arm the idle-prompt learner: the next title arrival is the - // precmd-set prompt and should join the learned-idle set. - awaitingIdleTitleLearningBySurface.insert(surfaceId) - } - - /// Drop the per-surface detector state. Called when a surface is - /// closed or its parent tab is torn down so we don't retain - /// learned-idle sets keyed by ids that will never emit again. - func cleanupCommandDetectorState(forSurfaceId surfaceId: UUID) { - learnedIdleTitlesBySurface.removeValue(forKey: surfaceId) - awaitingIdleTitleLearningBySurface.remove(surfaceId) - } - - private func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { - guard agentDetectionEnabled else { return } - agentDetectionTasks[view.id]?.cancel() - surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) - agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in - while !Task.isCancelled { - guard let self, let view, self.surfaces[view.id] != nil else { return } - await self.detectAgentState(for: view, tabId: tabId) - let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil - try? await Task.sleep(for: hasAgent ? activeAgentDetectionInterval : idleAgentDetectionInterval) - } - } - } - - private func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async { - let surfaceID = view.id - let childPID = view.bridge.childPID() - let processGroupID = view.bridge.foregroundProcessGroupID() - let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) - guard surfaces[surfaceID] != nil else { return } - - let identified = job.flatMap { identifyAgentInJob($0) } - let probedAgent = identified?.agent - - var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() - let agent = presence.update(detectedAgent: probedAgent) - agentDetectionPresenceBySurface[surfaceID] = presence - - guard let agent else { - // Only log the moment we lose a previously detected agent; pre-agent - // shells churn process lists every command and would otherwise spam. - if surfaceAgentStates[surfaceID]?.detectedAgent != nil { - logAgentDetectionDiagnostic( - surfaceID: surfaceID, - diagnostic: AgentDetectionDiagnostic( - tabId: tabId, - childPID: childPID, - processGroupID: processGroupID, - job: job, - identified: identified, - retainedAgent: nil, - raw: nil, - stabilized: nil - ) - ) - } - removeAgentEntryIfNeeded(surfaceID: surfaceID) - return - } - - let now = Date() - let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now) - let activeText = view.bridge.readActiveText() ?? "" - // `detectState` is a `nonisolated` pure function that runs in well under a - // millisecond on a terminal-sized active screen, so the prior `Task.detached` hop - // bought nothing but allocator churn. In long sessions, each detection - // tick (300 ms or 2 s per surface) was leaving a task stack + closure - // capture behind that never reached ARC; over a 24 h session this added - // up to hundreds of MB of unreferenced allocations. - let raw = agent.detectState(in: activeText) - guard surfaces[surfaceID] != nil else { return } - - var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] - let stabilized = stabilizeAgentState( - agent: agent, - previous: previous.state, - raw: raw, - now: now, - lastClaudeWorkingAt: &lastClaudeWorkingAt - ) - lastClaudeWorkingAtBySurface[surfaceID] = lastClaudeWorkingAt - - let isForeground = isSelected() && isFocusedSurface(surfaceID) - let becameIdleFromActive = - (previous.state == .working || previous.state == .blocked) - && stabilized == .idle - let seen: Bool - if isForeground || stabilized == .blocked { - seen = true - } else if becameIdleFromActive { - seen = false - } else { - seen = previous.seen - } - let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt - let next = PaneAgentState( - detectedAgent: agent, - fallbackState: raw, - state: stabilized, - seen: seen, - lastChangedAt: lastChangedAt - ) - // Limit logging to meaningful transitions — agent identity or - // stabilized state changes. Raw oscillation and `seen` flips are - // routine and would otherwise dominate the log stream. - if previous.detectedAgent != agent || previous.state != stabilized { - logAgentDetectionDiagnostic( - surfaceID: surfaceID, - diagnostic: AgentDetectionDiagnostic( - tabId: tabId, - childPID: childPID, - processGroupID: processGroupID, - job: job, - identified: identified, - retainedAgent: agent, - raw: raw, - stabilized: stabilized - ) - ) - } - guard next != previous else { return } - surfaceAgentStates[surfaceID] = next - emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) - } - - private func markAgentSeen(surfaceID: UUID) { - guard var state = surfaceAgentStates[surfaceID], !state.seen else { return } - state.seen = true - state.lastChangedAt = Date() - surfaceAgentStates[surfaceID] = state - guard let tabId = tabId(containing: surfaceID) else { return } - emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) - } - - private func removeAgentEntryIfNeeded(surfaceID: UUID) { - guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } - surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceID) - onAgentEntryRemoved?(surfaceID) - } - - /// Re-emit Active Agents entries for every pane in `tabId` so the panel picks - /// up a fresh tab-title snapshot. Title changes (OSC-2, focus sync, manual - /// rename) don't move agent detection state, so without this nudge the - /// subtitle only refreshes on the next agent state transition. - private func refreshAgentEntriesForTitleChange(in tabId: TerminalTabID) { - let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] - for surfaceID in surfaceIDs { - guard let state = surfaceAgentStates[surfaceID], - state.detectedAgent != nil, - state.state != .unknown - else { continue } - emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) - } - } - - private func emitAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) { - guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { - onAgentEntryRemoved?(surfaceID) - return - } - onAgentEntryChanged?(entry) - } - - private func activeAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) -> ActiveAgentEntry? { - guard let agent = state.detectedAgent, state.state != .unknown else { return nil } - let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] - let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 - let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" - // Resolve the displayed repository/branch from where the agent actually runs, not the tab's - // owning worktree: a user may `cd` into a different repo before launching the agent. Falls - // back to the surface's launch directory when the shell hasn't reported a pwd. - let workingDirectory = inheritedSurfaceConfig( - fromSurfaceId: surfaceID, - context: GHOSTTY_SURFACE_CONTEXT_TAB - ).workingDirectory - return ActiveAgentEntry( - id: surfaceID, - worktreeID: worktree.id, - worktreeName: worktree.name, - workingDirectory: workingDirectory, - tabID: tabId, - tabTitle: tabTitle, - surfaceID: surfaceID, - paneIndex: paneIndex, - agent: agent, - rawState: state.fallbackState, - displayState: state.displayState, - lastChangedAt: state.lastChangedAt - ) - } - - private func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { - agentDetectionTasks[surfaceId]?.cancel() - agentDetectionTasks.removeValue(forKey: surfaceId) - surfaceAgentStates.removeValue(forKey: surfaceId) - agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) - lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) - onAgentEntryRemoved?(surfaceId) - } - - private func cleanupAllAgentDetectionState() { - for task in agentDetectionTasks.values { - task.cancel() - } - let removedIDs = Array(surfaceAgentStates.keys) - agentDetectionTasks.removeAll() - surfaceAgentStates.removeAll() - agentDetectionPresenceBySurface.removeAll() - lastClaudeWorkingAtBySurface.removeAll() - lastAgentDetectionDiagnosticsBySurface.removeAll() - for id in removedIDs { - onAgentEntryRemoved?(id) - } - } - - private func agentDetectionDiagnosticMessage(_ diagnostic: AgentDetectionDiagnostic) -> String { - let processSummary = - diagnostic.job?.processes - .map { "\($0.pid):\($0.argv0 ?? $0.name)" } - .joined(separator: ",") ?? "none" - return [ - "tab=\(diagnostic.tabId.rawValue.uuidString.prefix(8))", - "childPID=\(diagnostic.childPID.map(String.init) ?? "nil")", - "ptyPGID=\(diagnostic.processGroupID.map(String.init) ?? "nil")", - "fgPGID=\(diagnostic.job.map { String($0.processGroupID) } ?? "nil")", - "processes=\(processSummary)", - "identified=\(diagnostic.identified.map { "\($0.agent.rawValue)(\($0.name))" } ?? "nil")", - "retained=\(diagnostic.retainedAgent?.rawValue ?? "nil")", - "raw=\(diagnostic.raw?.rawValue ?? "nil")", - "state=\(diagnostic.stabilized?.rawValue ?? "nil")", - ].joined(separator: " ") - } - - private func logAgentDetectionDiagnostic(surfaceID: UUID, diagnostic: AgentDetectionDiagnostic) { - #if DEBUG - let message = agentDetectionDiagnosticMessage(diagnostic) - guard lastAgentDetectionDiagnosticsBySurface[surfaceID] != message else { return } - lastAgentDetectionDiagnosticsBySurface[surfaceID] = message - terminalStateLogger.debug( - "agent detection worktree=\(worktree.name) surface=\(surfaceID.uuidString.prefix(8)) \(message)" - ) - #endif - } - - /// Heuristic shape-only detection for shell idle prompts. The - /// bootstrap filter — before `awaitingIdleTitleLearning` has caught - /// the precmd-set prompt at least once on this surface — for two - /// common forms: - /// 1. `user@host[:path]` — contains `@` plus `:` or `/`, no spaces. - /// 2. Pure path — starts with `~`, `/`, or `…`, no spaces. - /// Real commands typically contain a space (program + args) or a - /// short single token (`ls`, `claude`, `vim`) that doesn't match - /// either shape, so the false-negative risk is small. - /// - /// Exposed (`internal static`) for direct unit testing — does not - /// touch instance state. - static func isLikelyIdleTitleByShape(_ title: String) -> Bool { - guard !title.contains(" ") else { return false } - if title.contains("@"), title.contains(":") || title.contains("/") { - return true - } - if title.hasPrefix("~") || title.hasPrefix("/") || title.hasPrefix("…") { - return true - } - return false - } - - /// Apply an already-resolved icon to the tab. Honours focus, the user - /// icon lock, and the Run Script / Custom Command override; encodes - /// the icon through `storageString` so `assetName`-bearing entries - /// pick up the `@asset:` marker the renderers parse via - /// `ResolvedTabIcon`. - private func applyResolvedIcon( - _ icon: TabIconSource, - surfaceId: UUID, - tabId: TerminalTabID - ) { - // Per-tab UI is single-headed: only the focused surface in a - // multi-split tab gets to drive its tab's icon. Stops a - // background split's command from silently overriding what the - // user is currently looking at. - guard focusedSurfaceIdByTab[tabId] == surfaceId else { return } - guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } - guard tab.iconLock == .auto else { return } - let serialised = icon.storageString - guard tab.icon != serialised else { return } - tabManager.updateIcon(tabId, icon: serialised) - } - - static func formatDuration(_ seconds: Int) -> String { - if seconds < 60 { - return "\(seconds)s" - } - let minutes = seconds / 60 - let remainingSeconds = seconds % 60 - if minutes < 60 { - return remainingSeconds > 0 ? "\(minutes)m \(remainingSeconds)s" : "\(minutes)m" - } - let hours = minutes / 60 - let remainingMinutes = minutes % 60 - return remainingMinutes > 0 ? "\(hours)h \(remainingMinutes)m" : "\(hours)h" - } - - /// Drops all per-surface bookkeeping for a surface that has been torn down, - /// including any notifications it produced. A notification is keyed by its - /// originating surface and is only cleared when that surface is focused or - /// typed into; once the surface is gone there is no way to mark it read, so - /// without dropping them here the worktree's unseen indicator (bell + Dock - /// badge) would stay lit until the user manually dismisses everything. - private func forgetSurface(_ surfaceID: UUID) { - surfaces.removeValue(forKey: surfaceID) - surfaceRunningStartedAtById.removeValue(forKey: surfaceID) - autoCloseSurfaceIds.remove(surfaceID) - pendingCustomCommands.removeValue(forKey: surfaceID) - cleanupCommandDetectorState(forSurfaceId: surfaceID) - cleanupAgentDetectionState(forSurfaceId: surfaceID) - let previousHasUnseen = hasUnseenNotification - notifications = Self.prunedNotifications(from: notifications, removingSurfaceID: surfaceID) - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) - } - - /// Removes every notification that originated from `surfaceID`, regardless of - /// read state. Pure so the teardown behavior can be unit-tested without a - /// live Ghostty surface. - static func prunedNotifications( - from notifications: [WorktreeTerminalNotification], - removingSurfaceID surfaceID: UUID - ) -> [WorktreeTerminalNotification] { - notifications.filter { $0.surfaceId != surfaceID } - } - - private func removeTree(for tabId: TerminalTabID) { - guard let tree = trees.removeValue(forKey: tabId) else { return } - for surface in tree.leaves() { - surface.closeSurface() - forgetSurface(surface.id) - } - focusedSurfaceIdByTab.removeValue(forKey: tabId) - tabIsRunningById.removeValue(forKey: tabId) - } - - func tabID(containing surfaceId: UUID) -> TerminalTabID? { - for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { - return tabId - } - return nil - } - - private func tabId(containing surfaceId: UUID) -> TerminalTabID? { - tabID(containing: surfaceId) - } - - private func isFocusedSurface(_ surfaceId: UUID) -> Bool { - guard let selectedTabId = tabManager.selectedTabId else { - return false - } - return focusedSurfaceIdByTab[selectedTabId] == surfaceId - } - - private func updateRunningState(for tabId: TerminalTabID) { - guard let tree = trees[tabId] else { return } - let now = Date() - var isRunningNow = false - for surface in tree.leaves() { - if isRunningProgressState(surface.bridge.state.progressState) { - isRunningNow = true - if surfaceRunningStartedAtById[surface.id] == nil { - surfaceRunningStartedAtById[surface.id] = now - } - } else { - surfaceRunningStartedAtById.removeValue(forKey: surface.id) - } - } - tabIsRunningById[tabId] = isRunningNow - tabManager.updateDirty(tabId, isDirty: isRunningNow) - emitTaskStatusIfChanged() - } - - private func emitTaskStatusIfChanged() { - let newStatus = taskStatus - if newStatus != lastReportedTaskStatus { - lastReportedTaskStatus = newStatus - onTaskStatusChanged?(newStatus) - } - } - - private func emitFocusChangedIfNeeded(_ surfaceId: UUID) { - guard surfaceId != lastEmittedFocusSurfaceId else { return } - lastEmittedFocusSurfaceId = surfaceId - onFocusChanged?(surfaceId) - } - - private func emitNotificationIndicatorIfNeeded(previousHasUnseen: Bool) { - if previousHasUnseen != hasUnseenNotification { - onNotificationIndicatorChanged?() - } - } - - private func syncFocusIfNeeded() { - guard !isCanvasManaged else { return } - guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } - applySurfaceActivity() - } - - private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { - trees[tabId] = tree - syncFocusIfNeeded() - } - - private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { - switch state { - case .some(GHOSTTY_PROGRESS_STATE_SET), - .some(GHOSTTY_PROGRESS_STATE_INDETERMINATE), - .some(GHOSTTY_PROGRESS_STATE_PAUSE), - .some(GHOSTTY_PROGRESS_STATE_ERROR): - return true - default: - return false - } - } - - private func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) - -> SplitTree.NewDirection - { - switch direction { - case .left: - return .left - case .right: - return .right - case .top: - return .top - case .down: - return .down - } - } - - private func mapUserSplitDirection(_ direction: UserCustomSplitDirection) - -> SplitTree.NewDirection - { - switch direction { - case .left: - return .left - case .right: - return .right - case .top: - return .top - case .down: - return .down - } - } - - private func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) - -> SplitTree.FocusDirection - { - switch direction { - case .previous: - return .previous - case .next: - return .next - case .left: - return .spatial(.left) - case .right: - return .spatial(.right) - case .top: - return .spatial(.top) - case .down: - return .spatial(.down) - } - } - - private func mapResizeDirection(_ direction: GhosttySplitAction.ResizeDirection) - -> SplitTree.SpatialDirection - { - switch direction { - case .left: - return .left - case .right: - return .right - case .top: - return .top - case .down: - return .down - } - } - - private func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { - guard surfaces[view.id] != nil else { return } - if processAlive { - guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } - } - guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { - view.closeSurface() - forgetSurface(view.id) - return - } - guard let node = tree.find(id: view.id) else { - view.closeSurface() - forgetSurface(view.id) - return - } - let nextSurface = - focusedSurfaceIdByTab[tabId] == view.id - ? tree.focusTargetAfterClosing(node) - : nil - let newTree = tree.removing(node) - view.closeSurface() - forgetSurface(view.id) - if newTree.isEmpty { - trees.removeValue(forKey: tabId) - focusedSurfaceIdByTab.removeValue(forKey: tabId) - tabManager.closeTab(tabId) - if tabId == runScriptTabId { - setRunScriptTabId(nil) - } - // Mirror `state.closeTab(_:)`'s `onTabClosed` emit: this path - // fires when the shell process exits (ghostty-driven close) - // and historically skipped the callback, which meant the - // Shelf's "retire the book when its last tab closes" logic - // never saw this very common path. - onTabClosed?() - return - } - updateTree(newTree, for: tabId) - updateRunningState(for: tabId) - if focusedSurfaceIdByTab[tabId] == view.id { - if let nextSurface { - focusSurface(nextSurface, in: tabId) - } else { - focusedSurfaceIdByTab.removeValue(forKey: tabId) - } - } - } - - private func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { - let tabs = tabManager.tabs - guard !tabs.isEmpty else { return false } - let raw = Int(target.rawValue) - let selectedIndex = tabManager.selectedTabId.flatMap { selected in - tabs.firstIndex { $0.id == selected } - } - let targetIndex: Int - if raw <= 0 { - switch raw { - case Int(GHOSTTY_GOTO_TAB_PREVIOUS.rawValue): - let current = selectedIndex ?? 0 - targetIndex = (current - 1 + tabs.count) % tabs.count - case Int(GHOSTTY_GOTO_TAB_NEXT.rawValue): - let current = selectedIndex ?? 0 - targetIndex = (current + 1) % tabs.count - case Int(GHOSTTY_GOTO_TAB_LAST.rawValue): - targetIndex = tabs.count - 1 - default: - return false - } - } else { - targetIndex = min(raw - 1, tabs.count - 1) - } - selectTab(tabs[targetIndex].id) - return true - } - - private func mapDropZone(_ zone: TerminalSplitTreeView.DropZone) - -> SplitTree.NewDirection - { - switch zone { - case .top: - return .top - case .bottom: - return .down - case .left: - return .left - case .right: - return .right - } - } - - private func nextTabIndex() -> Int { - let prefix = "\(worktree.name) " - var maxIndex = 0 - for tab in tabManager.tabs { - guard tab.title.hasPrefix(prefix) else { continue } - let suffix = tab.title.dropFirst(prefix.count) - guard let value = Int(suffix) else { continue } - maxIndex = max(maxIndex, value) - } - return maxIndex + 1 - } -} - -struct CLIWorktreeTerminalSnapshot: Sendable { - let tabs: [CLITerminalTabSnapshot] - let taskStatus: ListCommandTask.Status? -} - -struct CLITerminalTabSnapshot: Sendable { - let id: UUID - let title: String - let selected: Bool - let focusedPaneID: UUID? - let panes: [CLITerminalPaneSnapshot] -} - -struct CLITerminalPaneSnapshot: Sendable { - let id: UUID - let title: String - let cwd: String? -} - -extension WorktreeTerminalState { - func makeCLIListSnapshot() -> CLIWorktreeTerminalSnapshot { - let selectedTabID = tabManager.selectedTabId - - let tabs: [CLITerminalTabSnapshot] = tabManager.tabs.map { tab in - let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] - let panes = paneIDs.map { paneID in - let cwd = inheritedSurfaceConfig( - fromSurfaceId: paneID, - context: GHOSTTY_SURFACE_CONTEXT_TAB - ).workingDirectory?.path(percentEncoded: false) - - let title = paneTitle(surfaceID: paneID, fallbackTabTitle: tab.displayTitle) - return CLITerminalPaneSnapshot(id: paneID, title: title, cwd: cwd) - } - - return CLITerminalTabSnapshot( - id: tab.id.rawValue, - title: tab.displayTitle, - selected: tab.id == selectedTabID, - focusedPaneID: focusedSurfaceIdByTab[tab.id], - panes: panes - ) - } - - return CLIWorktreeTerminalSnapshot( - tabs: tabs, - taskStatus: taskStatus == .running ? .running : .idle - ) - } - - private func paneTitle(surfaceID: UUID, fallbackTabTitle: String) -> String { - let rawTitle = surfaces[surfaceID]?.bridge.state.title?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - - if let rawTitle, !rawTitle.isEmpty { - return rawTitle - } - - return fallbackTabTitle - } -} - -// MARK: - CLI Command Finished Waiting - -extension WorktreeTerminalState { - /// Returns an `AsyncStream` that yields exactly once when the command finishes - /// on the given surface. The caller should race this against a timeout. - func waitForCommandFinished(surfaceID: UUID) -> AsyncStream<(exitCode: Int?, durationMs: Int)> { - // Cancel any existing waiter for this surface. - commandFinishedWaiters[surfaceID]?.finish() - commandFinishedWaiters.removeValue(forKey: surfaceID) - - return AsyncStream { continuation in - commandFinishedWaiters[surfaceID] = continuation - continuation.onTermination = { [weak self] _ in - Task { @MainActor in - self?.commandFinishedWaiters.removeValue(forKey: surfaceID) - } - } - } - } -} - -// MARK: - CLI Send Snapshot - -struct CLISendTabSnapshot { - let focusedPaneID: UUID? - let panes: [TargetResolutionSnapshot.Pane] -} - -extension WorktreeTerminalState { - func makeCLISendSnapshot(for tabId: TerminalTabID) -> CLISendTabSnapshot? { - let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] - guard !paneIDs.isEmpty else { return nil } - - let focusedPaneID = focusedSurfaceIdByTab[tabId] - let panes: [TargetResolutionSnapshot.Pane] = paneIDs.compactMap { paneID in - guard let surfaceView = surfaces[paneID] else { return nil } - let cwd = inheritedSurfaceConfig( - fromSurfaceId: paneID, - context: GHOSTTY_SURFACE_CONTEXT_TAB - ).workingDirectory?.path(percentEncoded: false) - let title = paneTitle(surfaceID: paneID, fallbackTabTitle: "") - return TargetResolutionSnapshot.Pane( - id: paneID, - title: title, - cwd: cwd, - isFocusedInTab: paneID == focusedPaneID, - surfaceView: surfaceView - ) - } - - return CLISendTabSnapshot(focusedPaneID: focusedPaneID, panes: panes) - } -} - -nonisolated func makeCommandInput(script: String) -> String? { - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed + "\n" -} - -nonisolated func makeBlockingScriptInput(script: String) -> String? { - guard let input = makeCommandInput(script: script) else { return nil } - return input + "exit\n" } -- 2.51.2 From 40f9e8e1a3fa0d2e9341e42e880b4e5df72966ca Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 02:17:30 +0900 Subject: [PATCH 335/528] Split Ghostty surface view helpers --- .../Infrastructure/Ghostty/CLIKeySpec.swift | 298 +++ .../Ghostty/GhosttySurfaceScrollView.swift | 291 +++ .../GhosttySurfaceView+Accessibility.swift | 169 ++ .../Ghostty/GhosttySurfaceView+Dragging.swift | 33 + .../GhosttySurfaceView+EventTranslation.swift | 131 ++ .../Ghostty/GhosttySurfaceView+Keyboard.swift | 501 +++++ .../Ghostty/GhosttySurfaceView+Mouse.swift | 217 ++ .../Ghostty/GhosttySurfaceView+Services.swift | 45 + .../GhosttySurfaceView+TextInput.swift | 233 ++ .../Ghostty/GhosttySurfaceView.swift | 1915 +---------------- 10 files changed, 1932 insertions(+), 1901 deletions(-) create mode 100644 supacode/Infrastructure/Ghostty/CLIKeySpec.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceScrollView.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+Dragging.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+Services.swift create mode 100644 supacode/Infrastructure/Ghostty/GhosttySurfaceView+TextInput.swift diff --git a/supacode/Infrastructure/Ghostty/CLIKeySpec.swift b/supacode/Infrastructure/Ghostty/CLIKeySpec.swift new file mode 100644 index 00000000..163b2254 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/CLIKeySpec.swift @@ -0,0 +1,298 @@ +import AppKit +import Carbon + +/// Maps canonical CLI key tokens to macOS NSEvent parameters. +struct CLIKeySpec { + let keyCode: UInt16 + let characters: String + let charactersIgnoringModifiers: String + let modifiers: NSEvent.ModifierFlags + + static func from(token: String) -> CLIKeySpec? { + guard let descriptor = KeyTokens.descriptor(for: token), + let base = baseSpec(for: descriptor.baseToken) + else { + return nil + } + + var modifierFlags = eventModifiers(from: descriptor.modifiers) + if base.usesFunctionModifier { + modifierFlags.insert(.function) + } + + let charactersIgnoringModifiers = base.charactersIgnoringModifiers + let characters: String + if usesControlCharacter(for: descriptor.modifiers), + let controlCharacter = controlCharacter(for: descriptor.baseToken) + { + characters = controlCharacter + } else if descriptor.modifiers.contains(.shift) { + characters = shiftedCharacters(for: descriptor.baseToken) ?? charactersIgnoringModifiers + } else { + characters = charactersIgnoringModifiers + } + + return CLIKeySpec( + keyCode: base.keyCode, + characters: characters, + charactersIgnoringModifiers: charactersIgnoringModifiers, + modifiers: modifierFlags + ) + } + + private struct BaseSpec { + let keyCode: UInt16 + let charactersIgnoringModifiers: String + let usesFunctionModifier: Bool + } + + private static let namedBaseSpecs: [String: BaseSpec] = [ + "enter": BaseSpec( + keyCode: UInt16(kVK_Return), + charactersIgnoringModifiers: "\r", + usesFunctionModifier: false + ), + "esc": BaseSpec( + keyCode: UInt16(kVK_Escape), + charactersIgnoringModifiers: "\u{1B}", + usesFunctionModifier: false + ), + "tab": BaseSpec( + keyCode: UInt16(kVK_Tab), + charactersIgnoringModifiers: "\t", + usesFunctionModifier: false + ), + "backspace": BaseSpec( + keyCode: UInt16(kVK_Delete), + charactersIgnoringModifiers: "\u{7F}", + usesFunctionModifier: false + ), + "delete-forward": BaseSpec( + keyCode: UInt16(kVK_ForwardDelete), + charactersIgnoringModifiers: String(UnicodeScalar(NSDeleteFunctionKey)!), + usesFunctionModifier: true + ), + "insert": BaseSpec( + keyCode: UInt16(kVK_Help), + charactersIgnoringModifiers: String(UnicodeScalar(NSInsertFunctionKey)!), + usesFunctionModifier: true + ), + "up": BaseSpec( + keyCode: UInt16(kVK_UpArrow), + charactersIgnoringModifiers: String(UnicodeScalar(NSUpArrowFunctionKey)!), + usesFunctionModifier: true + ), + "down": BaseSpec( + keyCode: UInt16(kVK_DownArrow), + charactersIgnoringModifiers: String(UnicodeScalar(NSDownArrowFunctionKey)!), + usesFunctionModifier: true + ), + "left": BaseSpec( + keyCode: UInt16(kVK_LeftArrow), + charactersIgnoringModifiers: String(UnicodeScalar(NSLeftArrowFunctionKey)!), + usesFunctionModifier: true + ), + "right": BaseSpec( + keyCode: UInt16(kVK_RightArrow), + charactersIgnoringModifiers: String(UnicodeScalar(NSRightArrowFunctionKey)!), + usesFunctionModifier: true + ), + "pageup": BaseSpec( + keyCode: UInt16(kVK_PageUp), + charactersIgnoringModifiers: String(UnicodeScalar(NSPageUpFunctionKey)!), + usesFunctionModifier: true + ), + "pagedown": BaseSpec( + keyCode: UInt16(kVK_PageDown), + charactersIgnoringModifiers: String(UnicodeScalar(NSPageDownFunctionKey)!), + usesFunctionModifier: true + ), + "home": BaseSpec( + keyCode: UInt16(kVK_Home), + charactersIgnoringModifiers: String(UnicodeScalar(NSHomeFunctionKey)!), + usesFunctionModifier: true + ), + "end": BaseSpec( + keyCode: UInt16(kVK_End), + charactersIgnoringModifiers: String(UnicodeScalar(NSEndFunctionKey)!), + usesFunctionModifier: true + ), + ] + + private static let functionKeyMap: [String: (UInt16, Int)] = [ + "f1": (UInt16(kVK_F1), NSF1FunctionKey), + "f2": (UInt16(kVK_F2), NSF2FunctionKey), + "f3": (UInt16(kVK_F3), NSF3FunctionKey), + "f4": (UInt16(kVK_F4), NSF4FunctionKey), + "f5": (UInt16(kVK_F5), NSF5FunctionKey), + "f6": (UInt16(kVK_F6), NSF6FunctionKey), + "f7": (UInt16(kVK_F7), NSF7FunctionKey), + "f8": (UInt16(kVK_F8), NSF8FunctionKey), + "f9": (UInt16(kVK_F9), NSF9FunctionKey), + "f10": (UInt16(kVK_F10), NSF10FunctionKey), + "f11": (UInt16(kVK_F11), NSF11FunctionKey), + "f12": (UInt16(kVK_F12), NSF12FunctionKey), + ] + + private static let printableKeyCodes: [String: UInt16] = [ + "a": UInt16(kVK_ANSI_A), + "b": UInt16(kVK_ANSI_B), + "c": UInt16(kVK_ANSI_C), + "d": UInt16(kVK_ANSI_D), + "e": UInt16(kVK_ANSI_E), + "f": UInt16(kVK_ANSI_F), + "g": UInt16(kVK_ANSI_G), + "h": UInt16(kVK_ANSI_H), + "i": UInt16(kVK_ANSI_I), + "j": UInt16(kVK_ANSI_J), + "k": UInt16(kVK_ANSI_K), + "l": UInt16(kVK_ANSI_L), + "m": UInt16(kVK_ANSI_M), + "n": UInt16(kVK_ANSI_N), + "o": UInt16(kVK_ANSI_O), + "p": UInt16(kVK_ANSI_P), + "q": UInt16(kVK_ANSI_Q), + "r": UInt16(kVK_ANSI_R), + "s": UInt16(kVK_ANSI_S), + "t": UInt16(kVK_ANSI_T), + "u": UInt16(kVK_ANSI_U), + "v": UInt16(kVK_ANSI_V), + "w": UInt16(kVK_ANSI_W), + "x": UInt16(kVK_ANSI_X), + "y": UInt16(kVK_ANSI_Y), + "z": UInt16(kVK_ANSI_Z), + "0": UInt16(kVK_ANSI_0), + "1": UInt16(kVK_ANSI_1), + "2": UInt16(kVK_ANSI_2), + "3": UInt16(kVK_ANSI_3), + "4": UInt16(kVK_ANSI_4), + "5": UInt16(kVK_ANSI_5), + "6": UInt16(kVK_ANSI_6), + "7": UInt16(kVK_ANSI_7), + "8": UInt16(kVK_ANSI_8), + "9": UInt16(kVK_ANSI_9), + "space": UInt16(kVK_Space), + "minus": UInt16(kVK_ANSI_Minus), + "equal": UInt16(kVK_ANSI_Equal), + "comma": UInt16(kVK_ANSI_Comma), + "period": UInt16(kVK_ANSI_Period), + "slash": UInt16(kVK_ANSI_Slash), + "backslash": UInt16(kVK_ANSI_Backslash), + "semicolon": UInt16(kVK_ANSI_Semicolon), + "quote": UInt16(kVK_ANSI_Quote), + "grave": UInt16(kVK_ANSI_Grave), + "left-bracket": UInt16(kVK_ANSI_LeftBracket), + "right-bracket": UInt16(kVK_ANSI_RightBracket), + ] + + private static let shiftedCharacterMap: [String: String] = [ + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "minus": "_", + "equal": "+", + "comma": "<", + "period": ">", + "slash": "?", + "backslash": "|", + "semicolon": ":", + "quote": "\"", + "grave": "~", + "left-bracket": "{", + "right-bracket": "}", + ] + + private static func eventModifiers(from modifiers: [KeyModifier]) -> NSEvent.ModifierFlags { + modifiers.reduce(into: NSEvent.ModifierFlags()) { result, modifier in + switch modifier { + case .cmd: result.insert(.command) + case .shift: result.insert(.shift) + case .opt: result.insert(.option) + case .ctrl: result.insert(.control) + } + } + } + + private static func baseSpec(for token: String) -> BaseSpec? { + if let base = namedBaseSpecs[token] { return base } + if let (keyCode, scalar) = functionKeyMap[token] { + return BaseSpec( + keyCode: keyCode, + charactersIgnoringModifiers: String(UnicodeScalar(scalar)!), + usesFunctionModifier: true + ) + } + return printableBaseSpec(for: token) + } + + private static func printableBaseSpec(for token: String) -> BaseSpec? { + guard let character = printableCharacter(for: token), + let keyCode = printableKeyCode(for: token) + else { return nil } + + return BaseSpec( + keyCode: keyCode, + charactersIgnoringModifiers: String(character), + usesFunctionModifier: false + ) + } + + private static func printableCharacter(for token: String) -> Character? { + switch token { + case "space": return " " + case "minus": return "-" + case "equal": return "=" + case "comma": return "," + case "period": return "." + case "slash": return "/" + case "backslash": return "\\" + case "semicolon": return ";" + case "quote": return "'" + case "grave": return "`" + case "left-bracket": return "[" + case "right-bracket": return "]" + default: + guard token.count == 1 else { return nil } + return token.first + } + } + + private static func printableKeyCode(for token: String) -> UInt16? { + printableKeyCodes[token] + } + + private static func shiftedCharacters(for token: String) -> String? { + if token.count == 1, let character = token.first, character.isLetter { + return String(character).uppercased() + } + + return shiftedCharacterMap[token] + } + + private static func usesControlCharacter(for modifiers: [KeyModifier]) -> Bool { + let modifierSet = Set(modifiers) + return modifierSet.contains(.ctrl) && modifierSet.isSubset(of: [.ctrl, .shift]) + } + + private static func controlCharacter(for token: String) -> String? { + if token.count == 1, let scalar = token.unicodeScalars.first, scalar.properties.isAlphabetic { + return String(UnicodeScalar(scalar.value & 0x1F)!) + } + + switch token { + case "left-bracket": return String(UnicodeScalar(27)!) + case "backslash": return String(UnicodeScalar(28)!) + case "right-bracket": return String(UnicodeScalar(29)!) + case "6": return String(UnicodeScalar(30)!) + case "minus": return String(UnicodeScalar(31)!) + default: return nil + } + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceScrollView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceScrollView.swift new file mode 100644 index 00000000..d65c152a --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceScrollView.swift @@ -0,0 +1,291 @@ +import AppKit +import GhosttyKit + +final class GhosttySurfaceScrollView: NSView { + enum HostKind: String { + case terminal + case canvas + } + + private struct ScrollbarState { + let total: UInt64 + let offset: UInt64 + let length: UInt64 + } + + private let scrollView: NSScrollView + private let documentView: NSView + private let surfaceView: GhosttySurfaceView + let hostKind: HostKind + private let debugID = String(UUID().uuidString.prefix(8)) + var debugIdentifier: String { + debugID + } + private var observers: [NSObjectProtocol] = [] + + private var isLiveScrolling = false + private var isProgrammaticScrollChange = false + private var isUserScrolledBack = false + private var lastSentRow: Int? + private var scrollbar: ScrollbarState? + + /// When set, the surface renders at this fixed size regardless of the hosting + /// view's bounds. Used in canvas mode to prevent `.scaleEffect()` from causing + /// terminal reflow. + var pinnedSize: CGSize? + + init(surfaceView: GhosttySurfaceView, hostKind: HostKind) { + self.surfaceView = surfaceView + self.hostKind = hostKind + scrollView = NSScrollView() + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.usesPredominantAxisScrolling = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.contentView.clipsToBounds = false + documentView = NSView(frame: .zero) + scrollView.documentView = documentView + documentView.addSubview(surfaceView) + super.init(frame: .zero) + addSubview(scrollView) + surfaceView.scrollWrapper = self + refreshAppearance() + + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append( + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleScrollChange() + } + }) + + observers.append( + NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.isLiveScrolling = true + } + }) + + observers.append( + NotificationCenter.default.addObserver( + forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.isLiveScrolling = false + self?.updateScrollBackState() + } + }) + + observers.append( + NotificationCenter.default.addObserver( + forName: NSScrollView.didLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleLiveScroll() + } + }) + + observers.append( + NotificationCenter.default.addObserver( + forName: NSScroller.preferredScrollerStyleDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleScrollerStyleChange() + } + }) + + observers.append( + NotificationCenter.default.addObserver( + forName: .ghosttyRuntimeConfigDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.refreshAppearance() + } + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + isolated deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + override func layout() { + super.layout() + ensureSurfaceAttached() + let effectiveSize = pinnedSize ?? bounds.size + scrollView.frame = CGRect(origin: .zero, size: effectiveSize) + surfaceView.frame.size = effectiveSize + documentView.frame.size.width = effectiveSize.width + synchronizeScrollView() + synchronizeSurfaceView() + surfaceView.updateSurfaceSize() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + ensureSurfaceAttached() + } + + func updateSurfaceSize() { + surfaceView.updateSurfaceSize() + needsLayout = true + } + + var isSurfaceAttachedToDocumentView: Bool { + surfaceView.superview === documentView + } + + func ensureSurfaceAttached(requiresLiveHost: Bool = true) { + guard hostKind == .terminal else { return } + if requiresLiveHost { + guard superview != nil || window != nil else { return } + } + guard !isSurfaceAttachedToDocumentView else { return } + // Only adopt an orphaned surface; never steal it from a live host such as Canvas. + guard surfaceView.superview == nil else { return } + surfaceLogger.info( + "[CanvasExit] hostReattach wrapper=\(debugID) host=\(hostKind.rawValue) " + + "surface=\(surfaceView.debugIdentifierForLogging) " + + "currentSuperview=\(String(describing: surfaceView.superview)) " + + "wrapperWindow=\(window != nil)" + ) + documentView.addSubview(surfaceView) + surfaceView.scrollWrapper = self + surfaceLogger.info( + "[CanvasExit] hostReattachComplete wrapper=\(debugID) host=\(hostKind.rawValue) " + + "surface=\(surfaceView.debugIdentifierForLogging) " + + "superview=\(surfaceView.superview != nil) " + + "window=\(surfaceView.window != nil) " + + "bounds=\(Int(surfaceView.bounds.width))x\(Int(surfaceView.bounds.height))" + ) + } + + func updateScrollbar(total: UInt64, offset: UInt64, length: UInt64) { + scrollbar = ScrollbarState(total: total, offset: offset, length: length) + synchronizeScrollView() + } + + func refreshAppearance() { + scrollView.hasVerticalScroller = surfaceView.shouldShowScrollbar() + scrollView.appearance = NSAppearance(named: surfaceView.scrollbarAppearanceName()) + scrollView.scrollerStyle = .overlay + updateTrackingAreas() + } + + private func handleScrollChange() { + synchronizeSurfaceView() + guard !isProgrammaticScrollChange else { + return + } + updateScrollBackState() + } + + private func handleScrollerStyleChange() { + refreshAppearance() + surfaceView.updateSurfaceSize() + } + + private func synchronizeSurfaceView() { + let visibleRect = scrollView.contentView.documentVisibleRect + surfaceView.frame.origin = visibleRect.origin + } + + private func synchronizeScrollView() { + documentView.frame.size.height = documentHeight() + if !isLiveScrolling && !isUserScrolledBack { + let cellHeight = surfaceView.currentCellSize().height + if cellHeight > 0, let scrollbar { + let targetY = + CGFloat(scrollbar.total - scrollbar.offset - scrollbar.length) * cellHeight + isProgrammaticScrollChange = true + defer { isProgrammaticScrollChange = false } + scrollView.contentView.scroll(to: CGPoint(x: 0, y: targetY)) + lastSentRow = Int(scrollbar.offset) + } + } + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + /// Tracks whether the user intentionally moved away from the live bottom of + /// the terminal. While this is true we keep the viewport fixed so incoming + /// output cannot yank scrollback out from under the user. + private func updateScrollBackState() { + let cellHeight = surfaceView.currentCellSize().height + guard cellHeight > 0 else { + isUserScrolledBack = false + return + } + + let visibleRect = scrollView.contentView.documentVisibleRect + let distanceFromBottom = max(0, documentView.frame.height - visibleRect.maxY) + isUserScrolledBack = distanceFromBottom > cellHeight / 2 + } + + private func handleLiveScroll() { + let cellHeight = surfaceView.currentCellSize().height + guard cellHeight > 0 else { return } + let visibleRect = scrollView.contentView.documentVisibleRect + let documentHeight = documentView.frame.height + let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + let row = Int(scrollOffset / cellHeight) + guard row != lastSentRow else { return } + lastSentRow = row + surfaceView.performBindingAction("scroll_to_row:\(row)") + } + + private func documentHeight() -> CGFloat { + let contentHeight = scrollView.contentSize.height + let cellHeight = surfaceView.currentCellSize().height + if cellHeight > 0, let scrollbar { + let documentGridHeight = CGFloat(scrollbar.total) * cellHeight + let padding = contentHeight - (CGFloat(scrollbar.length) * cellHeight) + return documentGridHeight + padding + } + return contentHeight + } + + override func mouseMoved(with event: NSEvent) { + guard NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + for trackingArea in trackingAreas { + removeTrackingArea(trackingArea) + } + super.updateTrackingAreas() + guard let scroller = scrollView.verticalScroller else { return } + addTrackingArea( + NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil + )) + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift new file mode 100644 index 00000000..39473efe --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift @@ -0,0 +1,169 @@ +import AppKit +import CoreText +import GhosttyKit + +extension GhosttySurfaceView { + func setAccessibilityPaneIndex(index: Int, total: Int) { + guard total > 0, index > 0, index <= total else { + accessibilityPaneIndexHelp = nil + return + } + accessibilityPaneIndexHelp = "Pane \(index) of \(total)" + } + + override func isAccessibilityElement() -> Bool { + // Avoid interacting with panes after teardown. + surface != nil + } + + override func accessibilityRole() -> NSAccessibility.Role? { + // Match Ghostty.app so speech/input tools can treat the surface as editable text. + .textArea + } + + override func accessibilityLabel() -> String? { + let title = bridge.state.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } + let pwd = bridge.state.pwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !pwd.isEmpty { + return pwd + } + return "Terminal pane" + } + + override func accessibilityValue() -> Any? { + cachedScreenContents.get() + } + + override func accessibilityHelp() -> String? { + accessibilityPaneIndexHelp + } + + override func accessibilitySelectedTextRange() -> NSRange { + selectedRange() + } + + override func accessibilitySelectedText() -> String? { + guard let surface else { return nil } + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + let value = Self.string(from: text) + return value.isEmpty ? nil : value + } + + override func accessibilityNumberOfCharacters() -> Int { + cachedScreenContents.get().count + } + + override func accessibilityVisibleCharacterRange() -> NSRange { + let content = cachedScreenContents.get() + return NSRange(location: 0, length: content.count) + } + + override func accessibilityLine(for index: Int) -> Int { + Self.accessibilityLine(for: index, in: cachedScreenContents.get()) + } + + override func accessibilityString(for range: NSRange) -> String? { + Self.accessibilityString(for: range, in: cachedScreenContents.get()) + } + + override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { + guard let surface else { return nil } + guard let plainString = accessibilityString(for: range) else { return nil } + + var attributes: [NSAttributedString.Key: Any] = [:] + if let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + + return NSAttributedString(string: plainString, attributes: attributes) + } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + focusDidChange(true) + postAccessibilityFocusChanged() + } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + focusDidChange(false) + } + return result + } + + func postAccessibilityFocusChanged() { + guard surface != nil else { return } + // Post on the window so assistive tech can query the focused element from it. + if let window { + NSAccessibility.post(element: window, notification: .focusedUIElementChanged) + } else { + NSAccessibility.post(element: self, notification: .focusedUIElementChanged) + } + } + + func readText( + topLeftTag: ghostty_point_tag_e, + bottomRightTag: ghostty_point_tag_e + ) -> String? { + guard let surface else { return nil } + var text = ghostty_text_s() + let selection = ghostty_selection_s( + top_left: ghostty_point_s( + tag: topLeftTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ), + bottom_right: ghostty_point_s( + tag: bottomRightTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ), + rectangle: false + ) + guard ghostty_surface_read_text(surface, selection, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + return Self.string(from: text) + } + + func readScreenContents() -> String { + readText( + topLeftTag: GHOSTTY_POINT_SCREEN, + bottomRightTag: GHOSTTY_POINT_SCREEN + ) ?? "" + } + + func readViewportContentsForCLI() -> String? { + readText( + topLeftTag: GHOSTTY_POINT_VIEWPORT, + bottomRightTag: GHOSTTY_POINT_VIEWPORT + ) + } + + func readActiveContentsForCLI() -> String? { + readText( + topLeftTag: GHOSTTY_POINT_ACTIVE, + bottomRightTag: GHOSTTY_POINT_ACTIVE + ) + } + + func readScreenContentsForCLI() -> String? { + readText( + topLeftTag: GHOSTTY_POINT_SCREEN, + bottomRightTag: GHOSTTY_POINT_SCREEN + ) + } + +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Dragging.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Dragging.swift new file mode 100644 index 00000000..4e4a73dc --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Dragging.swift @@ -0,0 +1,33 @@ +import AppKit + +extension GhosttySurfaceView { + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard let types = sender.draggingPasteboard.types else { return [] } + if Set(types).isDisjoint(with: Self.dropTypes) { + return [] + } + return .copy + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let pasteboard = sender.draggingPasteboard + let content: String? + if let url = pasteboard.string(forType: .URL) { + content = NSPasteboard.ghosttyEscape(url) + } else if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + !urls.isEmpty + { + content = urls.map { NSPasteboard.ghosttyEscape($0.path) }.joined(separator: " ") + } else if let str = pasteboard.string(forType: .string) { + content = str + } else { + content = nil + } + + guard let content else { return false } + Task { @MainActor in + self.insertText(content, replacementRange: NSRange(location: 0, length: 0)) + } + return true + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift new file mode 100644 index 00000000..8802e969 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift @@ -0,0 +1,131 @@ +import AppKit +import Carbon +import GhosttyKit + +extension GhosttySurfaceView { + func translationState(_ event: NSEvent, surface: ghostty_surface_t) -> ( + NSEvent, NSEvent.ModifierFlags + ) { + let translatedModsGhostty = ghostty_surface_key_translation_mods( + surface, ghosttyMods(event.modifierFlags)) + let translatedMods = appKitMods(translatedModsGhostty) + var resolved = event.modifierFlags + for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { + if translatedMods.contains(flag) { + resolved.insert(flag) + } else { + resolved.remove(flag) + } + } + if resolved == event.modifierFlags { + return (event, resolved) + } + let translatedEvent = + NSEvent.keyEvent( + with: event.type, + location: event.locationInWindow, + modifierFlags: resolved, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters(byApplyingModifiers: resolved) ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) ?? event + return (translatedEvent, resolved) + } + + func ghosttyKeyEvent( + _ event: NSEvent, + action: ghostty_input_action_e, + originalMods: NSEvent.ModifierFlags, + translationMods: NSEvent.ModifierFlags, + composing: Bool = false + ) -> ghostty_input_key_s { + var keyEvent: ghostty_input_key_s = .init() + keyEvent.action = action + keyEvent.keycode = UInt32(event.keyCode) + keyEvent.text = nil + keyEvent.composing = composing + keyEvent.mods = ghosttyMods(originalMods) + keyEvent.consumed_mods = ghosttyMods(translationMods.subtracting([.control, .command])) + keyEvent.unshifted_codepoint = 0 + if event.type == .keyDown || event.type == .keyUp { + if let chars = event.characters(byApplyingModifiers: []), + let codepoint = chars.unicodeScalars.first + { + keyEvent.unshifted_codepoint = codepoint.value + } + } + return keyEvent + } + + func suppressKeyboardLayoutChangeKeyUp(_ event: NSEvent) -> Bool { + guard let suppression = keyboardLayoutChangeKeyUpSuppression else { return false } + if suppression.isExpired(at: event.timestamp) { + keyboardLayoutChangeKeyUpSuppression = nil + return false + } + if suppression.suppresses(keyCode: event.keyCode, timestamp: event.timestamp) { + keyboardLayoutChangeKeyUpSuppression = nil + return true + } + return false + } + + func ghosttyCharacters(_ event: NSEvent) -> String? { + GhosttyEventText.characters(for: event) + } + + func syncPreedit(clearIfNeeded: Bool = true) { + guard let surface else { return } + if markedText.length > 0 { + let str = markedText.string + let len = str.utf8CString.count + if len > 0 { + markedText.string.withCString { ptr in + ghostty_surface_preedit(surface, ptr, UInt(len - 1)) + } + } + } else if clearIfNeeded { + ghostty_surface_preedit(surface, nil, 0) + } + } + + func keyboardLayoutId() -> String? { + guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { + return nil + } + guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { + return nil + } + let value = Unmanaged.fromOpaque(raw).takeUnretainedValue() + return value as String + } + + func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { + var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue + if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } + if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } + if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } + if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue } + let rawFlags = flags.rawValue + if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK)) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } + if (rawFlags & UInt(NX_DEVICERCTLKEYMASK)) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } + if (rawFlags & UInt(NX_DEVICERALTKEYMASK)) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } + if (rawFlags & UInt(NX_DEVICERCMDKEYMASK)) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } + return ghostty_input_mods_e(mods) + } + + func appKitMods(_ mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 { flags.insert(.shift) } + if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 { flags.insert(.control) } + if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 { flags.insert(.option) } + if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 { flags.insert(.command) } + if (mods.rawValue & GHOSTTY_MODS_CAPS.rawValue) != 0 { flags.insert(.capsLock) } + return flags + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift new file mode 100644 index 00000000..a457e420 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift @@ -0,0 +1,501 @@ +import AppKit +import Carbon +import GhosttyKit +import SwiftUI + +extension GhosttySurfaceView { + override func keyDown(with event: NSEvent) { + guard let surface else { + interpretKeyEvents([event]) + return + } + bridge.state.bellCount = 0 + onKeyInput?() + if let mirroredKey = MirroredTerminalKey(event: event) { + onMirroredKey?(mirroredKey) + } + let (translationEvent, translationMods) = translationState(event, surface: surface) + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + keyTextAccumulator = [] + defer { keyTextAccumulator = nil } + let markedTextBefore = markedText.length > 0 + let keyboardIdBefore = markedTextBefore ? nil : keyboardLayoutId() + lastPerformKeyEvent = nil + interpretKeyEvents([translationEvent]) + if !markedTextBefore, keyboardIdBefore != keyboardLayoutId() { + keyboardLayoutChangeKeyUpSuppression = KeyboardLayoutChangeKeyUpSuppression( + keyCode: event.keyCode, + timestamp: event.timestamp + ) + return + } + syncPreedit(clearIfNeeded: markedTextBefore) + if let list = keyTextAccumulator, !list.isEmpty { + for text in list { + _ = sendKey( + action: action, + event: event, + translationEvent: translationEvent, + translationMods: translationMods, + text: text, + composing: false + ) + } + } else { + _ = sendKey( + action: action, + event: event, + translationEvent: translationEvent, + translationMods: translationMods, + text: ghosttyCharacters(translationEvent), + composing: markedText.length > 0 || markedTextBefore + ) + } + } + + override func keyUp(with event: NSEvent) { + if suppressKeyboardLayoutChangeKeyUp(event) { return } + sendKey(action: GHOSTTY_ACTION_RELEASE, event: event) + } + + override func flagsChanged(with event: NSEvent) { + let mod: UInt32 + switch event.keyCode { + case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue + case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue + case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue + case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue + case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue + default: return + } + if hasMarkedText() { return } + let mods = ghosttyMods(event.modifierFlags) + var action = GHOSTTY_ACTION_RELEASE + if (mods.rawValue & mod) != 0 { + let sidePressed: Bool + switch event.keyCode { + case 0x3C: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0 + case 0x3E: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0 + case 0x3D: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0 + case 0x36: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0 + default: + sidePressed = true + } + if sidePressed { + action = GHOSTTY_ACTION_PRESS + } + } + sendKey(action: action, event: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + let isFontSizeShortcut = matchesFontSizeShortcut(event: event) + guard let surface else { return false } + guard + Self.hasKeyEquivalentFocusOwnership( + cachedFocused: focused, + isActualFirstResponder: window?.firstResponder === self + ) + else { return false } + + if UserCustomShortcutRegistry.shared.matches(event: event), + let menu = NSApp.mainMenu, + Self.mainMenuHasMatchingItem(for: event, in: menu), + menu.performKeyEquivalent(with: event) + { + return true + } + + if let bindingFlags = bindingFlags(for: event, surface: surface) { + if shouldAttemptMenu(for: bindingFlags), + let menu = NSApp.mainMenu, + Self.mainMenuHasMatchingItem(for: event, in: menu), + menu.performKeyEquivalent(with: event) + { + return true + } + keyDown(with: event) + if isFontSizeShortcut { + onFontSizeShortcut?() + } + // Ghostty handled paste internally; broadcast the pasted text to followers. + if onCommittedText != nil, + event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers == "v", + let text = NSPasteboard.general.string(forType: .string), + !text.isEmpty + { + onCommittedText?(text) + } + return true + } + + guard let equivalent = equivalentKey(for: event) else { return false } + + guard + let finalEvent = NSEvent.keyEvent( + with: .keyDown, + location: event.locationInWindow, + modifierFlags: event.modifierFlags, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: equivalent, + charactersIgnoringModifiers: equivalent, + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) + else { + return false + } + keyDown(with: finalEvent) + if isFontSizeShortcut { + onFontSizeShortcut?() + } + return true + } + + func matchesFontSizeShortcut(event: NSEvent) -> Bool { + matchesBindingShortcut(event: event, action: "reset_font_size") + || matchesBindingShortcut(event: event, action: "increase_font_size:1") + || matchesBindingShortcut(event: event, action: "decrease_font_size:1") + } + + func matchesBindingShortcut(event: NSEvent, action: String) -> Bool { + guard let shortcut = runtime.keyboardShortcut(for: action) else { return false } + let normalizedEventModifiers = normalizedModifiers(from: event.modifierFlags) + guard normalizedEventModifiers == shortcut.modifiers else { return false } + let eventKey = (event.charactersIgnoringModifiers ?? "").lowercased() + let shortcutKey = String(shortcut.key.character).lowercased() + return !eventKey.isEmpty && eventKey == shortcutKey + } + + func normalizedModifiers(from flags: NSEvent.ModifierFlags) -> SwiftUI.EventModifiers { + var normalized: SwiftUI.EventModifiers = [] + if flags.contains(.command) { normalized.insert(.command) } + if flags.contains(.shift) { normalized.insert(.shift) } + if flags.contains(.option) { normalized.insert(.option) } + if flags.contains(.control) { normalized.insert(.control) } + return normalized + } + + func bindingFlags( + for event: NSEvent, + surface: ghostty_surface_t + ) -> ghostty_binding_flags_e? { + var key = ghosttyKeyEvent( + event, + action: GHOSTTY_ACTION_PRESS, + originalMods: event.modifierFlags, + translationMods: event.modifierFlags + ) + var flags = ghostty_binding_flags_e(0) + let isBinding = (event.characters ?? "").withCString { ptr in + key.text = ptr + return ghostty_surface_key_is_binding(surface, key, &flags) + } + return isBinding ? flags : nil + } + + func equivalentKey(for event: NSEvent) -> String? { + switch event.charactersIgnoringModifiers { + case "\r": + guard event.modifierFlags.contains(.control) else { return nil } + return "\r" + case "/": + guard event.modifierFlags.contains(.control) else { return nil } + guard event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else { return nil } + return "_" + default: + if event.timestamp == 0 { return nil } + if !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) { + lastPerformKeyEvent = nil + return nil + } + if let lastPerformKeyEvent { + self.lastPerformKeyEvent = nil + if lastPerformKeyEvent == event.timestamp { + return event.characters ?? "" + } + } + lastPerformKeyEvent = event.timestamp + return nil + } + } + + override func doCommand(by selector: Selector) { + if let lastPerformKeyEvent, + let current = NSApp.currentEvent, + lastPerformKeyEvent == current.timestamp + { + NSApp.sendEvent(current) + return + } + switch selector { + case #selector(moveToBeginningOfDocument(_:)): + performBindingAction("scroll_to_top") + case #selector(moveToEndOfDocument(_:)): + performBindingAction("scroll_to_bottom") + default: + break + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + switch event.type { + case .rightMouseDown: + break + case .leftMouseDown: + if !event.modifierFlags.contains(.control) { + return nil + } + guard let surface else { return nil } + if ghostty_surface_mouse_captured(surface) { + return nil + } + let mods = ghosttyMods(event.modifierFlags) + _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) + default: + return nil + } + + guard let surface else { return nil } + if ghostty_surface_mouse_captured(surface) { + return nil + } + + let menu = NSMenu() + if ghostty_surface_has_selection(surface) { + menu.addItem(NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "")) + } + menu.addItem(NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem( + menuItem( + title: "Split Right", + action: #selector(splitRight(_:)), + symbol: "rectangle.righthalf.inset.filled" + )) + menu.addItem( + menuItem( + title: "Split Left", + action: #selector(splitLeft(_:)), + symbol: "rectangle.leadinghalf.inset.filled" + )) + menu.addItem( + menuItem( + title: "Split Down", + action: #selector(splitDown(_:)), + symbol: "rectangle.bottomhalf.inset.filled" + )) + menu.addItem( + menuItem( + title: "Split Up", + action: #selector(splitUp(_:)), + symbol: "rectangle.tophalf.inset.filled" + )) + menu.addItem(.separator()) + menu.addItem( + menuItem( + title: "Reset Terminal", + action: #selector(resetTerminal(_:)), + symbol: "arrow.trianglehead.2.clockwise" + )) + menu.addItem(.separator()) + menu.addItem( + menuItem( + title: "Change Title...", + action: #selector(changeTitle(_:)), + symbol: "pencil.line" + )) + return menu + } + + func menuItem(title: String, action: Selector, symbol: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + return item + } + + @IBAction func splitRight(_ sender: Any?) { + _ = bridge.onSplitAction?(.newSplit(direction: .right)) + } + + @IBAction func splitLeft(_ sender: Any?) { + _ = bridge.onSplitAction?(.newSplit(direction: .left)) + } + + @IBAction func splitDown(_ sender: Any?) { + _ = bridge.onSplitAction?(.newSplit(direction: .down)) + } + + @IBAction func splitUp(_ sender: Any?) { + _ = bridge.onSplitAction?(.newSplit(direction: .top)) + } + + @IBAction func resetTerminal(_ sender: Any?) { + performBindingAction("reset") + } + + @IBAction func changeTitle(_ sender: Any?) { + performBindingAction("prompt_surface_title") + } + + static func hasKeyEquivalentFocusOwnership( + cachedFocused: Bool, + isActualFirstResponder: Bool + ) -> Bool { + cachedFocused && isActualFirstResponder + } + + static func mainMenuHasMatchingItem(for event: NSEvent, in menu: NSMenu) -> Bool { + let eventEquivalents = normalizedEventKeyEquivalents(for: event) + guard !eventEquivalents.isEmpty else { return false } + + for item in menu.items { + if let submenu = item.submenu, mainMenuHasMatchingItem(for: event, in: submenu) { + return true + } + + guard !item.keyEquivalent.isEmpty else { continue } + guard + let itemEquivalent = normalizedKeyEquivalent( + key: item.keyEquivalent, + modifiers: item.keyEquivalentModifierMask + ) + else { continue } + if eventEquivalents.contains(where: { $0 == itemEquivalent }) { + return true + } + } + + return false + } + + static let shortcutMask: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + static let shiftedKeyEquivalentBases: [Character: Character] = [ + "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", + "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", + ":": ";", "\"": "'", "<": ",", ">": ".", "?": "/", + ] + + struct KeyEquivalentSignature: Equatable { + var key: String + var modifiers: NSEvent.ModifierFlags + } + + static func normalizedEventKeyEquivalents(for event: NSEvent) -> [KeyEquivalentSignature] { + let eventModifiers = event.modifierFlags.intersection(shortcutMask) + return [event.charactersIgnoringModifiers, event.characters] + .compactMap { characters in + characters.flatMap { normalizedKeyEquivalent(key: $0, modifiers: eventModifiers) } + } + .reduce(into: []) { result, equivalent in + if !result.contains(equivalent) { + result.append(equivalent) + } + } + } + + static func normalizedKeyEquivalent( + key: String, + modifiers: NSEvent.ModifierFlags + ) -> KeyEquivalentSignature? { + guard !key.isEmpty else { return nil } + + var normalizedKey = key.lowercased() + var normalizedModifiers = modifiers.intersection(shortcutMask) + + if key.count == 1, let character = key.first { + if let base = shiftedKeyEquivalentBases[character] { + normalizedKey = String(base) + normalizedModifiers.insert(.shift) + } else if normalizedKey != key { + normalizedModifiers.insert(.shift) + } + } + + return KeyEquivalentSignature(key: normalizedKey, modifiers: normalizedModifiers) + } + + func shouldAttemptMenu(for flags: ghostty_binding_flags_e) -> Bool { + if bridge.state.keySequenceActive == true { return false } + if bridge.state.keyTableDepth > 0 { return false } + let raw = flags.rawValue + let isAll = (raw & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 + let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 + let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 + return !isAll && !isPerformable && isConsumed + } + + @IBAction func copy(_ sender: Any?) { + performBindingAction("copy_to_clipboard") + } + + @IBAction func paste(_ sender: Any?) { + performBindingAction("paste_from_clipboard") + if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { + onCommittedText?(text) + } + } + + @IBAction func pasteSelection(_ sender: Any?) { + performBindingAction("paste_from_selection") + } + + @IBAction override func selectAll(_ sender: Any?) { + performBindingAction("select_all") + } + + @discardableResult + func sendKey( + action: ghostty_input_action_e, + event: NSEvent, + translationEvent: NSEvent? = nil, + translationMods: NSEvent.ModifierFlags? = nil, + text: String? = nil, + composing: Bool = false + ) -> Bool { + guard let surface else { return false } + let resolvedEvent: NSEvent + let resolvedMods: NSEvent.ModifierFlags + if let translationEvent, let translationMods { + resolvedEvent = translationEvent + resolvedMods = translationMods + } else { + (resolvedEvent, resolvedMods) = translationState(event, surface: surface) + } + var key = ghosttyKeyEvent( + resolvedEvent, + action: action, + originalMods: event.modifierFlags, + translationMods: resolvedMods, + composing: composing + ) + let finalText = text ?? ghosttyCharacters(resolvedEvent) + if let finalText, !finalText.isEmpty, + let codepoint = finalText.utf8.first, codepoint >= 0x20 + { + return finalText.withCString { ptr in + key.text = ptr + return ghostty_surface_key(surface, key) + } + } + key.text = nil + return ghostty_surface_key(surface, key) + } + + func performBindingAction(_ action: String) { + guard let surface else { return } + _ = action.withCString { ptr in + ghostty_surface_binding_action(surface, ptr, UInt(action.lengthOfBytes(using: .utf8))) + } + } + +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift new file mode 100644 index 00000000..93cd1fd3 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift @@ -0,0 +1,217 @@ +import AppKit +import CoreText +import GhosttyKit + +extension GhosttySurfaceView { + override func mouseMoved(with event: NSEvent) { + sendMousePosition(event) + if let window, window.isKeyWindow, !focused, runtime.focusFollowsMouse() { + requestFocus() + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + sendMousePosition(event) + } + + override func mouseExited(with event: NSEvent) { + if NSEvent.pressedMouseButtons != 0 { + return + } + guard let surface else { return } + let mods = ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_pos(surface, -1, -1, mods) + } + + override func mouseDown(with event: NSEvent) { + sendMouseButton(event, state: GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_LEFT) + } + + override func mouseUp(with event: NSEvent) { + prevPressureStage = 0 + sendMouseButton(event, state: GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_LEFT) + if let surface { + ghostty_surface_mouse_pressure(surface, 0, 0) + } + } + + override func rightMouseDown(with event: NSEvent) { + guard let surface else { + super.rightMouseDown(with: event) + return + } + let mods = ghosttyMods(event.modifierFlags) + if ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) { + return + } + super.rightMouseDown(with: event) + } + + override func rightMouseUp(with event: NSEvent) { + guard let surface else { + super.rightMouseUp(with: event) + return + } + let mods = ghosttyMods(event.modifierFlags) + if ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) { + return + } + super.rightMouseUp(with: event) + } + + override func otherMouseDown(with event: NSEvent) { + sendMouseButton( + event, state: GHOSTTY_MOUSE_PRESS, button: Self.ghosttyMouseButton(from: event.buttonNumber)) + } + + override func otherMouseUp(with event: NSEvent) { + sendMouseButton( + event, state: GHOSTTY_MOUSE_RELEASE, button: Self.ghosttyMouseButton(from: event.buttonNumber) + ) + } + + static func ghosttyMouseButton(from buttonNumber: Int) -> ghostty_input_mouse_button_e { + switch buttonNumber { + case 0: GHOSTTY_MOUSE_LEFT + case 1: GHOSTTY_MOUSE_RIGHT + case 2: GHOSTTY_MOUSE_MIDDLE + case 3: GHOSTTY_MOUSE_EIGHT + case 4: GHOSTTY_MOUSE_NINE + case 5: GHOSTTY_MOUSE_SIX + case 6: GHOSTTY_MOUSE_SEVEN + case 7: GHOSTTY_MOUSE_FOUR + case 8: GHOSTTY_MOUSE_FIVE + case 9: GHOSTTY_MOUSE_TEN + case 10: GHOSTTY_MOUSE_ELEVEN + default: GHOSTTY_MOUSE_UNKNOWN + } + } + + override func mouseDragged(with event: NSEvent) { + sendMousePosition(event) + } + + override func rightMouseDragged(with event: NSEvent) { + sendMousePosition(event) + } + + override func otherMouseDragged(with event: NSEvent) { + sendMousePosition(event) + } + + override func scrollWheel(with event: NSEvent) { + guard let surface else { return } + var scrollX = event.scrollingDeltaX + var scrollY = event.scrollingDeltaY + if event.hasPreciseScrollingDeltas { + scrollX *= 2 + scrollY *= 2 + } + ghostty_surface_mouse_scroll(surface, scrollX, scrollY, scrollMods(for: event)) + } + + override func pressureChange(with event: NSEvent) { + guard let surface else { return } + ghostty_surface_mouse_pressure(surface, UInt32(event.stage), Double(event.pressure)) + guard prevPressureStage < 2 else { return } + prevPressureStage = event.stage + guard event.stage == 2 else { return } + guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } + quickLook(with: event) + } + + override func quickLook(with event: NSEvent) { + guard let surface else { return super.quickLook(with: event) } + var text = ghostty_text_s() + guard ghostty_surface_quicklook_word(surface, &text) else { + return super.quickLook(with: event) + } + defer { ghostty_surface_free_text(surface, &text) } + guard text.text_len > 0 else { return super.quickLook(with: event) } + + var attributes: [NSAttributedString.Key: Any] = [:] + if let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + + let str = NSAttributedString(string: Self.string(from: text), attributes: attributes) + let point = NSPoint(x: text.tl_px_x, y: frame.size.height - text.tl_px_y) + showDefinition(for: str, at: point) + } + + func localEventHandler(_ event: NSEvent) -> NSEvent? { + switch event.type { + case .keyUp: + localEventKeyUp(event) + case .leftMouseDown: + localEventLeftMouseDown(event) + default: + event + } + } + + func localEventKeyUp(_ event: NSEvent) -> NSEvent? { + if !event.modifierFlags.contains(.command) { return event } + guard focused else { return event } + keyUp(with: event) + return nil + } + + func localEventLeftMouseDown(_ event: NSEvent) -> NSEvent? { + guard let window, event.window != nil, window == event.window else { return event } + let location = convert(event.locationInWindow, from: nil) + guard hitTest(location) == self else { return event } + guard !NSApp.isActive || !window.isKeyWindow else { return event } + guard !focused else { return event } + window.makeFirstResponder(self) + return event + } + + func scrollMods(for event: NSEvent) -> ghostty_input_scroll_mods_t { + var value: Int32 = 0 + if event.hasPreciseScrollingDeltas { + value |= 0b0000_0001 + } + let momentum: Int32 + switch event.momentumPhase { + case .began: + momentum = 1 + case .stationary: + momentum = 2 + case .changed: + momentum = 3 + case .ended: + momentum = 4 + case .cancelled: + momentum = 5 + case .mayBegin: + momentum = 6 + default: + momentum = 0 + } + value |= (momentum << 1) + return ghostty_input_scroll_mods_t(value) + } + + func sendMousePosition(_ event: NSEvent) { + guard let surface else { return } + let point = convert(event.locationInWindow, from: nil) + let yPosition = bounds.height - point.y + let mods = ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_pos(surface, point.x, yPosition, mods) + } + + func sendMouseButton( + _ event: NSEvent, + state: ghostty_input_mouse_state_e, + button: ghostty_input_mouse_button_e + ) { + guard let surface else { return } + let mods = ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, state, button, mods) + } + +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Services.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Services.swift new file mode 100644 index 00000000..5e4ba72a --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Services.swift @@ -0,0 +1,45 @@ +import AppKit +import GhosttyKit + +extension GhosttySurfaceView: NSServicesMenuRequestor { + override func validRequestor( + forSendType sendType: NSPasteboard.PasteboardType?, + returnType: NSPasteboard.PasteboardType? + ) -> Any? { + let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] + let sendable = receivable + let sendableRequiresSelection = sendable + + if (returnType == nil || receivable.contains(returnType!)) + && (sendType == nil || sendable.contains(sendType!)) + { + if let sendType, sendableRequiresSelection.contains(sendType) { + if surface == nil || !ghostty_surface_has_selection(surface) { + return super.validRequestor(forSendType: sendType, returnType: returnType) + } + } + return self + } + return super.validRequestor(forSendType: sendType, returnType: returnType) + } + + func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool { + guard let surface else { return false } + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return false } + defer { ghostty_surface_free_text(surface, &text) } + pboard.declareTypes([.string], owner: nil) + pboard.setString(Self.string(from: text), forType: .string) + return true + } + + func readSelection(from pboard: NSPasteboard) -> Bool { + guard let str = pboard.getOpinionatedStringContents() else { return false } + let len = str.utf8CString.count + if len == 0 { return true } + str.withCString { ptr in + ghostty_surface_text(surface, ptr, UInt(len - 1)) + } + return true + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+TextInput.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+TextInput.swift new file mode 100644 index 00000000..75c23d14 --- /dev/null +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+TextInput.swift @@ -0,0 +1,233 @@ +import AppKit +import CoreText +import GhosttyKit + +extension GhosttySurfaceView: NSTextInputClient { + func hasMarkedText() -> Bool { + markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange() } + return NSRange(location: 0, length: markedText.length) + } + + func selectedRange() -> NSRange { + guard let surface else { return NSRange() } + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return NSRange() } + defer { ghostty_surface_free_text(surface, &text) } + return NSRange(location: Int(text.offset_start), length: Int(text.offset_len)) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + switch string { + case let attributedText as NSAttributedString: + markedText = NSMutableAttributedString(attributedString: attributedText) + case let stringValue as String: + markedText = NSMutableAttributedString(string: stringValue) + default: + return + } + if keyTextAccumulator == nil { + syncPreedit() + } + } + + func unmarkText() { + if markedText.length > 0 { + markedText.mutableString.setString("") + syncPreedit() + } + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + [] + } + + func attributedSubstring( + forProposedRange range: NSRange, + actualRange: NSRangePointer? + ) -> NSAttributedString? { + guard let surface else { return nil } + guard range.length > 0 else { return nil } + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + var attributes: [NSAttributedString.Key: Any] = [:] + if let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + return NSAttributedString(string: Self.string(from: text), attributes: attributes) + } + + func characterIndex(for point: NSPoint) -> Int { + 0 + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let surface else { + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) + } + var caretX: Double = 0 + var caretY: Double = 0 + var width: Double = cellSize.width + var height: Double = cellSize.height + if range.length > 0, range != selectedRange() { + var text = ghostty_text_s() + if ghostty_surface_read_selection(surface, &text) { + caretX = text.tl_px_x - 2 + caretY = text.tl_px_y + 2 + ghostty_surface_free_text(surface, &text) + } else { + ghostty_surface_ime_point(surface, &caretX, &caretY, &width, &height) + } + } else { + ghostty_surface_ime_point(surface, &caretX, &caretY, &width, &height) + } + if range.length == 0, width > 0 { + width = 0 + caretX += cellSize.width * Double(range.location + range.length) + } + let viewRect = NSRect( + x: caretX, + y: frame.size.height - caretY, + width: width, + height: max(height, cellSize.height) + ) + let winRect = convert(viewRect, to: nil) + guard let window else { return winRect } + return window.convertToScreen(winRect) + } + + func insertText(_ string: Any, replacementRange _: NSRange) { + guard NSApp.currentEvent != nil else { return } + guard surface != nil else { return } + var chars = "" + switch string { + case let attributedText as NSAttributedString: + chars = attributedText.string + case let stringValue as String: + chars = stringValue + default: + return + } + unmarkText() + if var acc = keyTextAccumulator { + acc.append(chars) + keyTextAccumulator = acc + onCommittedText?(chars) + return + } + insertCommittedTextForBroadcast(chars) + onCommittedText?(chars) + } + + func insertCommittedTextForBroadcast(_ text: String) { + guard let surface else { return } + guard !text.isEmpty else { return } + unmarkText() + let len = text.utf8CString.count + guard len > 0 else { return } + text.withCString { ptr in + ghostty_surface_text(surface, ptr, UInt(len - 1)) + } + } + + @discardableResult + func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool { + let windowNumber = window?.windowNumber ?? 0 + guard let keyDownEvent = key.keyDownEvent(windowNumber: windowNumber) else { + return false + } + keyDown(with: keyDownEvent) + if !key.isRepeat, + let keyUpEvent = key.keyUpEvent(windowNumber: windowNumber) + { + keyUp(with: keyUpEvent) + } + return true + } + + @discardableResult + func submitLine() -> Bool { + let timestamp = ProcessInfo.processInfo.systemUptime + let windowNumber = window?.windowNumber ?? 0 + guard + let keyDownEvent = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: timestamp, + windowNumber: windowNumber, + context: nil, + characters: "\r", + charactersIgnoringModifiers: "\r", + isARepeat: false, + keyCode: 36 + ), + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: [], + timestamp: timestamp, + windowNumber: windowNumber, + context: nil, + characters: "\r", + charactersIgnoringModifiers: "\r", + isARepeat: false, + keyCode: 36 + ) + else { + return false + } + keyDown(with: keyDownEvent) + keyUp(with: keyUpEvent) + return true + } + + // MARK: - CLI key token delivery + + /// Send a single key event for a canonical CLI token (e.g. "enter", "ctrl-c", "pageup"). + /// Creates synthetic keyDown/keyUp NSEvents matching the token spec. + @discardableResult + func sendCLIKeyToken(_ token: String) -> Bool { + guard surface != nil else { return false } + guard let spec = CLIKeySpec.from(token: token) else { return false } + let timestamp = ProcessInfo.processInfo.systemUptime + let windowNumber = window?.windowNumber ?? 0 + guard + let keyDownEvent = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: spec.modifiers, + timestamp: timestamp, + windowNumber: windowNumber, + context: nil, + characters: spec.characters, + charactersIgnoringModifiers: spec.charactersIgnoringModifiers, + isARepeat: false, + keyCode: spec.keyCode + ), + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: spec.modifiers, + timestamp: timestamp, + windowNumber: windowNumber, + context: nil, + characters: spec.characters, + charactersIgnoringModifiers: spec.charactersIgnoringModifiers, + isARepeat: false, + keyCode: spec.keyCode + ) + else { + return false + } + keyDown(with: keyDownEvent) + keyUp(with: keyUpEvent) + return true + } +} diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 03739102..33aedc20 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -5,7 +5,7 @@ import GhosttyKit import QuartzCore import SwiftUI -private let surfaceLogger = SupaLogger("Surface") +let surfaceLogger = SupaLogger("Surface") enum GhosttyEventText { static func characters(for event: NSEvent) -> String? { @@ -80,7 +80,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } } - private final class CachedValue { + final class CachedValue { private var value: T? private let fetch: () -> T private let duration: Duration @@ -114,7 +114,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } } - private let runtime: GhosttyRuntime + let runtime: GhosttyRuntime let id = UUID() private var debugID: String { String(id.uuidString.prefix(8)) @@ -138,22 +138,22 @@ final class GhosttySurfaceView: NSView, Identifiable { private let skipsSurfaceCreationForTesting: Bool private var trackingArea: NSTrackingArea? private var lastBackingSize: CGSize = .zero - private var lastPerformKeyEvent: TimeInterval? + var lastPerformKeyEvent: TimeInterval? private var currentCursor: NSCursor = .iBeam - private var focused = false + var focused = false private var detachedFocusClearTask: Task? - private var markedText = NSMutableAttributedString() - private var keyboardLayoutChangeKeyUpSuppression: KeyboardLayoutChangeKeyUpSuppression? - private var keyTextAccumulator: [String]? - private var cellSize: CGSize = .zero + var markedText = NSMutableAttributedString() + var keyboardLayoutChangeKeyUpSuppression: KeyboardLayoutChangeKeyUpSuppression? + var keyTextAccumulator: [String]? + var cellSize: CGSize = .zero private var lastScrollbar: ScrollbarState? private var occlusionState = OcclusionState() private var lastSurfaceFocus: Bool? private var eventMonitor: Any? private var notificationObservers: [NSObjectProtocol] = [] - private var prevPressureStage: Int = 0 + var prevPressureStage: Int = 0 private var isBackgroundOpaqueOverride = false - private lazy var cachedScreenContents = CachedValue(duration: .milliseconds(500)) { + lazy var cachedScreenContents = CachedValue(duration: .milliseconds(500)) { [weak self] in self?.readScreenContents() ?? "" } @@ -187,7 +187,7 @@ final class GhosttySurfaceView: NSView, Identifiable { var onOcclusionAppliedForTesting: ((Bool) -> Void)? var attachmentStateForTesting: (() -> (hasSuperview: Bool, hasWindow: Bool))? - private var accessibilityPaneIndexHelp: String? + var accessibilityPaneIndexHelp: String? private static let mouseCursorMap: [ghostty_action_mouse_shape_e: NSCursor] = [ GHOSTTY_MOUSE_SHAPE_DEFAULT: .arrow, @@ -214,7 +214,7 @@ final class GhosttySurfaceView: NSView, Identifiable { GHOSTTY_MOUSE_SHAPE_S_RESIZE, GHOSTTY_MOUSE_SHAPE_NS_RESIZE, ] - private static let dropTypes: Set = [ + static let dropTypes: Set = [ .string, .fileURL, .URL, @@ -245,7 +245,7 @@ final class GhosttySurfaceView: NSView, Identifiable { return String(bytes: buffer, encoding: .utf8) ?? "" } - private static func string(from text: ghostty_text_s) -> String { + static func string(from text: ghostty_text_s) -> String { stringFromGhosttyText(pointer: text.text, length: text.text_len) } @@ -563,424 +563,6 @@ final class GhosttySurfaceView: NSView, Identifiable { } } - func setAccessibilityPaneIndex(index: Int, total: Int) { - guard total > 0, index > 0, index <= total else { - accessibilityPaneIndexHelp = nil - return - } - accessibilityPaneIndexHelp = "Pane \(index) of \(total)" - } - - override func isAccessibilityElement() -> Bool { - // Avoid interacting with panes after teardown. - surface != nil - } - - override func accessibilityRole() -> NSAccessibility.Role? { - // Match Ghostty.app so speech/input tools can treat the surface as editable text. - .textArea - } - - override func accessibilityLabel() -> String? { - let title = bridge.state.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !title.isEmpty { - return title - } - let pwd = bridge.state.pwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !pwd.isEmpty { - return pwd - } - return "Terminal pane" - } - - override func accessibilityValue() -> Any? { - cachedScreenContents.get() - } - - override func accessibilityHelp() -> String? { - accessibilityPaneIndexHelp - } - - override func accessibilitySelectedTextRange() -> NSRange { - selectedRange() - } - - override func accessibilitySelectedText() -> String? { - guard let surface else { return nil } - var text = ghostty_text_s() - guard ghostty_surface_read_selection(surface, &text) else { return nil } - defer { ghostty_surface_free_text(surface, &text) } - let value = Self.string(from: text) - return value.isEmpty ? nil : value - } - - override func accessibilityNumberOfCharacters() -> Int { - cachedScreenContents.get().count - } - - override func accessibilityVisibleCharacterRange() -> NSRange { - let content = cachedScreenContents.get() - return NSRange(location: 0, length: content.count) - } - - override func accessibilityLine(for index: Int) -> Int { - Self.accessibilityLine(for: index, in: cachedScreenContents.get()) - } - - override func accessibilityString(for range: NSRange) -> String? { - Self.accessibilityString(for: range, in: cachedScreenContents.get()) - } - - override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { - guard let surface else { return nil } - guard let plainString = accessibilityString(for: range) else { return nil } - - var attributes: [NSAttributedString.Key: Any] = [:] - if let fontRaw = ghostty_surface_quicklook_font(surface) { - let font = Unmanaged.fromOpaque(fontRaw) - attributes[.font] = font.takeUnretainedValue() - font.release() - } - - return NSAttributedString(string: plainString, attributes: attributes) - } - - override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - if result { - focusDidChange(true) - postAccessibilityFocusChanged() - } - return result - } - - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - if result { - focusDidChange(false) - } - return result - } - - private func postAccessibilityFocusChanged() { - guard surface != nil else { return } - // Post on the window so assistive tech can query the focused element from it. - if let window { - NSAccessibility.post(element: window, notification: .focusedUIElementChanged) - } else { - NSAccessibility.post(element: self, notification: .focusedUIElementChanged) - } - } - - private func readText( - topLeftTag: ghostty_point_tag_e, - bottomRightTag: ghostty_point_tag_e - ) -> String? { - guard let surface else { return nil } - var text = ghostty_text_s() - let selection = ghostty_selection_s( - top_left: ghostty_point_s( - tag: topLeftTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ), - bottom_right: ghostty_point_s( - tag: bottomRightTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ), - rectangle: false - ) - guard ghostty_surface_read_text(surface, selection, &text) else { return nil } - defer { ghostty_surface_free_text(surface, &text) } - return Self.string(from: text) - } - - private func readScreenContents() -> String { - readText( - topLeftTag: GHOSTTY_POINT_SCREEN, - bottomRightTag: GHOSTTY_POINT_SCREEN - ) ?? "" - } - - func readViewportContentsForCLI() -> String? { - readText( - topLeftTag: GHOSTTY_POINT_VIEWPORT, - bottomRightTag: GHOSTTY_POINT_VIEWPORT - ) - } - - func readActiveContentsForCLI() -> String? { - readText( - topLeftTag: GHOSTTY_POINT_ACTIVE, - bottomRightTag: GHOSTTY_POINT_ACTIVE - ) - } - - func readScreenContentsForCLI() -> String? { - readText( - topLeftTag: GHOSTTY_POINT_SCREEN, - bottomRightTag: GHOSTTY_POINT_SCREEN - ) - } - - override func keyDown(with event: NSEvent) { - guard let surface else { - interpretKeyEvents([event]) - return - } - bridge.state.bellCount = 0 - onKeyInput?() - if let mirroredKey = MirroredTerminalKey(event: event) { - onMirroredKey?(mirroredKey) - } - let (translationEvent, translationMods) = translationState(event, surface: surface) - let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - keyTextAccumulator = [] - defer { keyTextAccumulator = nil } - let markedTextBefore = markedText.length > 0 - let keyboardIdBefore = markedTextBefore ? nil : keyboardLayoutId() - lastPerformKeyEvent = nil - interpretKeyEvents([translationEvent]) - if !markedTextBefore, keyboardIdBefore != keyboardLayoutId() { - keyboardLayoutChangeKeyUpSuppression = KeyboardLayoutChangeKeyUpSuppression( - keyCode: event.keyCode, - timestamp: event.timestamp - ) - return - } - syncPreedit(clearIfNeeded: markedTextBefore) - if let list = keyTextAccumulator, !list.isEmpty { - for text in list { - _ = sendKey( - action: action, - event: event, - translationEvent: translationEvent, - translationMods: translationMods, - text: text, - composing: false - ) - } - } else { - _ = sendKey( - action: action, - event: event, - translationEvent: translationEvent, - translationMods: translationMods, - text: ghosttyCharacters(translationEvent), - composing: markedText.length > 0 || markedTextBefore - ) - } - } - - override func keyUp(with event: NSEvent) { - if suppressKeyboardLayoutChangeKeyUp(event) { return } - sendKey(action: GHOSTTY_ACTION_RELEASE, event: event) - } - - override func flagsChanged(with event: NSEvent) { - let mod: UInt32 - switch event.keyCode { - case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue - case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue - case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue - case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue - case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue - default: return - } - if hasMarkedText() { return } - let mods = ghosttyMods(event.modifierFlags) - var action = GHOSTTY_ACTION_RELEASE - if (mods.rawValue & mod) != 0 { - let sidePressed: Bool - switch event.keyCode { - case 0x3C: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0 - case 0x3E: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0 - case 0x3D: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0 - case 0x36: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0 - default: - sidePressed = true - } - if sidePressed { - action = GHOSTTY_ACTION_PRESS - } - } - sendKey(action: action, event: event) - } - - override func mouseMoved(with event: NSEvent) { - sendMousePosition(event) - if let window, window.isKeyWindow, !focused, runtime.focusFollowsMouse() { - requestFocus() - } - } - - override func mouseEntered(with event: NSEvent) { - super.mouseEntered(with: event) - sendMousePosition(event) - } - - override func mouseExited(with event: NSEvent) { - if NSEvent.pressedMouseButtons != 0 { - return - } - guard let surface else { return } - let mods = ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, -1, -1, mods) - } - - override func mouseDown(with event: NSEvent) { - sendMouseButton(event, state: GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_LEFT) - } - - override func mouseUp(with event: NSEvent) { - prevPressureStage = 0 - sendMouseButton(event, state: GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_LEFT) - if let surface { - ghostty_surface_mouse_pressure(surface, 0, 0) - } - } - - override func rightMouseDown(with event: NSEvent) { - guard let surface else { - super.rightMouseDown(with: event) - return - } - let mods = ghosttyMods(event.modifierFlags) - if ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) { - return - } - super.rightMouseDown(with: event) - } - - override func rightMouseUp(with event: NSEvent) { - guard let surface else { - super.rightMouseUp(with: event) - return - } - let mods = ghosttyMods(event.modifierFlags) - if ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) { - return - } - super.rightMouseUp(with: event) - } - - override func otherMouseDown(with event: NSEvent) { - sendMouseButton( - event, state: GHOSTTY_MOUSE_PRESS, button: Self.ghosttyMouseButton(from: event.buttonNumber)) - } - - override func otherMouseUp(with event: NSEvent) { - sendMouseButton( - event, state: GHOSTTY_MOUSE_RELEASE, button: Self.ghosttyMouseButton(from: event.buttonNumber) - ) - } - - private static func ghosttyMouseButton(from buttonNumber: Int) -> ghostty_input_mouse_button_e { - switch buttonNumber { - case 0: GHOSTTY_MOUSE_LEFT - case 1: GHOSTTY_MOUSE_RIGHT - case 2: GHOSTTY_MOUSE_MIDDLE - case 3: GHOSTTY_MOUSE_EIGHT - case 4: GHOSTTY_MOUSE_NINE - case 5: GHOSTTY_MOUSE_SIX - case 6: GHOSTTY_MOUSE_SEVEN - case 7: GHOSTTY_MOUSE_FOUR - case 8: GHOSTTY_MOUSE_FIVE - case 9: GHOSTTY_MOUSE_TEN - case 10: GHOSTTY_MOUSE_ELEVEN - default: GHOSTTY_MOUSE_UNKNOWN - } - } - - override func mouseDragged(with event: NSEvent) { - sendMousePosition(event) - } - - override func rightMouseDragged(with event: NSEvent) { - sendMousePosition(event) - } - - override func otherMouseDragged(with event: NSEvent) { - sendMousePosition(event) - } - - override func scrollWheel(with event: NSEvent) { - guard let surface else { return } - var scrollX = event.scrollingDeltaX - var scrollY = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - scrollX *= 2 - scrollY *= 2 - } - ghostty_surface_mouse_scroll(surface, scrollX, scrollY, scrollMods(for: event)) - } - - override func pressureChange(with event: NSEvent) { - guard let surface else { return } - ghostty_surface_mouse_pressure(surface, UInt32(event.stage), Double(event.pressure)) - guard prevPressureStage < 2 else { return } - prevPressureStage = event.stage - guard event.stage == 2 else { return } - guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } - quickLook(with: event) - } - - override func quickLook(with event: NSEvent) { - guard let surface else { return super.quickLook(with: event) } - var text = ghostty_text_s() - guard ghostty_surface_quicklook_word(surface, &text) else { - return super.quickLook(with: event) - } - defer { ghostty_surface_free_text(surface, &text) } - guard text.text_len > 0 else { return super.quickLook(with: event) } - - var attributes: [NSAttributedString.Key: Any] = [:] - if let fontRaw = ghostty_surface_quicklook_font(surface) { - let font = Unmanaged.fromOpaque(fontRaw) - attributes[.font] = font.takeUnretainedValue() - font.release() - } - - let str = NSAttributedString(string: Self.string(from: text), attributes: attributes) - let point = NSPoint(x: text.tl_px_x, y: frame.size.height - text.tl_px_y) - showDefinition(for: str, at: point) - } - - private func localEventHandler(_ event: NSEvent) -> NSEvent? { - switch event.type { - case .keyUp: - localEventKeyUp(event) - case .leftMouseDown: - localEventLeftMouseDown(event) - default: - event - } - } - - private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { - if !event.modifierFlags.contains(.command) { return event } - guard focused else { return event } - keyUp(with: event) - return nil - } - - private func localEventLeftMouseDown(_ event: NSEvent) -> NSEvent? { - guard let window, event.window != nil, window == event.window else { return event } - let location = convert(event.locationInWindow, from: nil) - guard hitTest(location) == self else { return event } - guard !NSApp.isActive || !window.isKeyWindow else { return event } - guard !focused else { return event } - window.makeFirstResponder(self) - return event - } - func updateSurfaceSize() { resumeDeferredOcclusionIfNeeded() guard let surface else { return } @@ -1235,1473 +817,4 @@ final class GhosttySurfaceView: NSView, Identifiable { window.makeFirstResponder(view) } } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - guard event.type == .keyDown else { return false } - let isFontSizeShortcut = matchesFontSizeShortcut(event: event) - guard let surface else { return false } - guard - Self.hasKeyEquivalentFocusOwnership( - cachedFocused: focused, - isActualFirstResponder: window?.firstResponder === self - ) - else { return false } - - if UserCustomShortcutRegistry.shared.matches(event: event), - let menu = NSApp.mainMenu, - Self.mainMenuHasMatchingItem(for: event, in: menu), - menu.performKeyEquivalent(with: event) - { - return true - } - - if let bindingFlags = bindingFlags(for: event, surface: surface) { - if shouldAttemptMenu(for: bindingFlags), - let menu = NSApp.mainMenu, - Self.mainMenuHasMatchingItem(for: event, in: menu), - menu.performKeyEquivalent(with: event) - { - return true - } - keyDown(with: event) - if isFontSizeShortcut { - onFontSizeShortcut?() - } - // Ghostty handled paste internally; broadcast the pasted text to followers. - if onCommittedText != nil, - event.modifierFlags.contains(.command), - event.charactersIgnoringModifiers == "v", - let text = NSPasteboard.general.string(forType: .string), - !text.isEmpty - { - onCommittedText?(text) - } - return true - } - - guard let equivalent = equivalentKey(for: event) else { return false } - - guard - let finalEvent = NSEvent.keyEvent( - with: .keyDown, - location: event.locationInWindow, - modifierFlags: event.modifierFlags, - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: equivalent, - charactersIgnoringModifiers: equivalent, - isARepeat: event.isARepeat, - keyCode: event.keyCode - ) - else { - return false - } - keyDown(with: finalEvent) - if isFontSizeShortcut { - onFontSizeShortcut?() - } - return true - } - - private func matchesFontSizeShortcut(event: NSEvent) -> Bool { - matchesBindingShortcut(event: event, action: "reset_font_size") - || matchesBindingShortcut(event: event, action: "increase_font_size:1") - || matchesBindingShortcut(event: event, action: "decrease_font_size:1") - } - - private func matchesBindingShortcut(event: NSEvent, action: String) -> Bool { - guard let shortcut = runtime.keyboardShortcut(for: action) else { return false } - let normalizedEventModifiers = normalizedModifiers(from: event.modifierFlags) - guard normalizedEventModifiers == shortcut.modifiers else { return false } - let eventKey = (event.charactersIgnoringModifiers ?? "").lowercased() - let shortcutKey = String(shortcut.key.character).lowercased() - return !eventKey.isEmpty && eventKey == shortcutKey - } - - private func normalizedModifiers(from flags: NSEvent.ModifierFlags) -> SwiftUI.EventModifiers { - var normalized: SwiftUI.EventModifiers = [] - if flags.contains(.command) { normalized.insert(.command) } - if flags.contains(.shift) { normalized.insert(.shift) } - if flags.contains(.option) { normalized.insert(.option) } - if flags.contains(.control) { normalized.insert(.control) } - return normalized - } - - private func bindingFlags( - for event: NSEvent, - surface: ghostty_surface_t - ) -> ghostty_binding_flags_e? { - var key = ghosttyKeyEvent( - event, - action: GHOSTTY_ACTION_PRESS, - originalMods: event.modifierFlags, - translationMods: event.modifierFlags - ) - var flags = ghostty_binding_flags_e(0) - let isBinding = (event.characters ?? "").withCString { ptr in - key.text = ptr - return ghostty_surface_key_is_binding(surface, key, &flags) - } - return isBinding ? flags : nil - } - - private func equivalentKey(for event: NSEvent) -> String? { - switch event.charactersIgnoringModifiers { - case "\r": - guard event.modifierFlags.contains(.control) else { return nil } - return "\r" - case "/": - guard event.modifierFlags.contains(.control) else { return nil } - guard event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else { return nil } - return "_" - default: - if event.timestamp == 0 { return nil } - if !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) { - lastPerformKeyEvent = nil - return nil - } - if let lastPerformKeyEvent { - self.lastPerformKeyEvent = nil - if lastPerformKeyEvent == event.timestamp { - return event.characters ?? "" - } - } - lastPerformKeyEvent = event.timestamp - return nil - } - } - - override func doCommand(by selector: Selector) { - if let lastPerformKeyEvent, - let current = NSApp.currentEvent, - lastPerformKeyEvent == current.timestamp - { - NSApp.sendEvent(current) - return - } - switch selector { - case #selector(moveToBeginningOfDocument(_:)): - performBindingAction("scroll_to_top") - case #selector(moveToEndOfDocument(_:)): - performBindingAction("scroll_to_bottom") - default: - break - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - switch event.type { - case .rightMouseDown: - break - case .leftMouseDown: - if !event.modifierFlags.contains(.control) { - return nil - } - guard let surface else { return nil } - if ghostty_surface_mouse_captured(surface) { - return nil - } - let mods = ghosttyMods(event.modifierFlags) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) - default: - return nil - } - - guard let surface else { return nil } - if ghostty_surface_mouse_captured(surface) { - return nil - } - - let menu = NSMenu() - if ghostty_surface_has_selection(surface) { - menu.addItem(NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "")) - } - menu.addItem(NSMenuItem(title: "Paste", action: #selector(paste(_:)), keyEquivalent: "")) - menu.addItem(.separator()) - menu.addItem( - menuItem( - title: "Split Right", - action: #selector(splitRight(_:)), - symbol: "rectangle.righthalf.inset.filled" - )) - menu.addItem( - menuItem( - title: "Split Left", - action: #selector(splitLeft(_:)), - symbol: "rectangle.leadinghalf.inset.filled" - )) - menu.addItem( - menuItem( - title: "Split Down", - action: #selector(splitDown(_:)), - symbol: "rectangle.bottomhalf.inset.filled" - )) - menu.addItem( - menuItem( - title: "Split Up", - action: #selector(splitUp(_:)), - symbol: "rectangle.tophalf.inset.filled" - )) - menu.addItem(.separator()) - menu.addItem( - menuItem( - title: "Reset Terminal", - action: #selector(resetTerminal(_:)), - symbol: "arrow.trianglehead.2.clockwise" - )) - menu.addItem(.separator()) - menu.addItem( - menuItem( - title: "Change Title...", - action: #selector(changeTitle(_:)), - symbol: "pencil.line" - )) - return menu - } - - private func menuItem(title: String, action: Selector, symbol: String) -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: "") - item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) - return item - } - - @IBAction func splitRight(_ sender: Any?) { - _ = bridge.onSplitAction?(.newSplit(direction: .right)) - } - - @IBAction func splitLeft(_ sender: Any?) { - _ = bridge.onSplitAction?(.newSplit(direction: .left)) - } - - @IBAction func splitDown(_ sender: Any?) { - _ = bridge.onSplitAction?(.newSplit(direction: .down)) - } - - @IBAction func splitUp(_ sender: Any?) { - _ = bridge.onSplitAction?(.newSplit(direction: .top)) - } - - @IBAction func resetTerminal(_ sender: Any?) { - performBindingAction("reset") - } - - @IBAction func changeTitle(_ sender: Any?) { - performBindingAction("prompt_surface_title") - } - - static func hasKeyEquivalentFocusOwnership( - cachedFocused: Bool, - isActualFirstResponder: Bool - ) -> Bool { - cachedFocused && isActualFirstResponder - } - - static func mainMenuHasMatchingItem(for event: NSEvent, in menu: NSMenu) -> Bool { - let eventEquivalents = normalizedEventKeyEquivalents(for: event) - guard !eventEquivalents.isEmpty else { return false } - - for item in menu.items { - if let submenu = item.submenu, mainMenuHasMatchingItem(for: event, in: submenu) { - return true - } - - guard !item.keyEquivalent.isEmpty else { continue } - guard - let itemEquivalent = normalizedKeyEquivalent( - key: item.keyEquivalent, - modifiers: item.keyEquivalentModifierMask - ) - else { continue } - if eventEquivalents.contains(where: { $0 == itemEquivalent }) { - return true - } - } - - return false - } - - private static let shortcutMask: NSEvent.ModifierFlags = [.shift, .control, .option, .command] - - private static let shiftedKeyEquivalentBases: [Character: Character] = [ - "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", - "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", - ":": ";", "\"": "'", "<": ",", ">": ".", "?": "/", - ] - - private struct KeyEquivalentSignature: Equatable { - var key: String - var modifiers: NSEvent.ModifierFlags - } - - private static func normalizedEventKeyEquivalents(for event: NSEvent) -> [KeyEquivalentSignature] { - let eventModifiers = event.modifierFlags.intersection(shortcutMask) - return [event.charactersIgnoringModifiers, event.characters] - .compactMap { characters in - characters.flatMap { normalizedKeyEquivalent(key: $0, modifiers: eventModifiers) } - } - .reduce(into: []) { result, equivalent in - if !result.contains(equivalent) { - result.append(equivalent) - } - } - } - - private static func normalizedKeyEquivalent( - key: String, - modifiers: NSEvent.ModifierFlags - ) -> KeyEquivalentSignature? { - guard !key.isEmpty else { return nil } - - var normalizedKey = key.lowercased() - var normalizedModifiers = modifiers.intersection(shortcutMask) - - if key.count == 1, let character = key.first { - if let base = shiftedKeyEquivalentBases[character] { - normalizedKey = String(base) - normalizedModifiers.insert(.shift) - } else if normalizedKey != key { - normalizedModifiers.insert(.shift) - } - } - - return KeyEquivalentSignature(key: normalizedKey, modifiers: normalizedModifiers) - } - - private func shouldAttemptMenu(for flags: ghostty_binding_flags_e) -> Bool { - if bridge.state.keySequenceActive == true { return false } - if bridge.state.keyTableDepth > 0 { return false } - let raw = flags.rawValue - let isAll = (raw & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 - let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 - let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 - return !isAll && !isPerformable && isConsumed - } - - @IBAction func copy(_ sender: Any?) { - performBindingAction("copy_to_clipboard") - } - - @IBAction func paste(_ sender: Any?) { - performBindingAction("paste_from_clipboard") - if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { - onCommittedText?(text) - } - } - - @IBAction func pasteSelection(_ sender: Any?) { - performBindingAction("paste_from_selection") - } - - @IBAction override func selectAll(_ sender: Any?) { - performBindingAction("select_all") - } - - @discardableResult - private func sendKey( - action: ghostty_input_action_e, - event: NSEvent, - translationEvent: NSEvent? = nil, - translationMods: NSEvent.ModifierFlags? = nil, - text: String? = nil, - composing: Bool = false - ) -> Bool { - guard let surface else { return false } - let resolvedEvent: NSEvent - let resolvedMods: NSEvent.ModifierFlags - if let translationEvent, let translationMods { - resolvedEvent = translationEvent - resolvedMods = translationMods - } else { - (resolvedEvent, resolvedMods) = translationState(event, surface: surface) - } - var key = ghosttyKeyEvent( - resolvedEvent, - action: action, - originalMods: event.modifierFlags, - translationMods: resolvedMods, - composing: composing - ) - let finalText = text ?? ghosttyCharacters(resolvedEvent) - if let finalText, !finalText.isEmpty, - let codepoint = finalText.utf8.first, codepoint >= 0x20 - { - return finalText.withCString { ptr in - key.text = ptr - return ghostty_surface_key(surface, key) - } - } - key.text = nil - return ghostty_surface_key(surface, key) - } - - func performBindingAction(_ action: String) { - guard let surface else { return } - _ = action.withCString { ptr in - ghostty_surface_binding_action(surface, ptr, UInt(action.lengthOfBytes(using: .utf8))) - } - } - - private func translationState(_ event: NSEvent, surface: ghostty_surface_t) -> ( - NSEvent, NSEvent.ModifierFlags - ) { - let translatedModsGhostty = ghostty_surface_key_translation_mods( - surface, ghosttyMods(event.modifierFlags)) - let translatedMods = appKitMods(translatedModsGhostty) - var resolved = event.modifierFlags - for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { - if translatedMods.contains(flag) { - resolved.insert(flag) - } else { - resolved.remove(flag) - } - } - if resolved == event.modifierFlags { - return (event, resolved) - } - let translatedEvent = - NSEvent.keyEvent( - with: event.type, - location: event.locationInWindow, - modifierFlags: resolved, - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: event.characters(byApplyingModifiers: resolved) ?? "", - charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", - isARepeat: event.isARepeat, - keyCode: event.keyCode - ) ?? event - return (translatedEvent, resolved) - } - - private func ghosttyKeyEvent( - _ event: NSEvent, - action: ghostty_input_action_e, - originalMods: NSEvent.ModifierFlags, - translationMods: NSEvent.ModifierFlags, - composing: Bool = false - ) -> ghostty_input_key_s { - var keyEvent: ghostty_input_key_s = .init() - keyEvent.action = action - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.text = nil - keyEvent.composing = composing - keyEvent.mods = ghosttyMods(originalMods) - keyEvent.consumed_mods = ghosttyMods(translationMods.subtracting([.control, .command])) - keyEvent.unshifted_codepoint = 0 - if event.type == .keyDown || event.type == .keyUp { - if let chars = event.characters(byApplyingModifiers: []), - let codepoint = chars.unicodeScalars.first - { - keyEvent.unshifted_codepoint = codepoint.value - } - } - return keyEvent - } - - private func suppressKeyboardLayoutChangeKeyUp(_ event: NSEvent) -> Bool { - guard let suppression = keyboardLayoutChangeKeyUpSuppression else { return false } - if suppression.isExpired(at: event.timestamp) { - keyboardLayoutChangeKeyUpSuppression = nil - return false - } - if suppression.suppresses(keyCode: event.keyCode, timestamp: event.timestamp) { - keyboardLayoutChangeKeyUpSuppression = nil - return true - } - return false - } - - private func ghosttyCharacters(_ event: NSEvent) -> String? { - GhosttyEventText.characters(for: event) - } - - private func syncPreedit(clearIfNeeded: Bool = true) { - guard let surface else { return } - if markedText.length > 0 { - let str = markedText.string - let len = str.utf8CString.count - if len > 0 { - markedText.string.withCString { ptr in - ghostty_surface_preedit(surface, ptr, UInt(len - 1)) - } - } - } else if clearIfNeeded { - ghostty_surface_preedit(surface, nil, 0) - } - } - - private func scrollMods(for event: NSEvent) -> ghostty_input_scroll_mods_t { - var value: Int32 = 0 - if event.hasPreciseScrollingDeltas { - value |= 0b0000_0001 - } - let momentum: Int32 - switch event.momentumPhase { - case .began: - momentum = 1 - case .stationary: - momentum = 2 - case .changed: - momentum = 3 - case .ended: - momentum = 4 - case .cancelled: - momentum = 5 - case .mayBegin: - momentum = 6 - default: - momentum = 0 - } - value |= (momentum << 1) - return ghostty_input_scroll_mods_t(value) - } - - private func keyboardLayoutId() -> String? { - guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { - return nil - } - guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { - return nil - } - let value = Unmanaged.fromOpaque(raw).takeUnretainedValue() - return value as String - } - - private func sendMousePosition(_ event: NSEvent) { - guard let surface else { return } - let point = convert(event.locationInWindow, from: nil) - let yPosition = bounds.height - point.y - let mods = ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, point.x, yPosition, mods) - } - - private func sendMouseButton( - _ event: NSEvent, - state: ghostty_input_mouse_state_e, - button: ghostty_input_mouse_button_e - ) { - guard let surface else { return } - let mods = ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, state, button, mods) - } - - private func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { - var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue - if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } - if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } - if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } - if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue } - let rawFlags = flags.rawValue - if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK)) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERCTLKEYMASK)) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERALTKEYMASK)) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERCMDKEYMASK)) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } - return ghostty_input_mods_e(mods) - } - - private func appKitMods(_ mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { - var flags: NSEvent.ModifierFlags = [] - if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 { flags.insert(.shift) } - if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 { flags.insert(.control) } - if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 { flags.insert(.option) } - if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 { flags.insert(.command) } - if (mods.rawValue & GHOSTTY_MODS_CAPS.rawValue) != 0 { flags.insert(.capsLock) } - return flags - } - -} - -extension GhosttySurfaceView { - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - guard let types = sender.draggingPasteboard.types else { return [] } - if Set(types).isDisjoint(with: Self.dropTypes) { - return [] - } - return .copy - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let pasteboard = sender.draggingPasteboard - let content: String? - if let url = pasteboard.string(forType: .URL) { - content = NSPasteboard.ghosttyEscape(url) - } else if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - !urls.isEmpty - { - content = urls.map { NSPasteboard.ghosttyEscape($0.path) }.joined(separator: " ") - } else if let str = pasteboard.string(forType: .string) { - content = str - } else { - content = nil - } - - guard let content else { return false } - Task { @MainActor in - self.insertText(content, replacementRange: NSRange(location: 0, length: 0)) - } - return true - } -} - -extension GhosttySurfaceView: NSTextInputClient { - func hasMarkedText() -> Bool { - markedText.length > 0 - } - - func markedRange() -> NSRange { - guard markedText.length > 0 else { return NSRange() } - return NSRange(location: 0, length: markedText.length) - } - - func selectedRange() -> NSRange { - guard let surface else { return NSRange() } - var text = ghostty_text_s() - guard ghostty_surface_read_selection(surface, &text) else { return NSRange() } - defer { ghostty_surface_free_text(surface, &text) } - return NSRange(location: Int(text.offset_start), length: Int(text.offset_len)) - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - switch string { - case let attributedText as NSAttributedString: - markedText = NSMutableAttributedString(attributedString: attributedText) - case let stringValue as String: - markedText = NSMutableAttributedString(string: stringValue) - default: - return - } - if keyTextAccumulator == nil { - syncPreedit() - } - } - - func unmarkText() { - if markedText.length > 0 { - markedText.mutableString.setString("") - syncPreedit() - } - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - [] - } - - func attributedSubstring( - forProposedRange range: NSRange, - actualRange: NSRangePointer? - ) -> NSAttributedString? { - guard let surface else { return nil } - guard range.length > 0 else { return nil } - var text = ghostty_text_s() - guard ghostty_surface_read_selection(surface, &text) else { return nil } - defer { ghostty_surface_free_text(surface, &text) } - var attributes: [NSAttributedString.Key: Any] = [:] - if let fontRaw = ghostty_surface_quicklook_font(surface) { - let font = Unmanaged.fromOpaque(fontRaw) - attributes[.font] = font.takeUnretainedValue() - font.release() - } - return NSAttributedString(string: Self.string(from: text), attributes: attributes) - } - - func characterIndex(for point: NSPoint) -> Int { - 0 - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let surface else { - return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) - } - var caretX: Double = 0 - var caretY: Double = 0 - var width: Double = cellSize.width - var height: Double = cellSize.height - if range.length > 0, range != selectedRange() { - var text = ghostty_text_s() - if ghostty_surface_read_selection(surface, &text) { - caretX = text.tl_px_x - 2 - caretY = text.tl_px_y + 2 - ghostty_surface_free_text(surface, &text) - } else { - ghostty_surface_ime_point(surface, &caretX, &caretY, &width, &height) - } - } else { - ghostty_surface_ime_point(surface, &caretX, &caretY, &width, &height) - } - if range.length == 0, width > 0 { - width = 0 - caretX += cellSize.width * Double(range.location + range.length) - } - let viewRect = NSRect( - x: caretX, - y: frame.size.height - caretY, - width: width, - height: max(height, cellSize.height) - ) - let winRect = convert(viewRect, to: nil) - guard let window else { return winRect } - return window.convertToScreen(winRect) - } - - func insertText(_ string: Any, replacementRange _: NSRange) { - guard NSApp.currentEvent != nil else { return } - guard surface != nil else { return } - var chars = "" - switch string { - case let attributedText as NSAttributedString: - chars = attributedText.string - case let stringValue as String: - chars = stringValue - default: - return - } - unmarkText() - if var acc = keyTextAccumulator { - acc.append(chars) - keyTextAccumulator = acc - onCommittedText?(chars) - return - } - insertCommittedTextForBroadcast(chars) - onCommittedText?(chars) - } - - func insertCommittedTextForBroadcast(_ text: String) { - guard let surface else { return } - guard !text.isEmpty else { return } - unmarkText() - let len = text.utf8CString.count - guard len > 0 else { return } - text.withCString { ptr in - ghostty_surface_text(surface, ptr, UInt(len - 1)) - } - } - - @discardableResult - func applyMirroredKeyForBroadcast(_ key: MirroredTerminalKey) -> Bool { - let windowNumber = window?.windowNumber ?? 0 - guard let keyDownEvent = key.keyDownEvent(windowNumber: windowNumber) else { - return false - } - keyDown(with: keyDownEvent) - if !key.isRepeat, - let keyUpEvent = key.keyUpEvent(windowNumber: windowNumber) - { - keyUp(with: keyUpEvent) - } - return true - } - - @discardableResult - func submitLine() -> Bool { - let timestamp = ProcessInfo.processInfo.systemUptime - let windowNumber = window?.windowNumber ?? 0 - guard - let keyDownEvent = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: timestamp, - windowNumber: windowNumber, - context: nil, - characters: "\r", - charactersIgnoringModifiers: "\r", - isARepeat: false, - keyCode: 36 - ), - let keyUpEvent = NSEvent.keyEvent( - with: .keyUp, - location: .zero, - modifierFlags: [], - timestamp: timestamp, - windowNumber: windowNumber, - context: nil, - characters: "\r", - charactersIgnoringModifiers: "\r", - isARepeat: false, - keyCode: 36 - ) - else { - return false - } - keyDown(with: keyDownEvent) - keyUp(with: keyUpEvent) - return true - } - - // MARK: - CLI key token delivery - - /// Send a single key event for a canonical CLI token (e.g. "enter", "ctrl-c", "pageup"). - /// Creates synthetic keyDown/keyUp NSEvents matching the token spec. - @discardableResult - func sendCLIKeyToken(_ token: String) -> Bool { - guard surface != nil else { return false } - guard let spec = CLIKeySpec.from(token: token) else { return false } - let timestamp = ProcessInfo.processInfo.systemUptime - let windowNumber = window?.windowNumber ?? 0 - guard - let keyDownEvent = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: spec.modifiers, - timestamp: timestamp, - windowNumber: windowNumber, - context: nil, - characters: spec.characters, - charactersIgnoringModifiers: spec.charactersIgnoringModifiers, - isARepeat: false, - keyCode: spec.keyCode - ), - let keyUpEvent = NSEvent.keyEvent( - with: .keyUp, - location: .zero, - modifierFlags: spec.modifiers, - timestamp: timestamp, - windowNumber: windowNumber, - context: nil, - characters: spec.characters, - charactersIgnoringModifiers: spec.charactersIgnoringModifiers, - isARepeat: false, - keyCode: spec.keyCode - ) - else { - return false - } - keyDown(with: keyDownEvent) - keyUp(with: keyUpEvent) - return true - } -} - -// MARK: - CLI Key Spec - -/// Maps canonical CLI key tokens to macOS NSEvent parameters. -struct CLIKeySpec { - let keyCode: UInt16 - let characters: String - let charactersIgnoringModifiers: String - let modifiers: NSEvent.ModifierFlags - - static func from(token: String) -> CLIKeySpec? { - guard let descriptor = KeyTokens.descriptor(for: token), - let base = baseSpec(for: descriptor.baseToken) - else { - return nil - } - - var modifierFlags = eventModifiers(from: descriptor.modifiers) - if base.usesFunctionModifier { - modifierFlags.insert(.function) - } - - let charactersIgnoringModifiers = base.charactersIgnoringModifiers - let characters: String - if usesControlCharacter(for: descriptor.modifiers), - let controlCharacter = controlCharacter(for: descriptor.baseToken) - { - characters = controlCharacter - } else if descriptor.modifiers.contains(.shift) { - characters = shiftedCharacters(for: descriptor.baseToken) ?? charactersIgnoringModifiers - } else { - characters = charactersIgnoringModifiers - } - - return CLIKeySpec( - keyCode: base.keyCode, - characters: characters, - charactersIgnoringModifiers: charactersIgnoringModifiers, - modifiers: modifierFlags - ) - } - - private struct BaseSpec { - let keyCode: UInt16 - let charactersIgnoringModifiers: String - let usesFunctionModifier: Bool - } - - private static let namedBaseSpecs: [String: BaseSpec] = [ - "enter": BaseSpec( - keyCode: UInt16(kVK_Return), - charactersIgnoringModifiers: "\r", - usesFunctionModifier: false - ), - "esc": BaseSpec( - keyCode: UInt16(kVK_Escape), - charactersIgnoringModifiers: "\u{1B}", - usesFunctionModifier: false - ), - "tab": BaseSpec( - keyCode: UInt16(kVK_Tab), - charactersIgnoringModifiers: "\t", - usesFunctionModifier: false - ), - "backspace": BaseSpec( - keyCode: UInt16(kVK_Delete), - charactersIgnoringModifiers: "\u{7F}", - usesFunctionModifier: false - ), - "delete-forward": BaseSpec( - keyCode: UInt16(kVK_ForwardDelete), - charactersIgnoringModifiers: String(UnicodeScalar(NSDeleteFunctionKey)!), - usesFunctionModifier: true - ), - "insert": BaseSpec( - keyCode: UInt16(kVK_Help), - charactersIgnoringModifiers: String(UnicodeScalar(NSInsertFunctionKey)!), - usesFunctionModifier: true - ), - "up": BaseSpec( - keyCode: UInt16(kVK_UpArrow), - charactersIgnoringModifiers: String(UnicodeScalar(NSUpArrowFunctionKey)!), - usesFunctionModifier: true - ), - "down": BaseSpec( - keyCode: UInt16(kVK_DownArrow), - charactersIgnoringModifiers: String(UnicodeScalar(NSDownArrowFunctionKey)!), - usesFunctionModifier: true - ), - "left": BaseSpec( - keyCode: UInt16(kVK_LeftArrow), - charactersIgnoringModifiers: String(UnicodeScalar(NSLeftArrowFunctionKey)!), - usesFunctionModifier: true - ), - "right": BaseSpec( - keyCode: UInt16(kVK_RightArrow), - charactersIgnoringModifiers: String(UnicodeScalar(NSRightArrowFunctionKey)!), - usesFunctionModifier: true - ), - "pageup": BaseSpec( - keyCode: UInt16(kVK_PageUp), - charactersIgnoringModifiers: String(UnicodeScalar(NSPageUpFunctionKey)!), - usesFunctionModifier: true - ), - "pagedown": BaseSpec( - keyCode: UInt16(kVK_PageDown), - charactersIgnoringModifiers: String(UnicodeScalar(NSPageDownFunctionKey)!), - usesFunctionModifier: true - ), - "home": BaseSpec( - keyCode: UInt16(kVK_Home), - charactersIgnoringModifiers: String(UnicodeScalar(NSHomeFunctionKey)!), - usesFunctionModifier: true - ), - "end": BaseSpec( - keyCode: UInt16(kVK_End), - charactersIgnoringModifiers: String(UnicodeScalar(NSEndFunctionKey)!), - usesFunctionModifier: true - ), - ] - - private static let functionKeyMap: [String: (UInt16, Int)] = [ - "f1": (UInt16(kVK_F1), NSF1FunctionKey), - "f2": (UInt16(kVK_F2), NSF2FunctionKey), - "f3": (UInt16(kVK_F3), NSF3FunctionKey), - "f4": (UInt16(kVK_F4), NSF4FunctionKey), - "f5": (UInt16(kVK_F5), NSF5FunctionKey), - "f6": (UInt16(kVK_F6), NSF6FunctionKey), - "f7": (UInt16(kVK_F7), NSF7FunctionKey), - "f8": (UInt16(kVK_F8), NSF8FunctionKey), - "f9": (UInt16(kVK_F9), NSF9FunctionKey), - "f10": (UInt16(kVK_F10), NSF10FunctionKey), - "f11": (UInt16(kVK_F11), NSF11FunctionKey), - "f12": (UInt16(kVK_F12), NSF12FunctionKey), - ] - - private static let printableKeyCodes: [String: UInt16] = [ - "a": UInt16(kVK_ANSI_A), - "b": UInt16(kVK_ANSI_B), - "c": UInt16(kVK_ANSI_C), - "d": UInt16(kVK_ANSI_D), - "e": UInt16(kVK_ANSI_E), - "f": UInt16(kVK_ANSI_F), - "g": UInt16(kVK_ANSI_G), - "h": UInt16(kVK_ANSI_H), - "i": UInt16(kVK_ANSI_I), - "j": UInt16(kVK_ANSI_J), - "k": UInt16(kVK_ANSI_K), - "l": UInt16(kVK_ANSI_L), - "m": UInt16(kVK_ANSI_M), - "n": UInt16(kVK_ANSI_N), - "o": UInt16(kVK_ANSI_O), - "p": UInt16(kVK_ANSI_P), - "q": UInt16(kVK_ANSI_Q), - "r": UInt16(kVK_ANSI_R), - "s": UInt16(kVK_ANSI_S), - "t": UInt16(kVK_ANSI_T), - "u": UInt16(kVK_ANSI_U), - "v": UInt16(kVK_ANSI_V), - "w": UInt16(kVK_ANSI_W), - "x": UInt16(kVK_ANSI_X), - "y": UInt16(kVK_ANSI_Y), - "z": UInt16(kVK_ANSI_Z), - "0": UInt16(kVK_ANSI_0), - "1": UInt16(kVK_ANSI_1), - "2": UInt16(kVK_ANSI_2), - "3": UInt16(kVK_ANSI_3), - "4": UInt16(kVK_ANSI_4), - "5": UInt16(kVK_ANSI_5), - "6": UInt16(kVK_ANSI_6), - "7": UInt16(kVK_ANSI_7), - "8": UInt16(kVK_ANSI_8), - "9": UInt16(kVK_ANSI_9), - "space": UInt16(kVK_Space), - "minus": UInt16(kVK_ANSI_Minus), - "equal": UInt16(kVK_ANSI_Equal), - "comma": UInt16(kVK_ANSI_Comma), - "period": UInt16(kVK_ANSI_Period), - "slash": UInt16(kVK_ANSI_Slash), - "backslash": UInt16(kVK_ANSI_Backslash), - "semicolon": UInt16(kVK_ANSI_Semicolon), - "quote": UInt16(kVK_ANSI_Quote), - "grave": UInt16(kVK_ANSI_Grave), - "left-bracket": UInt16(kVK_ANSI_LeftBracket), - "right-bracket": UInt16(kVK_ANSI_RightBracket), - ] - - private static let shiftedCharacterMap: [String: String] = [ - "1": "!", - "2": "@", - "3": "#", - "4": "$", - "5": "%", - "6": "^", - "7": "&", - "8": "*", - "9": "(", - "0": ")", - "minus": "_", - "equal": "+", - "comma": "<", - "period": ">", - "slash": "?", - "backslash": "|", - "semicolon": ":", - "quote": "\"", - "grave": "~", - "left-bracket": "{", - "right-bracket": "}", - ] - - private static func eventModifiers(from modifiers: [KeyModifier]) -> NSEvent.ModifierFlags { - modifiers.reduce(into: NSEvent.ModifierFlags()) { result, modifier in - switch modifier { - case .cmd: result.insert(.command) - case .shift: result.insert(.shift) - case .opt: result.insert(.option) - case .ctrl: result.insert(.control) - } - } - } - - private static func baseSpec(for token: String) -> BaseSpec? { - if let base = namedBaseSpecs[token] { return base } - if let (keyCode, scalar) = functionKeyMap[token] { - return BaseSpec( - keyCode: keyCode, - charactersIgnoringModifiers: String(UnicodeScalar(scalar)!), - usesFunctionModifier: true - ) - } - return printableBaseSpec(for: token) - } - - private static func printableBaseSpec(for token: String) -> BaseSpec? { - guard let character = printableCharacter(for: token), - let keyCode = printableKeyCode(for: token) - else { return nil } - - return BaseSpec( - keyCode: keyCode, - charactersIgnoringModifiers: String(character), - usesFunctionModifier: false - ) - } - - private static func printableCharacter(for token: String) -> Character? { - switch token { - case "space": return " " - case "minus": return "-" - case "equal": return "=" - case "comma": return "," - case "period": return "." - case "slash": return "/" - case "backslash": return "\\" - case "semicolon": return ";" - case "quote": return "'" - case "grave": return "`" - case "left-bracket": return "[" - case "right-bracket": return "]" - default: - guard token.count == 1 else { return nil } - return token.first - } - } - - private static func printableKeyCode(for token: String) -> UInt16? { - printableKeyCodes[token] - } - - private static func shiftedCharacters(for token: String) -> String? { - if token.count == 1, let character = token.first, character.isLetter { - return String(character).uppercased() - } - - return shiftedCharacterMap[token] - } - - private static func usesControlCharacter(for modifiers: [KeyModifier]) -> Bool { - let modifierSet = Set(modifiers) - return modifierSet.contains(.ctrl) && modifierSet.isSubset(of: [.ctrl, .shift]) - } - - private static func controlCharacter(for token: String) -> String? { - if token.count == 1, let scalar = token.unicodeScalars.first, scalar.properties.isAlphabetic { - return String(UnicodeScalar(scalar.value & 0x1F)!) - } - - switch token { - case "left-bracket": return String(UnicodeScalar(27)!) - case "backslash": return String(UnicodeScalar(28)!) - case "right-bracket": return String(UnicodeScalar(29)!) - case "6": return String(UnicodeScalar(30)!) - case "minus": return String(UnicodeScalar(31)!) - default: return nil - } - } -} - -extension GhosttySurfaceView: NSServicesMenuRequestor { - override func validRequestor( - forSendType sendType: NSPasteboard.PasteboardType?, - returnType: NSPasteboard.PasteboardType? - ) -> Any? { - let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] - let sendable = receivable - let sendableRequiresSelection = sendable - - if (returnType == nil || receivable.contains(returnType!)) - && (sendType == nil || sendable.contains(sendType!)) - { - if let sendType, sendableRequiresSelection.contains(sendType) { - if surface == nil || !ghostty_surface_has_selection(surface) { - return super.validRequestor(forSendType: sendType, returnType: returnType) - } - } - return self - } - return super.validRequestor(forSendType: sendType, returnType: returnType) - } - - func writeSelection(to pboard: NSPasteboard, types: [NSPasteboard.PasteboardType]) -> Bool { - guard let surface else { return false } - var text = ghostty_text_s() - guard ghostty_surface_read_selection(surface, &text) else { return false } - defer { ghostty_surface_free_text(surface, &text) } - pboard.declareTypes([.string], owner: nil) - pboard.setString(Self.string(from: text), forType: .string) - return true - } - - func readSelection(from pboard: NSPasteboard) -> Bool { - guard let str = pboard.getOpinionatedStringContents() else { return false } - let len = str.utf8CString.count - if len == 0 { return true } - str.withCString { ptr in - ghostty_surface_text(surface, ptr, UInt(len - 1)) - } - return true - } -} - -final class GhosttySurfaceScrollView: NSView { - enum HostKind: String { - case terminal - case canvas - } - - private struct ScrollbarState { - let total: UInt64 - let offset: UInt64 - let length: UInt64 - } - - private let scrollView: NSScrollView - private let documentView: NSView - private let surfaceView: GhosttySurfaceView - let hostKind: HostKind - private let debugID = String(UUID().uuidString.prefix(8)) - var debugIdentifier: String { - debugID - } - private var observers: [NSObjectProtocol] = [] - - private var isLiveScrolling = false - private var isProgrammaticScrollChange = false - private var isUserScrolledBack = false - private var lastSentRow: Int? - private var scrollbar: ScrollbarState? - - /// When set, the surface renders at this fixed size regardless of the hosting - /// view's bounds. Used in canvas mode to prevent `.scaleEffect()` from causing - /// terminal reflow. - var pinnedSize: CGSize? - - init(surfaceView: GhosttySurfaceView, hostKind: HostKind) { - self.surfaceView = surfaceView - self.hostKind = hostKind - scrollView = NSScrollView() - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = false - scrollView.usesPredominantAxisScrolling = true - scrollView.scrollerStyle = .overlay - scrollView.drawsBackground = false - scrollView.contentView.clipsToBounds = false - documentView = NSView(frame: .zero) - scrollView.documentView = documentView - documentView.addSubview(surfaceView) - super.init(frame: .zero) - addSubview(scrollView) - surfaceView.scrollWrapper = self - refreshAppearance() - - scrollView.contentView.postsBoundsChangedNotifications = true - observers.append( - NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleScrollChange() - } - }) - - observers.append( - NotificationCenter.default.addObserver( - forName: NSScrollView.willStartLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.isLiveScrolling = true - } - }) - - observers.append( - NotificationCenter.default.addObserver( - forName: NSScrollView.didEndLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.isLiveScrolling = false - self?.updateScrollBackState() - } - }) - - observers.append( - NotificationCenter.default.addObserver( - forName: NSScrollView.didLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleLiveScroll() - } - }) - - observers.append( - NotificationCenter.default.addObserver( - forName: NSScroller.preferredScrollerStyleDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleScrollerStyleChange() - } - }) - - observers.append( - NotificationCenter.default.addObserver( - forName: .ghosttyRuntimeConfigDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.refreshAppearance() - } - }) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - isolated deinit { - for observer in observers { - NotificationCenter.default.removeObserver(observer) - } - } - - override func layout() { - super.layout() - ensureSurfaceAttached() - let effectiveSize = pinnedSize ?? bounds.size - scrollView.frame = CGRect(origin: .zero, size: effectiveSize) - surfaceView.frame.size = effectiveSize - documentView.frame.size.width = effectiveSize.width - synchronizeScrollView() - synchronizeSurfaceView() - surfaceView.updateSurfaceSize() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - ensureSurfaceAttached() - } - - func updateSurfaceSize() { - surfaceView.updateSurfaceSize() - needsLayout = true - } - - var isSurfaceAttachedToDocumentView: Bool { - surfaceView.superview === documentView - } - - func ensureSurfaceAttached(requiresLiveHost: Bool = true) { - guard hostKind == .terminal else { return } - if requiresLiveHost { - guard superview != nil || window != nil else { return } - } - guard !isSurfaceAttachedToDocumentView else { return } - // Only adopt an orphaned surface; never steal it from a live host such as Canvas. - guard surfaceView.superview == nil else { return } - surfaceLogger.info( - "[CanvasExit] hostReattach wrapper=\(debugID) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "currentSuperview=\(String(describing: surfaceView.superview)) " - + "wrapperWindow=\(window != nil)" - ) - documentView.addSubview(surfaceView) - surfaceView.scrollWrapper = self - surfaceLogger.info( - "[CanvasExit] hostReattachComplete wrapper=\(debugID) host=\(hostKind.rawValue) " - + "surface=\(surfaceView.debugIdentifierForLogging) " - + "superview=\(surfaceView.superview != nil) " - + "window=\(surfaceView.window != nil) " - + "bounds=\(Int(surfaceView.bounds.width))x\(Int(surfaceView.bounds.height))" - ) - } - - func updateScrollbar(total: UInt64, offset: UInt64, length: UInt64) { - scrollbar = ScrollbarState(total: total, offset: offset, length: length) - synchronizeScrollView() - } - - func refreshAppearance() { - scrollView.hasVerticalScroller = surfaceView.shouldShowScrollbar() - scrollView.appearance = NSAppearance(named: surfaceView.scrollbarAppearanceName()) - scrollView.scrollerStyle = .overlay - updateTrackingAreas() - } - - private func handleScrollChange() { - synchronizeSurfaceView() - guard !isProgrammaticScrollChange else { - return - } - updateScrollBackState() - } - - private func handleScrollerStyleChange() { - refreshAppearance() - surfaceView.updateSurfaceSize() - } - - private func synchronizeSurfaceView() { - let visibleRect = scrollView.contentView.documentVisibleRect - surfaceView.frame.origin = visibleRect.origin - } - - private func synchronizeScrollView() { - documentView.frame.size.height = documentHeight() - if !isLiveScrolling && !isUserScrolledBack { - let cellHeight = surfaceView.currentCellSize().height - if cellHeight > 0, let scrollbar { - let targetY = - CGFloat(scrollbar.total - scrollbar.offset - scrollbar.length) * cellHeight - isProgrammaticScrollChange = true - defer { isProgrammaticScrollChange = false } - scrollView.contentView.scroll(to: CGPoint(x: 0, y: targetY)) - lastSentRow = Int(scrollbar.offset) - } - } - scrollView.reflectScrolledClipView(scrollView.contentView) - } - - /// Tracks whether the user intentionally moved away from the live bottom of - /// the terminal. While this is true we keep the viewport fixed so incoming - /// output cannot yank scrollback out from under the user. - private func updateScrollBackState() { - let cellHeight = surfaceView.currentCellSize().height - guard cellHeight > 0 else { - isUserScrolledBack = false - return - } - - let visibleRect = scrollView.contentView.documentVisibleRect - let distanceFromBottom = max(0, documentView.frame.height - visibleRect.maxY) - isUserScrolledBack = distanceFromBottom > cellHeight / 2 - } - - private func handleLiveScroll() { - let cellHeight = surfaceView.currentCellSize().height - guard cellHeight > 0 else { return } - let visibleRect = scrollView.contentView.documentVisibleRect - let documentHeight = documentView.frame.height - let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height - let row = Int(scrollOffset / cellHeight) - guard row != lastSentRow else { return } - lastSentRow = row - surfaceView.performBindingAction("scroll_to_row:\(row)") - } - - private func documentHeight() -> CGFloat { - let contentHeight = scrollView.contentSize.height - let cellHeight = surfaceView.currentCellSize().height - if cellHeight > 0, let scrollbar { - let documentGridHeight = CGFloat(scrollbar.total) * cellHeight - let padding = contentHeight - (CGFloat(scrollbar.length) * cellHeight) - return documentGridHeight + padding - } - return contentHeight - } - - override func mouseMoved(with event: NSEvent) { - guard NSScroller.preferredScrollerStyle == .legacy else { return } - scrollView.flashScrollers() - } - - override func updateTrackingAreas() { - for trackingArea in trackingAreas { - removeTrackingArea(trackingArea) - } - super.updateTrackingAreas() - guard let scroller = scrollView.verticalScroller else { return } - addTrackingArea( - NSTrackingArea( - rect: convert(scroller.bounds, from: scroller), - options: [ - .mouseMoved, - .activeInKeyWindow, - ], - owner: self, - userInfo: nil - )) - } } -- 2.51.2 From fc2af518f8af20e7a2f4a1b3829443bd324a96db Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 02:22:10 +0900 Subject: [PATCH 336/528] Split repositories feature support logic --- .../RepositoriesFeature+CoreReducer.swift | 845 ++++++ ...epositoriesFeature+RepositoryLoading.swift | 383 +++ .../RepositoriesFeature+Selection.swift | 419 +++ .../RepositoriesFeature+StateQueries.swift | 516 ++++ .../RepositoriesFeature+WorktreeState.swift | 319 +++ .../Reducer/RepositoriesFeature.swift | 2460 +---------------- 6 files changed, 2485 insertions(+), 2457 deletions(-) create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift new file mode 100644 index 00000000..f693905f --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -0,0 +1,845 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import SwiftUI + +extension RepositoriesFeature { + // swiftlint:disable:next cyclomatic_complexity function_body_length + func reduceCore( + state: inout State, + action: Action + ) -> Effect { + switch action { + case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement: + return .none + + case .activeAgents(.entryTapped(let id)): + guard let entry = state.activeAgents.entries[id: id] else { return .none } + if state.isShowingCanvas { + requestCanvasFocus(.tab(entry.tabID), openedWorktreeID: entry.worktreeID, state: &state) + return .run { _ in + _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) + } + } + let isPlainFolder = + state.repositories[id: entry.worktreeID]?.kind == .plain + if isPlainFolder { + state.pendingTerminalFocusWorktreeIDs.insert(entry.worktreeID) + } + return .run { send in + // Focus the target surface (which selects its tab) before making the + // terminal target visible, so it shows the right tab immediately instead + // of flashing its previously-focused tab. Plain folders are represented + // by their repository id, not a real worktree row, so they must select the + // repository rather than attempting a worktree selection. + _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) + if isPlainFolder { + await send(.selectRepository(entry.worktreeID)) + } else { + await send(.selectWorktree(entry.worktreeID, focusTerminal: true)) + } + } + + case .activeAgents: + return .none + + case .task: + state.snapshotPersistencePhase = .restoring + return .run { send in + let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() + let archived = await repositoryPersistence.loadArchivedWorktrees() + let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() + let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() + let worktreeOrderByRepository = + await repositoryPersistence.loadWorktreeOrderByRepository() + let repositorySnapshot = await repositoryPersistence.loadRepositorySnapshot() + await send(.pinnedWorktreeIDsLoaded(pinned)) + await send(.archivedWorktreesLoaded(archived)) + await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) + await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) + await send(.lastFocusedWorktreeIDLoaded(lastFocused)) + await send(.repositorySnapshotLoaded(repositorySnapshot)) + await send(.loadPersistedRepositories) + } + + case .repositorySnapshotLoaded(let repositories): + guard let repositories, !repositories.isEmpty else { + return .none + } + state.isRefreshingWorktrees = false + let roots = repositories.map(\.rootURL) + let previousSelection = state.selectedWorktreeID + let previousSelectedWorktree = state.worktree(for: previousSelection) + let incomingRepositories = IdentifiedArray(uniqueElements: repositories) + let repositoriesChanged = incomingRepositories != state.repositories + _ = applyRepositories( + repositories, + roots: roots, + shouldPruneArchivedWorktrees: true, + state: &state, + animated: false + ) + state.repositoryRoots = roots + state.isInitialLoadComplete = true + state.loadFailuresByID = [:] + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) + let selectionChanged = selectionDidChange( + previousSelectionID: previousSelection, + previousSelectedWorktree: previousSelectedWorktree, + selectedWorktreeID: state.selectedWorktreeID, + selectedWorktree: selectedWorktree + ) + var allEffects: [Effect] = [] + if repositoriesChanged { + allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) + } + if selectionChanged { + allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) + } + return .merge(allEffects) + + case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): + state.pinnedWorktreeIDs = pinnedWorktreeIDs + return .none + + case .archivedWorktreesLoaded(let archivedWorktrees): + state.archivedWorktrees = archivedWorktrees + return .none + + case .setArchivedAutoDeletePeriod(let period): + state.archivedAutoDeletePeriod = period + guard period != nil else { return .none } + return .send(.autoDeleteExpiredArchivedWorktrees) + + case .autoDeleteExpiredArchivedWorktrees: + guard let period = state.archivedAutoDeletePeriod else { + return .none + } + let cutoff = now.addingTimeInterval(-Double(period.rawValue) * secondsPerDay) + let expiredEntries = state.archivedWorktrees.filter { $0.archivedAt <= cutoff } + guard !expiredEntries.isEmpty else { + return .none + } + @Shared(.settingsFile) var settingsFile + var deleteEffects: [Effect] = [] + for entry in expiredEntries { + guard + let (worktree, repository) = findWorktreeAndRepository( + worktreeID: entry.id, + state: state + ), + !state.isMainWorktree(worktree), + !state.deletingWorktreeIDs.contains(entry.id) + else { + continue + } + let shouldDeleteBranch = + settingsFile.global.deleteBranchOnDeleteWorktree + && state.prowlCreatedWorktreeIDs.contains(worktree.id) + deleteEffects.append( + .send( + .worktreeLifecycle( + .deleteWorktreeConfirmed( + worktree.id, + repository.id, + deleteBranch: shouldDeleteBranch + )) + ) + ) + } + guard !deleteEffects.isEmpty else { + return .none + } + return .merge(deleteEffects) + + case .repositoryOrderIDsLoaded(let repositoryOrderIDs): + state.repositoryOrderIDs = repositoryOrderIDs + return .none + + case .worktreeOrderByRepositoryLoaded(let worktreeOrderByRepository): + state.worktreeOrderByRepository = worktreeOrderByRepository + return .none + + case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): + state.lastFocusedWorktreeID = lastFocusedWorktreeID + if state.launchRestoreMode == .lastFocusedWorktree { + state.shouldRestoreLastFocusedWorktree = true + } + return .none + + case .setOpenPanelPresented(let isPresented): + state.isOpenPanelPresented = isPresented + return .none + + case .loadPersistedRepositories: + state.alert = nil + state.isRefreshingWorktrees = false + return .run { send in + let entries = await loadPersistedRepositoryEntries() + let roots = entries.map { URL(fileURLWithPath: $0.path) } + let (repositories, failures) = await loadRepositoriesData(entries) + await send( + .repositoriesLoaded( + repositories, + failures: failures, + roots: roots, + animated: false + ) + ) + } + .cancellable(id: CancelID.load, cancelInFlight: true) + + case .refreshWorktrees: + state.isRefreshingWorktrees = true + return .send(.reloadRepositories(animated: false)) + + case .reloadRepositories(let animated): + state.alert = nil + let roots = state.repositoryRoots + guard !roots.isEmpty else { + state.isRefreshingWorktrees = false + return .none + } + return loadRepositories(fallbackRoots: roots, animated: animated) + + case .repositoriesLoaded(let repositories, let failures, let roots, let animated): + state.isRefreshingWorktrees = false + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring + if failures.isEmpty, state.snapshotPersistencePhase != .active { + state.snapshotPersistencePhase = .active + } + let previousSelection = state.selectedWorktreeID + let previousSelectedWorktree = state.worktree(for: previousSelection) + let incomingRepositories = IdentifiedArray(uniqueElements: repositories) + let repositoriesChanged = incomingRepositories != state.repositories + let applyResult = applyRepositories( + repositories, + roots: roots, + shouldPruneArchivedWorktrees: failures.isEmpty, + state: &state, + animated: animated + ) + state.repositoryRoots = roots + state.isInitialLoadComplete = true + state.loadFailuresByID = Dictionary( + uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } + ) + let selectedWorktree = state.worktree(for: state.selectedWorktreeID) + let selectionChanged = selectionDidChange( + previousSelectionID: previousSelection, + previousSelectedWorktree: previousSelectedWorktree, + selectedWorktreeID: state.selectedWorktreeID, + selectedWorktree: selectedWorktree + ) + var allEffects: [Effect] = [] + if repositoriesChanged || wasRestoringSnapshot { + allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) + } + if selectionChanged { + allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) + } + if applyResult.didPrunePinned { + let pinnedWorktreeIDs = state.pinnedWorktreeIDs + allEffects.append( + .run { _ in + await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) + }) + } + if applyResult.didPruneRepositoryOrder { + let repositoryOrderIDs = state.repositoryOrderIDs + allEffects.append( + .run { _ in + await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) + }) + } + if applyResult.didPruneWorktreeOrder { + let worktreeOrderByRepository = state.worktreeOrderByRepository + allEffects.append( + .run { _ in + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) + }) + } + if applyResult.didPruneArchivedWorktrees { + let archivedWorktrees = state.archivedWorktrees + allEffects.append( + .run { _ in + await repositoryPersistence.saveArchivedWorktrees(archivedWorktrees) + } + ) + } + if failures.isEmpty, !wasRestoringSnapshot { + let repositories = Array(state.repositories) + allEffects.append( + .run { _ in + await repositoryPersistence.saveRepositorySnapshot(repositories) + } + ) + } + if state.archivedAutoDeletePeriod != nil { + allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) + } + if repositoriesChanged, + let effect = detectCodeHostsEffect(for: state.repositories) + { + allEffects.append(effect) + } + return .merge(allEffects) + + case .refreshAllCustomTitles: + // Fan out across the current repository list, reading each + // per-repo settings file via `@Shared`. Runs in a reducer + // effect (not in a view body), so even when the first cache + // miss triggers a `settingsFile` write the resulting view + // re-render can't loop back into this action. + let repositoriesForTitleRefresh = Array(state.repositories) + return .run { send in + var dict: [Repository.ID: String] = [:] + for repository in repositoriesForTitleRefresh { + @Shared(.repositorySettings(repository.rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + dict[repository.id] = trimmed + } + } + await send(.customTitlesLoaded(dict)) + } + + case .refreshCustomTitle(let rootURL): + guard let repository = state.repositories.first(where: { $0.rootURL == rootURL }) else { + return .none + } + let repositoryID = repository.id + return .run { send in + @Shared(.repositorySettings(rootURL)) var settings + let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = (trimmed?.isEmpty ?? true) ? nil : trimmed + await send(.customTitleUpdated(repositoryID, normalized)) + } + + case .customTitlesLoaded(let dict): + guard state.repositoryCustomTitles != dict else { return .none } + state.repositoryCustomTitles = dict + return .none + + case .customTitleUpdated(let id, let title): + if let title { + guard state.repositoryCustomTitles[id] != title else { return .none } + state.repositoryCustomTitles[id] = title + } else { + guard state.repositoryCustomTitles[id] != nil else { return .none } + state.repositoryCustomTitles.removeValue(forKey: id) + } + return .none + + case .codeHostsDetected(let codeHostByRepositoryID): + let knownIDs = Set(state.repositories.ids) + var updated = state.codeHostByRepositoryID.filter { knownIDs.contains($0.key) } + for (id, host) in codeHostByRepositoryID where knownIDs.contains(id) { + updated[id] = host + } + state.codeHostByRepositoryID = updated + return .none + + case .selectArchivedWorktrees: + state.isShelfActive = false + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) + state.selection = .archivedWorktrees + state.sidebarSelectedWorktreeIDs = [] + return .send(.delegate(.selectedWorktreeChanged(nil))) + + case .selectCanvas: + // Remember the current worktree so toggleCanvas can restore it. + let canvasSeedWorktree = state.selectedTerminalWorktree + state.preCanvasWorktreeID = state.selectedWorktreeID + state.preCanvasTerminalTargetID = canvasSeedWorktree?.id + state.isShelfActive = false + state.selection = .canvas + state.sidebarSelectedWorktreeIDs = [] + // Canvas only renders cards for worktrees that already have a live + // terminal surface. Normal/Shelf get the previously-focused worktree + // opened lazily when its view mounts and calls `ensureInitialTab`; + // Canvas mounts no such per-worktree view, so launching straight into + // Canvas would show an empty board. Seed the surface for the worktree + // we're entering from so at least that card appears — matching the + // single-tab restore Normal and Shelf perform. `ensureInitialTab` is + // idempotent, so this no-ops once the worktree already has tabs. + return .run { _ in + if let canvasSeedWorktree { + await terminalClient.send( + .ensureInitialTab(canvasSeedWorktree, runSetupScriptIfNew: false, focusing: false) + ) + } + await terminalClient.send(.setCanvasMode(true)) + } + + case .selectShelf: + guard !state.isShelfActive else { return .none } + return .send(.toggleShelf) + + case .selectTabbed: + if state.isShowingCanvas { + return .send(.toggleCanvas) + } + if state.isShowingShelf { + return .send(.toggleShelf) + } + return .none + + case .setTopSegment(let segment): + switch segment { + case .tabbed: + return .send(.selectTabbed) + case .canvas: + return .send(.selectCanvas) + case .shelf: + return .send(.selectShelf) + } + + case .toggleCanvas: + if state.isShowingCanvas { + // Exit canvas: prefer the card focused in canvas, then the worktree + // we came from, then the first available worktree. + let targetID = + terminalClient.canvasFocusedWorktreeID() + ?? state.preCanvasTerminalTargetID + ?? state.preCanvasWorktreeID + ?? state.lastFocusedWorktreeID + ?? state.orderedWorktreeRows().first?.id + guard let targetID else { return .none } + if state.worktree(for: targetID) == nil, + let repository = state.repositories[id: targetID], + repository.kind == .plain + { + state.pendingTerminalFocusWorktreeIDs.insert(targetID) + return .send(.selectRepository(targetID)) + } + return .send(.selectWorktree(targetID, focusTerminal: true)) + } else { + // Enter canvas if there are any open worktrees. + guard !state.orderedWorktreeRows().isEmpty else { return .none } + return .send(.selectCanvas) + } + + case .selectNextShelfBook: + guard let book = shelfBook(atOffset: 1, state: state) else { return .none } + return shelfBookSelectionEffect(for: book) + + case .selectPreviousShelfBook: + guard let book = shelfBook(atOffset: -1, state: state) else { return .none } + return shelfBookSelectionEffect(for: book) + + case .selectShelfBook(let index): + let books = state.orderedShelfBooks() + let zeroBased = index - 1 + guard books.indices.contains(zeroBased) else { return .none } + return shelfBookSelectionEffect(for: books[zeroBased]) + + case .markWorktreeOpened(let worktreeID): + state.openedWorktreeIDs.insert(worktreeID) + return .none + + case .markWorktreeClosed(let worktreeID): + // Closing the last tab of a book retires the book from the + // Shelf. If this book was the one currently open on the + // Shelf, move focus to the neighboring book — the one after + // the closed book if there is one, otherwise the one before + // — so the user lands close to where they were instead of + // always snapping back to the first spine. + let replacement = replacementBookAfterClosing( + worktreeID: worktreeID, + state: state + ) + state.openedWorktreeIDs.remove(worktreeID) + if let replacement { + return shelfBookSelectionEffect(for: replacement) + } + return .none + + case .toggleShelf: + if state.isShelfActive { + state.isShelfActive = false + return .none + } + // Entering Shelf requires at least one book to render. + guard !state.orderedWorktreeRows().isEmpty else { return .none } + // Shelf is mutually exclusive with Canvas / archived views: when entering + // Shelf we need a worktree- or repository-scoped selection. + let needsRedirect: Bool + switch state.selection { + case .some(.worktree), .some(.repository): + needsRedirect = false + case .some(.canvas), .some(.archivedWorktrees), .none: + needsRedirect = true + } + state.isShelfActive = true + if !needsRedirect { + // The current selection is the open book — make sure it's + // registered as opened so the Shelf renders at least this + // spine. Guards the case where `selection` was set without + // going through `.selectWorktree` / `.selectRepository`. + // + // Also request terminal focus for this worktree so that + // `ShelfOpenBookView.onAppear` forces focus onto the + // surface (`forceAutoFocus: shouldFocusTerminal(for:)`). + // Without this, entering Shelf via keyboard shortcut + // leaves the first responder on the (now-dismissed) menu + // path, and `applySurfaceActivity`'s "only refocus if the + // current responder is a GhosttySurfaceView" guard skips + // the surface — user can't type until a second + // interaction (tab switch, etc.) forces focus through. + switch state.selection { + case .some(.worktree(let id)): + state.openedWorktreeIDs.insert(id) + state.pendingTerminalFocusWorktreeIDs.insert(id) + case .some(.repository(let id)) + where state.repositories[id: id]?.kind == .plain: + state.openedWorktreeIDs.insert(id) + state.pendingTerminalFocusWorktreeIDs.insert(id) + default: + break + } + return .none + } + // Same fallback chain as `toggleCanvas`'s exit path: prefer + // the card the user was actively focused on in Canvas so a + // Canvas → Shelf switch opens *that* card as the active book, + // not whatever was selected before Canvas was entered. + let targetID = + terminalClient.canvasFocusedWorktreeID() + ?? state.preCanvasTerminalTargetID + ?? state.preCanvasWorktreeID + ?? state.lastFocusedWorktreeID + ?? state.orderedWorktreeRows().first?.id + guard let targetID else { return .none } + if state.worktree(for: targetID) == nil, + let repository = state.repositories[id: targetID], + repository.kind == .plain + { + state.pendingTerminalFocusWorktreeIDs.insert(targetID) + return .send(.selectRepository(targetID)) + } + return .send(.selectWorktree(targetID, focusTerminal: true)) + + case .setSidebarSelectedWorktreeIDs(let worktreeIDs): + let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) + var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) + if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { + nextWorktreeIDs.insert(selectedWorktreeID) + } + state.sidebarSelectedWorktreeIDs = nextWorktreeIDs + return .none + + case .selectRepository(let repositoryID): + // `inout state` cannot be captured by a closure, so use the + // begin/end token API rather than the `interval` helper. + let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") + defer { repositoriesLogger.endInterval(selectRepoToken) } + guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) + state.selection = .repository(repositoryID) + state.sidebarSelectedWorktreeIDs = [] + if state.repositories[id: repositoryID]?.kind == .plain { + // Plain folder selection opens the folder as a Shelf book. + state.openedWorktreeIDs.insert(repositoryID) + } + return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) + + case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): + let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") + defer { repositoriesLogger.endInterval(selectWtToken) } + setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: recordHistory) + if focusTerminal, let worktreeID { + state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) + } + if let worktreeID { + state.openedWorktreeIDs.insert(worktreeID) + } + let selectedWorktree = state.worktree(for: worktreeID) + return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) + + case .focusCanvasRepository(let repositoryID): + guard state.isShowingCanvas, + let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) + else { + return .none + } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + + case .focusCanvasWorktree(let worktreeID): + guard state.isShowingCanvas, + let worktree = state.worktree(for: worktreeID) + else { + return .none + } + requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) + return .run { _ in + await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + } + + case .selectNextWorktree: + // In Shelf, the vertical arrow pair maps to tab navigation + // within the open book — horizontal (← / →) is already book + // navigation, so the two axes match the Shelf layout. + if state.isShelfActive, let worktree = state.selectedTerminalWorktree { + return .run { _ in + await terminalClient.send(.performBindingAction(worktree, action: "next_tab")) + } + } + guard let id = state.worktreeID(byOffset: 1) else { return .none } + return .send(.selectWorktree(id)) + + case .selectPreviousWorktree: + if state.isShelfActive, let worktree = state.selectedTerminalWorktree { + return .run { _ in + await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) + } + } + guard let id = state.worktreeID(byOffset: -1) else { return .none } + return .send(.selectWorktree(id)) + + case .consumeCanvasFocusRequest(let id): + if state.pendingCanvasFocusRequest?.id == id { + state.pendingCanvasFocusRequest = nil + } + return .none + + case .requestCanvasCommand(let command): + state.nextCanvasCommandRequestID += 1 + state.pendingCanvasCommandRequest = CanvasCommandRequest( + id: state.nextCanvasCommandRequestID, + command: command + ) + return .none + + case .consumeCanvasCommandRequest(let id): + if state.pendingCanvasCommandRequest?.id == id { + state.pendingCanvasCommandRequest = nil + } + return .none + + case .worktreeHistoryBack: + return navigateWorktreeHistory(direction: .backward, state: &state) + + case .worktreeHistoryForward: + return navigateWorktreeHistory(direction: .forward, state: &state) + + case .revealSelectedWorktreeInSidebar: + guard let worktreeID = state.selectedWorktreeID, + let repositoryID = state.repositoryID(containing: worktreeID) + else { return .none } + state.$collapsedRepositoryIDs.withLock { + $0.removeAll { $0 == repositoryID } + } + state.nextPendingSidebarRevealID += 1 + state.pendingSidebarReveal = .init( + id: state.nextPendingSidebarRevealID, + worktreeID: worktreeID + ) + return .none + + case .consumePendingSidebarReveal(let revealID): + guard state.pendingSidebarReveal?.id == revealID else { return .none } + state.pendingSidebarReveal = nil + return .none + + case .requestRenameBranchPrompt(let worktreeID): + guard state.worktree(for: worktreeID) != nil else { return .none } + state.nextPendingRenameBranchRequestID += 1 + state.pendingRenameBranchRequest = .init( + id: state.nextPendingRenameBranchRequestID, + worktreeID: worktreeID + ) + return .none + + case .consumePendingRenameBranchRequest(let requestID): + guard state.pendingRenameBranchRequest?.id == requestID else { return .none } + state.pendingRenameBranchRequest = nil + return .none + + case .requestRenameBranch(let worktreeID, let branchName): + guard let worktree = state.worktree(for: worktreeID) else { return .none } + let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + state.alert = messageAlert( + title: "Branch name required", + message: "Enter a branch name to rename." + ) + return .none + } + guard !trimmed.contains(where: \.isWhitespace) else { + state.alert = messageAlert( + title: "Branch name invalid", + message: "Branch names can't contain spaces." + ) + return .none + } + if trimmed == worktree.name { + return .none + } + analyticsClient.capture("branch_renamed", nil) + return .run { send in + do { + try await gitClient.renameBranch(worktree.workingDirectory, trimmed) + await send(.reloadRepositories(animated: true)) + } catch { + await send( + .presentAlert( + title: "Unable to rename branch", + message: error.localizedDescription + ) + ) + } + } + + case .worktreeCreationPrompt(.presented(.delegate(.cancel))): + return .send(.worktreeCreation(.promptCanceled)) + + case .worktreeCreationPrompt( + .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) + ): + return .send( + .worktreeCreation( + .startPromptedWorktreeCreation( + repositoryID: repositoryID, + branchName: branchName, + baseRef: baseRef + ) + ) + ) + + case .worktreeCreationPrompt(.dismiss): + return .send(.worktreeCreation(.promptDismissed)) + + case .worktreeCreationPrompt: + return .none + + case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): + return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) + + case .alert(.presented(.confirmArchiveWorktrees(let targets))): + return .merge( + targets.map { target in + .send(.worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) + } + ) + + case .alert(.presented(.confirmForceDeleteBranch(let request))): + return .send(.worktreeLifecycle(.forceDeleteBranchConfirmed(request))) + + case .alert(.presented(.confirmRemoveRepository(let repositoryID))): + guard let repository = state.repositories[id: repositoryID] else { + return .none + } + if state.removingRepositoryIDs.contains(repository.id) { + return .none + } + state.alert = nil + state.removingRepositoryIDs.insert(repository.id) + let selectionWasRemoved = + state.selectedWorktreeID.map { id in + repository.worktrees.contains(where: { $0.id == id }) + } ?? false + return .send( + .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) + + case .presentAlert(let title, let message): + state.alert = messageAlert(title: title, message: message) + return .none + + case .showToast(let toast): + state.statusToast = toast + switch toast { + case .inProgress: + return .cancel(id: CancelID.toastAutoDismiss) + case .success, .warning: + return .run { send in + try? await ContinuousClock().sleep(for: .seconds(3)) + await send(.dismissToast) + } + .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true) + } + + case .dismissToast: + state.statusToast = nil + return .none + + case .worktreeInfoEvent(let event): + switch event { + case .branchChanged(let worktreeID): + guard let worktree = state.worktree(for: worktreeID) else { + return .none + } + let worktreeURL = worktree.workingDirectory + let gitClient = gitClient + return .run { send in + if let name = await gitClient.branchName(worktreeURL) { + await send(.worktreeBranchNameLoaded(worktreeID: worktreeID, name: name)) + } + } + case .filesChanged(let worktreeID): + guard let worktree = state.worktree(for: worktreeID) else { + return .none + } + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + guard repositorySettings.observesLineDiffsAutomatically else { + return .none + } + let worktreeURL = worktree.workingDirectory + let gitClient = gitClient + let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) + return .run { send in + if let changes = await gitClient.lineChanges(worktreeURL) { + let nextLineChanges = normalizedLineChanges(added: changes.added, removed: changes.removed) + guard !lineChangesEqual(nextLineChanges, previousLineChanges) else { + return + } + await send( + .worktreeLineChangesLoaded( + worktreeID: worktreeID, + added: changes.added, + removed: changes.removed + ) + ) + } + } + case .repositoryWorktreesChanged: + return .send(.reloadRepositories(animated: true)) + case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): + return .send( + .githubIntegration( + .repositoryPullRequestRefreshRequested( + repositoryRootURL: repositoryRootURL, + worktreeIDs: worktreeIDs + ) + ) + ) + } + + case .worktreeBranchNameLoaded(let worktreeID, let name): + updateWorktreeName(worktreeID, name: name, state: &state) + return .none + + case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): + updateWorktreeLineChanges( + worktreeID: worktreeID, + added: added, + removed: removed, + state: &state + ) + return .none + + case .alert(.dismiss): + dismissCurrentForceDeleteBranchRequest(state: &state) + return .none + + case .alert: + return .none + + case .delegate: + return .none + } + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift new file mode 100644 index 00000000..31d85b71 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -0,0 +1,383 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import SwiftUI + +extension RepositoriesFeature { + func detectCodeHostsEffect(for repositories: IdentifiedArrayOf) -> Effect? { + let targets = + repositories + .filter { $0.capabilities.supportsCodeHost } + .map { (id: $0.id, rootURL: $0.rootURL) } + guard !targets.isEmpty else { return nil } + let gitClient = gitClient + return .run { send in + var detected: [Repository.ID: CodeHost] = [:] + await withTaskGroup(of: (Repository.ID, CodeHost).self) { group in + for target in targets { + group.addTask { + let host = await gitClient.repositoryWebURL(target.rootURL)?.host + return (target.id, CodeHost.from(host: host)) + } + } + for await (id, host) in group { + detected[id] = host + } + } + // `codeHost(for:)` defaults to `.unknown`, so storing `.unknown` + // explicitly is a no-op. Skip the round trip when nothing is known. + let meaningful = detected.filter { $0.value != .unknown } + guard !meaningful.isEmpty else { return } + await send(.codeHostsDetected(meaningful)) + } + } + + func loadPersistedRepositoryEntries( + fallbackRoots: [URL] = [] + ) async -> [PersistedRepositoryEntry] { + let entries = await repositoryPersistence.loadRepositoryEntries() + let resolvedEntries: [PersistedRepositoryEntry] + if !entries.isEmpty { + resolvedEntries = entries + } else { + let loadedPaths = await repositoryPersistence.loadRoots() + let pathSource = + if !loadedPaths.isEmpty { + loadedPaths + } else { + fallbackRoots.map { $0.path(percentEncoded: false) } + } + resolvedEntries = RepositoryEntryNormalizer.normalize( + pathSource.map { PersistedRepositoryEntry(path: $0, kind: .git) } + ) + } + return await upgradedRepositoryEntriesIfNeeded(resolvedEntries) + } + + func upgradedRepositoryEntriesIfNeeded( + _ entries: [PersistedRepositoryEntry] + ) async -> [PersistedRepositoryEntry] { + let upgradedEntries = await withTaskGroup(of: (Int, PersistedRepositoryEntry).self) { group in + for (index, entry) in entries.enumerated() { + let gitClient = self.gitClient + group.addTask { + let normalizedPath = URL(fileURLWithPath: entry.path) + .standardizedFileURL + .path(percentEncoded: false) + do { + let repoRoot = try await gitClient.repoRoot(URL(fileURLWithPath: normalizedPath)) + let normalizedRepoRoot = repoRoot.standardizedFileURL.path(percentEncoded: false) + switch entry.kind { + case .plain: + if normalizedRepoRoot == normalizedPath { + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) + } + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + case .git: + if normalizedRepoRoot == normalizedPath { + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) + } + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + } + } catch { + if entry.kind == .git, + Self.isNotGitRepositoryError(error), + FileManager.default.fileExists(atPath: normalizedPath) + { + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + } + } + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: entry.kind)) + } + } + + var results = [PersistedRepositoryEntry?](repeating: nil, count: entries.count) + for await (index, entry) in group { + results[index] = entry + } + return results.compactMap { $0 } + } + + let normalizedEntries = RepositoryEntryNormalizer.normalize(upgradedEntries) + if normalizedEntries != entries { + await repositoryPersistence.saveRepositoryEntries(normalizedEntries) + } + return normalizedEntries + } + + nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { + guard case GitClientError.commandFailed(_, let message) = error else { + return false + } + return message.localizedCaseInsensitiveContains("not a git repository") + } + + nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { + let detail: String + if case GitClientError.commandFailed(_, let message) = error, + !message.isEmpty + { + detail = message + } else { + detail = error.localizedDescription + } + return "\(path): \(detail)" + } + + func loadRepositories( + fallbackRoots: [URL] = [], + animated: Bool = false + ) -> Effect { + let gitClient = gitClient + return .run { [animated, fallbackRoots] send in + let entries = await loadPersistedRepositoryEntries(fallbackRoots: fallbackRoots) + let roots = entries.map { URL(fileURLWithPath: $0.path) } + for entry in entries where entry.kind == .git { + _ = try? await gitClient.pruneWorktrees(URL(fileURLWithPath: entry.path)) + } + let (repositories, failures) = await loadRepositoriesData(entries) + await send( + .repositoriesLoaded( + repositories, + failures: failures, + roots: roots, + animated: animated + ) + ) + } + .cancellable(id: CancelID.load, cancelInFlight: true) + } + + private struct WorktreesFetchResult: Sendable { + let entry: PersistedRepositoryEntry + let repository: Repository? + let errorMessage: String? + } + + func loadRepositoriesData(_ entries: [PersistedRepositoryEntry]) async -> ([Repository], [LoadFailure]) { + let fetchResults = await withTaskGroup(of: WorktreesFetchResult.self) { group in + for entry in entries { + let gitClient = self.gitClient + group.addTask { + let rootURL = URL(fileURLWithPath: entry.path).standardizedFileURL + switch entry.kind { + case .git: + do { + let worktrees = try await gitClient.worktrees(rootURL) + return WorktreesFetchResult( + entry: entry, + repository: Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: Repository.name(for: rootURL), + kind: .git, + worktrees: IdentifiedArray(worktrees, uniquingIDsWith: { current, _ in current }) + ), + errorMessage: nil + ) + } catch { + return WorktreesFetchResult( + entry: entry, + repository: nil, + errorMessage: error.localizedDescription + ) + } + case .plain: + return WorktreesFetchResult( + entry: entry, + repository: Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: Repository.name(for: rootURL), + kind: .plain, + worktrees: IdentifiedArray() + ), + errorMessage: nil + ) + } + } + } + + var resultsByRootID: [Repository.ID: WorktreesFetchResult] = [:] + for await result in group { + let rootID = URL(fileURLWithPath: result.entry.path).standardizedFileURL.path(percentEncoded: false) + resultsByRootID[rootID] = result + } + return resultsByRootID + } + + var loaded: [Repository] = [] + var failures: [LoadFailure] = [] + for entry in entries { + let normalizedRoot = URL(fileURLWithPath: entry.path).standardizedFileURL + let rootID = normalizedRoot.path(percentEncoded: false) + guard let result = fetchResults[rootID] else { continue } + if let repository = result.repository { + loaded.append(repository) + } else { + failures.append( + LoadFailure( + rootID: rootID, + message: result.errorMessage ?? "Unknown error" + ) + ) + } + } + return (loaded, failures) + } + + func applyRepositories( + _ repositories: [Repository], + roots: [URL], + shouldPruneArchivedWorktrees: Bool, + state: inout State, + animated: Bool + ) -> ApplyRepositoriesResult { + let previousCounts = Dictionary( + uniqueKeysWithValues: state.repositories.map { ($0.id, $0.worktrees.count) } + ) + let repositoryIDs = Set(repositories.map(\.id)) + let newCounts = Dictionary( + uniqueKeysWithValues: repositories.map { ($0.id, $0.worktrees.count) } + ) + var addedCounts: [Repository.ID: Int] = [:] + for (id, newCount) in newCounts { + let oldCount = previousCounts[id] ?? 0 + let added = newCount - oldCount + if added > 0 { + addedCounts[id] = added + } + } + let filteredPendingWorktrees = state.pendingWorktrees.filter { pending in + guard repositoryIDs.contains(pending.repositoryID) else { return false } + guard let remaining = addedCounts[pending.repositoryID], remaining > 0 else { return true } + addedCounts[pending.repositoryID] = remaining - 1 + return false + } + let availableWorktreeIDs = Set(repositories.flatMap { $0.worktrees.map(\.id) }) + let filteredDeletingIDs = state.deletingWorktreeIDs.intersection(availableWorktreeIDs) + let filteredSetupScriptIDs = state.pendingSetupScriptWorktreeIDs.filter { + availableWorktreeIDs.contains($0) + } + let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { + availableWorktreeIDs.contains($0) + } + let filteredArchivingIDs = state.archivingWorktreeIDs + let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { + availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) + } + let filteredWorktreeInfo = state.worktreeInfoByID.filter { + availableWorktreeIDs.contains($0.key) + } + state.$prowlCreatedWorktreeIDs.withLock { + $0.removeAll { !availableWorktreeIDs.contains($0) } + } + let identifiedRepositories = IdentifiedArray(uniqueElements: repositories) + if animated { + withAnimation { + state.repositories = identifiedRepositories + state.pendingWorktrees = filteredPendingWorktrees + state.deletingWorktreeIDs = filteredDeletingIDs + state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs + state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.archivingWorktreeIDs = filteredArchivingIDs + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress + state.worktreeInfoByID = filteredWorktreeInfo + } + } else { + state.repositories = identifiedRepositories + state.pendingWorktrees = filteredPendingWorktrees + state.deletingWorktreeIDs = filteredDeletingIDs + state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs + state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.archivingWorktreeIDs = filteredArchivingIDs + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress + state.worktreeInfoByID = filteredWorktreeInfo + } + let didPrunePinned = prunePinnedWorktreeIDs(state: &state) + let didPruneRepositoryOrder = pruneRepositoryOrderIDs(roots: roots, state: &state) + let didPruneWorktreeOrder = pruneWorktreeOrderByRepository(roots: roots, state: &state) + let didPruneArchivedWorktrees = + shouldPruneArchivedWorktrees + ? pruneArchivedWorktrees(availableWorktreeIDs: availableWorktreeIDs, state: &state) + : false + if !state.isShowingArchivedWorktrees, !state.isShowingCanvas, + !isSidebarSelectionValid(state.selection, state: state) + { + state.selection = nil + } + if state.shouldRestoreLastFocusedWorktree { + state.shouldRestoreLastFocusedWorktree = false + if state.selection == nil, + isSelectionValid(state.lastFocusedWorktreeID, state: state) + { + state.selection = state.lastFocusedWorktreeID.map(SidebarSelection.worktree) + } + } + if state.selection == nil, state.shouldSelectFirstAfterReload { + state.selection = firstAvailableWorktreeID(from: repositories, state: state) + .map(SidebarSelection.worktree) + state.shouldSelectFirstAfterReload = false + } + return ApplyRepositoriesResult( + didPrunePinned: didPrunePinned, + didPruneRepositoryOrder: didPruneRepositoryOrder, + didPruneWorktreeOrder: didPruneWorktreeOrder, + didPruneArchivedWorktrees: didPruneArchivedWorktrees + ) + } + + func messageAlert(title: String, message: String) -> AlertState { + AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState(message) + } + } + + func confirmationAlertForRepositoryRemoval( + repositoryID: Repository.ID, + state: State + ) -> AlertState? { + guard let repository = state.repositories[id: repositoryID] else { + return nil + } + return AlertState { + TextState("Remove repository?") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveRepository(repository.id)) { + TextState("Remove repository") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState( + "This removes the repository from Prowl. " + + "Worktrees and the main repository folder stay on disk." + ) + } + } + + func selectionDidChange( + previousSelectionID: Worktree.ID?, + previousSelectedWorktree: Worktree?, + selectedWorktreeID: Worktree.ID?, + selectedWorktree: Worktree? + ) -> Bool { + if previousSelectionID != selectedWorktreeID { + return true + } + if previousSelectedWorktree?.workingDirectory != selectedWorktree?.workingDirectory { + return true + } + if previousSelectedWorktree?.repositoryRootURL != selectedWorktree?.repositoryRootURL { + return true + } + return false + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift new file mode 100644 index 00000000..b084bf95 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -0,0 +1,419 @@ +import ComposableArchitecture +import Foundation + +func queuePullRequestRefresh( + repositoryID: Repository.ID, + repositoryRootURL: URL, + worktreeIDs: [Worktree.ID], + refreshesByRepositoryID: inout [Repository.ID: RepositoriesFeature.PendingPullRequestRefresh] +) { + if var pending = refreshesByRepositoryID[repositoryID] { + var seenWorktreeIDs = Set(pending.worktreeIDs) + for worktreeID in worktreeIDs where seenWorktreeIDs.insert(worktreeID).inserted { + pending.worktreeIDs.append(worktreeID) + } + refreshesByRepositoryID[repositoryID] = pending + } else { + refreshesByRepositoryID[repositoryID] = RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: repositoryRootURL, + worktreeIDs: worktreeIDs + ) + } +} + +func reorderedUnpinnedWorktreeIDs( + for worktreeID: Worktree.ID, + in repository: Repository, + state: RepositoriesFeature.State +) -> [Worktree.ID] { + var ordered = state.orderedUnpinnedWorktreeIDs(in: repository) + guard let index = ordered.firstIndex(of: worktreeID) else { + return ordered + } + ordered.remove(at: index) + ordered.insert(worktreeID, at: 0) + return ordered +} + +func restoreSelection( + _ id: Worktree.ID?, + pendingID: Worktree.ID, + state: inout RepositoriesFeature.State +) { + guard state.selection == .worktree(pendingID) else { return } + setSingleWorktreeSelection( + isSelectionValid(id, state: state) ? id : nil, + state: &state, + recordHistory: false + ) + pruneWorktreeHistoryTails(state: &state) +} + +func isSelectionValid( + _ id: Worktree.ID?, + state: RepositoriesFeature.State +) -> Bool { + state.selectedRow(for: id) != nil +} + +/// Choose the next book to open after `worktreeID`'s book is retired. +/// Prefer the book immediately *after* the closed one in Shelf order; +/// fall back to the one immediately *before* it; return `nil` when +/// Shelf is inactive, when the closed book isn't the currently open +/// one, or when no other books remain. +func replacementBookAfterClosing( + worktreeID: Worktree.ID, + state: RepositoriesFeature.State +) -> ShelfBook? { + guard state.isShelfActive, + state.selectedTerminalWorktree?.id == worktreeID + else { return nil } + let books = state.orderedShelfBooks() + guard let index = books.firstIndex(where: { $0.id == worktreeID }) else { + return nil + } + let remaining = books.enumerated().filter { $0.offset != index }.map(\.element) + guard !remaining.isEmpty else { return nil } + // After removing index `index`, the "next" book is now at position + // `index` in the reduced list (if it exists); otherwise the last one + // is the "previous" relative to what was closed. + if index < remaining.count { + return remaining[index] + } + return remaining.last +} + +/// Returns the Shelf book at `offset` positions from the currently open +/// book (wrapping around the book list). Returns nil if there are no +/// books. When there is no open book, offset > 0 picks the first book +/// and offset < 0 picks the last. +func shelfBook( + atOffset offset: Int, + state: RepositoriesFeature.State +) -> ShelfBook? { + let books = state.orderedShelfBooks() + guard !books.isEmpty else { return nil } + if let currentID = state.openShelfBookID, + let currentIndex = books.firstIndex(where: { $0.id == currentID }) + { + let nextIndex = (currentIndex + offset + books.count) % books.count + return books[nextIndex] + } + return offset > 0 ? books.first : books.last +} + +/// Dispatches the right selection action for a book — a worktree vs. +/// a plain folder requires different Reducer actions even though the +/// Shelf treats them uniformly. +func shelfBookSelectionEffect( + for book: ShelfBook +) -> Effect { + switch book.kind { + case .worktree: + return .send(.selectWorktree(book.id, focusTerminal: true, recordHistory: false)) + case .plainFolder: + return .send(.selectRepository(book.repositoryID)) + } +} + +func isSidebarSelectionValid( + _ selection: SidebarSelection?, + state: RepositoriesFeature.State +) -> Bool { + switch selection { + case .worktree(let id): + return isSelectionValid(id, state: state) + case .repository(let id): + return state.repositories[id: id] != nil + case .archivedWorktrees, .canvas: + return true + case nil: + return false + } +} + +func setSingleWorktreeSelection( + _ worktreeID: Worktree.ID?, + state: inout RepositoriesFeature.State, + recordHistory: Bool = false +) { + if recordHistory { + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: worktreeID, state: &state) + } + state.selection = worktreeID.map(SidebarSelection.worktree) + if let worktreeID { + state.sidebarSelectedWorktreeIDs = [worktreeID] + } else { + state.sidebarSelectedWorktreeIDs = [] + } +} + +enum WorktreeHistoryDirection { + case backward + case forward +} + +private let worktreeHistoryStackLimit = 50 + +func navigateWorktreeHistory( + direction: WorktreeHistoryDirection, + state: inout RepositoriesFeature.State +) -> Effect { + guard !state.isShowingShelf, !state.isShowingCanvas else { return .none } + guard let currentID = state.selectedWorktreeID else { return .none } + var sourceStack = + direction == .backward + ? state.worktreeHistoryBackStack + : state.worktreeHistoryForwardStack + guard let destinationID = popValidWorktreeHistoryDestination(from: &sourceStack, currentID: currentID, state: state) + else { + if direction == .backward { + state.worktreeHistoryBackStack = sourceStack + } else { + state.worktreeHistoryForwardStack = sourceStack + } + return .none + } + + if direction == .backward { + state.worktreeHistoryBackStack = sourceStack + pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryForwardStack) + } else { + state.worktreeHistoryForwardStack = sourceStack + pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryBackStack) + } + setSingleWorktreeSelection(destinationID, state: &state, recordHistory: false) + state.openedWorktreeIDs.insert(destinationID) + return .send(.delegate(.selectedWorktreeChanged(state.worktree(for: destinationID)))) +} + +func recordWorktreeHistoryTransition( + from previousID: Worktree.ID?, + to nextID: Worktree.ID?, + state: inout RepositoriesFeature.State +) { + // Shelf / Canvas are mode switches, not worktree navigation — leave + // history frozen so users can resume Back/Forward where they left off. + guard !state.isShowingShelf, !state.isShowingCanvas else { return } + // No-op transitions (same worktree, or both endpoints nil) leave history alone. + if previousID == nextID { return } + // Any user-initiated selection change invalidates the redo path. Crucially + // this also fires when the user navigates to/from .repository or + // .archivedWorktrees (one or both IDs nil), so a stale forward stack + // can't carry over into an unrelated path. + state.worktreeHistoryForwardStack = [] + // Only push onto the back stack when leaving a still-valid worktree; we + // don't want non-worktree selections (repository / archived) showing up + // as Back targets. + guard let previousID, isSelectionValid(previousID, state: state) else { return } + pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) +} + +private func popValidWorktreeHistoryDestination( + from stack: inout [Worktree.ID], + currentID: Worktree.ID, + state: RepositoriesFeature.State +) -> Worktree.ID? { + while let candidateID = stack.popLast() { + guard candidateID != currentID, isSelectionValid(candidateID, state: state) else { + continue + } + return candidateID + } + return nil +} + +private func pushWorktreeHistoryID(_ id: Worktree.ID, onto stack: inout [Worktree.ID]) { + stack.append(id) + if stack.count > worktreeHistoryStackLimit { + stack.removeFirst(stack.count - worktreeHistoryStackLimit) + } +} + +func pruneWorktreeHistoryTails(state: inout RepositoriesFeature.State) { + pruneWorktreeHistoryTail(stack: &state.worktreeHistoryBackStack, state: state) + pruneWorktreeHistoryTail(stack: &state.worktreeHistoryForwardStack, state: state) +} + +private func pruneWorktreeHistoryTail( + stack: inout [Worktree.ID], + state: RepositoriesFeature.State +) { + while let last = stack.last, + last == state.selectedWorktreeID || !isSelectionValid(last, state: state) + { + stack.removeLast() + } +} + +func repositoryForWorktreeCreation( + _ state: RepositoriesFeature.State +) -> Repository? { + if let selectedRepository = state.selectedRepository, + selectedRepository.capabilities.supportsWorktrees + { + return selectedRepository + } + if let selectedWorktreeID = state.selectedWorktreeID { + if let pending = state.pendingWorktree(for: selectedWorktreeID) { + if let repository = state.repositories[id: pending.repositoryID], + repository.capabilities.supportsWorktrees + { + return repository + } + return nil + } + for repository in state.repositories + where repository.worktrees[id: selectedWorktreeID] != nil { + if repository.capabilities.supportsWorktrees { + return repository + } + return nil + } + } + if state.repositories.count == 1, + let repository = state.repositories.first, + repository.capabilities.supportsWorktrees + { + return repository + } + return nil +} + +func prunePinnedWorktreeIDs(state: inout RepositoriesFeature.State) -> Bool { + let availableIDs = Set(state.repositories.flatMap { $0.worktrees.map(\.id) }) + let mainIDs = Set( + state.repositories.compactMap { repository in + repository.worktrees.first(where: { state.isMainWorktree($0) })?.id + } + ) + let archivedSet = state.archivedWorktreeIDSet + let pruned = state.pinnedWorktreeIDs.filter { + availableIDs.contains($0) + && !mainIDs.contains($0) + && !archivedSet.contains($0) + } + if pruned != state.pinnedWorktreeIDs { + state.pinnedWorktreeIDs = pruned + return true + } + return false +} + +func pruneRepositoryOrderIDs( + roots: [URL], + state: inout RepositoriesFeature.State +) -> Bool { + let rootIDs = roots.map { $0.standardizedFileURL.path(percentEncoded: false) } + let availableIDs = Set(rootIDs + state.repositories.map(\.id)) + let pruned = state.repositoryOrderIDs.filter { availableIDs.contains($0) } + if pruned != state.repositoryOrderIDs { + state.repositoryOrderIDs = pruned + return true + } + return false +} + +func pruneWorktreeOrderByRepository( + roots: [URL], + state: inout RepositoriesFeature.State +) -> Bool { + let rootIDs = Set(roots.map { $0.standardizedFileURL.path(percentEncoded: false) }) + let repositoriesByID = Dictionary(uniqueKeysWithValues: state.repositories.map { ($0.id, $0) }) + let pinnedSet = Set(state.pinnedWorktreeIDs) + let archivedSet = state.archivedWorktreeIDSet + var pruned: [Repository.ID: [Worktree.ID]] = [:] + for (repoID, order) in state.worktreeOrderByRepository { + guard let repository = repositoriesByID[repoID] else { + if rootIDs.contains(repoID), !order.isEmpty { + pruned[repoID] = order + } + continue + } + let mainID = repository.worktrees.first(where: { state.isMainWorktree($0) })?.id + let availableIDs = Set(repository.worktrees.map(\.id)) + var seen: Set = [] + var filtered: [Worktree.ID] = [] + for id in order { + if availableIDs.contains(id), + id != mainID, + !pinnedSet.contains(id), + !archivedSet.contains(id), + seen.insert(id).inserted + { + filtered.append(id) + } + } + if !filtered.isEmpty { + pruned[repoID] = filtered + } + } + if pruned != state.worktreeOrderByRepository { + state.worktreeOrderByRepository = pruned + return true + } + return false +} + +func pruneArchivedWorktrees( + availableWorktreeIDs: Set, + state: inout RepositoriesFeature.State +) -> Bool { + let pruned = state.archivedWorktrees.filter { availableWorktreeIDs.contains($0.id) } + if pruned != state.archivedWorktrees { + state.archivedWorktrees = pruned + return true + } + return false +} + +func firstAvailableWorktreeID( + from repositories: [Repository], + state: RepositoriesFeature.State +) -> Worktree.ID? { + for repository in repositories { + if let first = state.orderedWorktrees(in: repository).first { + return first.id + } + } + return nil +} + +func firstAvailableWorktreeID( + in repositoryID: Repository.ID, + state: RepositoriesFeature.State +) -> Worktree.ID? { + guard let repository = state.repositories[id: repositoryID] else { + return nil + } + return state.orderedWorktrees(in: repository).first?.id +} + +func findWorktreeAndRepository( + worktreeID: Worktree.ID, + state: RepositoriesFeature.State +) -> (worktree: Worktree, repository: Repository)? { + for repository in state.repositories { + if let worktree = repository.worktrees[id: worktreeID] { + return (worktree, repository) + } + } + return nil +} + +func nextWorktreeID( + afterRemoving worktree: Worktree, + in repository: Repository, + state: RepositoriesFeature.State +) -> Worktree.ID? { + let orderedIDs = state.orderedWorktrees(in: repository).map(\.id) + guard let index = orderedIDs.firstIndex(of: worktree.id) else { return nil } + let nextIndex = index + 1 + if nextIndex < orderedIDs.count { + return orderedIDs[nextIndex] + } + if index > 0 { + return orderedIDs[index - 1] + } + return nil +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift new file mode 100644 index 00000000..bfd0a876 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -0,0 +1,516 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections + +extension RepositoriesFeature.State { + var selectedWorktreeID: Worktree.ID? { + selection?.worktreeID + } + + var canNavigateWorktreeHistoryBackward: Bool { + guard canUseWorktreeHistory else { return false } + return canNavigateWorktreeHistory(stack: worktreeHistoryBackStack) + } + + var canNavigateWorktreeHistoryForward: Bool { + guard canUseWorktreeHistory else { return false } + return canNavigateWorktreeHistory(stack: worktreeHistoryForwardStack) + } + + var selectedRepositoryID: Repository.ID? { + guard case .repository(let repositoryID) = selection else { return nil } + return repositoryID + } + + var selectedRepository: Repository? { + guard let selectedRepositoryID else { return nil } + return repositories[id: selectedRepositoryID] + } + + var selectedTerminalWorktree: Worktree? { + if let selectedWorktreeID { + return worktree(for: selectedWorktreeID) + } + guard let selectedRepository, + selectedRepository.capabilities.supportsRunnableFolderActions, + !selectedRepository.capabilities.supportsWorktrees + else { + return nil + } + return Worktree( + id: selectedRepository.id, + name: selectedRepository.name, + detail: selectedRepository.rootURL.path(percentEncoded: false), + workingDirectory: selectedRepository.rootURL, + repositoryRootURL: selectedRepository.rootURL + ) + } + + var terminalStateIDs: Set { + Set( + repositories.flatMap { repository -> [Worktree.ID] in + if repository.capabilities.supportsWorktrees { + repository.worktrees.map(\.id) + } else if repository.capabilities.supportsRunnableFolderActions { + [repository.id] + } else { + [] + } + } + ) + } + + var expandedRepositoryIDs: Set { + let repositoryIDs = Set(repositories.map(\.id)) + let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) + let pendingRepositoryIDs = Set(pendingWorktrees.map(\.repositoryID)) + return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) + } + + func worktreeID(byOffset offset: Int) -> Worktree.ID? { + let rows = orderedWorktreeRows(includingRepositoryIDs: expandedRepositoryIDs) + guard !rows.isEmpty else { return nil } + if let currentID = selectedWorktreeID, + let currentIndex = rows.firstIndex(where: { $0.id == currentID }) + { + return rows[(currentIndex + offset + rows.count) % rows.count].id + } + return rows[offset > 0 ? 0 : rows.count - 1].id + } + + var isShowingArchivedWorktrees: Bool { + selection == .archivedWorktrees + } + + var isShowingCanvas: Bool { + selection == .canvas + } + + var isShowingShelf: Bool { + // Shelf needs at least one repository to render. Guarding here (not just + // on entry) also covers the launch race where the repository snapshot + // briefly repopulates books and flips `isShelfActive` on before the empty + // entries file reconciles repos back to zero — without this a zero-repo + // launch with "Default View = Shelf" would stick on an empty Shelf instead + // of falling back to Normal. + isShelfActive && !repositories.isEmpty + } + + var topSegment: TopSegment { + if isShowingCanvas { return .canvas } + if isShowingShelf { return .shelf } + return .tabbed + } + + private var canUseWorktreeHistory: Bool { + // History navigation only makes sense when a worktree is the current + // anchor. With selection on `.repository`, `.archivedWorktrees`, or `nil`, + // there is no "current" position to step away from, so the menu items + // (and their shortcuts) must be disabled. + !isShowingShelf && !isShowingCanvas && selectedWorktreeID != nil + } + + var archivedWorktreeIDSet: Set { + Set(archivedWorktrees.map(\.id)) + } + + func isWorktreeArchived(_ id: Worktree.ID) -> Bool { + archivedWorktreeIDSet.contains(id) + } + + private func canNavigateWorktreeHistory(stack: [Worktree.ID]) -> Bool { + stack.reversed().contains { id in + id != selectedWorktreeID && isSelectionValid(id, state: self) + } + } + + func worktreeInfo(for worktreeID: Worktree.ID) -> WorktreeInfoEntry? { + worktreeInfoByID[worktreeID] + } + + func codeHost(for repositoryID: Repository.ID) -> CodeHost { + codeHostByRepositoryID[repositoryID] ?? .unknown + } + + func codeHost(forWorktreeID worktreeID: Worktree.ID?) -> CodeHost { + guard let worktreeID, let repositoryID = repositoryID(containing: worktreeID) else { + return .unknown + } + return codeHost(for: repositoryID) + } + + func worktreesForInfoWatcher() -> [Worktree] { + let worktrees = repositories.flatMap(\.worktrees) + guard !isShowingArchivedWorktrees else { + return worktrees + } + let archivedSet = archivedWorktreeIDSet + return worktrees.filter { !archivedSet.contains($0.id) } + } + + struct ArchivedWorktreeGroup: Equatable { + var repository: Repository + var worktrees: [Worktree] + } + + func archivedWorktreesByRepository() -> [ArchivedWorktreeGroup] { + let archivedSet = archivedWorktreeIDSet + var groups: [ArchivedWorktreeGroup] = [] + for repository in repositories { + let worktrees = Array(repository.worktrees.filter { archivedSet.contains($0.id) }) + if !worktrees.isEmpty { + groups.append(ArchivedWorktreeGroup(repository: repository, worktrees: worktrees)) + } + } + return groups + } + + var canCreateWorktree: Bool { + if repositories.isEmpty { + return false + } + if let repository = repositoryForWorktreeCreation(self) { + return !removingRepositoryIDs.contains(repository.id) + } + return false + } + + func worktree(for id: Worktree.ID?) -> Worktree? { + guard let id else { return nil } + for repository in repositories { + if let worktree = repository.worktrees[id: id] { + return worktree + } + } + return nil + } + + func canvasNavigationWorktree(forRepositoryID repositoryID: Repository.ID) -> Worktree? { + guard let repository = repositories[id: repositoryID] else { return nil } + if repository.capabilities.supportsWorktrees { + return worktreeRows(in: repository) + .compactMap { worktree(for: $0.id) } + .first + } + guard repository.capabilities.supportsRunnableFolderActions else { return nil } + return Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + } + + func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { + guard let id else { return nil } + return pendingWorktrees.first(where: { $0.id == id }) + } + + func archiveScriptProgress(for id: Worktree.ID?) -> ArchiveScriptProgress? { + guard let id else { return nil } + return archiveScriptProgressByWorktreeID[id] + } + + func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { + pendingTerminalFocusWorktreeIDs.contains(worktreeID) + } + + private func makePendingWorktreeRow(_ pending: PendingWorktree) -> WorktreeRowModel { + let isDeleting = removingRepositoryIDs.contains(pending.repositoryID) + return WorktreeRowModel( + id: pending.id, + repositoryID: pending.repositoryID, + name: pending.progress.titleText, + detail: pending.progress.detailText, + info: worktreeInfo(for: pending.id), + isPinned: false, + isMainWorktree: false, + isPending: true, + isArchiving: false, + isDeleting: isDeleting, + isRemovable: false + ) + } + + private func makeWorktreeRow( + _ worktree: Worktree, + repositoryID: Repository.ID, + isPinned: Bool, + isMainWorktree: Bool + ) -> WorktreeRowModel { + let isDeleting = + removingRepositoryIDs.contains(repositoryID) + || deletingWorktreeIDs.contains(worktree.id) + let isArchiving = archivingWorktreeIDs.contains(worktree.id) + return WorktreeRowModel( + id: worktree.id, + repositoryID: repositoryID, + name: worktree.name, + detail: worktree.detail, + info: worktreeInfo(for: worktree.id), + isPinned: isPinned, + isMainWorktree: isMainWorktree, + isPending: false, + isArchiving: isArchiving, + isDeleting: isDeleting, + isRemovable: !isDeleting && !isArchiving + ) + } + + func selectedRow(for id: Worktree.ID?) -> WorktreeRowModel? { + guard let id else { return nil } + if isWorktreeArchived(id) { + return nil + } + if let pending = pendingWorktree(for: id) { + return makePendingWorktreeRow(pending) + } + for repository in repositories { + if let worktree = repository.worktrees[id: id] { + return makeWorktreeRow( + worktree, + repositoryID: repository.id, + isPinned: pinnedWorktreeIDs.contains(worktree.id), + isMainWorktree: isMainWorktree(worktree) + ) + } + } + return nil + } + + func repositoryName(for id: Repository.ID) -> String? { + repositories[id: id]?.name + } + + func orderedRepositoryRoots() -> [URL] { + let rootsByID = Dictionary( + uniqueKeysWithValues: repositoryRoots.map { + ($0.standardizedFileURL.path(percentEncoded: false), $0.standardizedFileURL) + } + ) + var ordered: [URL] = [] + var seen: Set = [] + for id in repositoryOrderIDs { + if let rootURL = rootsByID[id], seen.insert(id).inserted { + ordered.append(rootURL) + } + } + for rootURL in repositoryRoots { + let id = rootURL.standardizedFileURL.path(percentEncoded: false) + if seen.insert(id).inserted { + ordered.append(rootURL.standardizedFileURL) + } + } + if ordered.isEmpty { + ordered = repositories.map(\.rootURL) + } + return ordered + } + + func orderedRepositoryIDs() -> [Repository.ID] { + orderedRepositoryRoots().map { $0.standardizedFileURL.path(percentEncoded: false) } + } + + func repositoryID(for worktreeID: Worktree.ID?) -> Repository.ID? { + selectedRow(for: worktreeID)?.repositoryID + } + + func repositoryID(containing worktreeID: Worktree.ID) -> Repository.ID? { + for repository in repositories where repository.worktrees[id: worktreeID] != nil { + return repository.id + } + return nil + } + + func isMainWorktree(_ worktree: Worktree) -> Bool { + worktree.isMain + } + + func isWorktreeMerged(_ worktree: Worktree) -> Bool { + worktreeInfoByID[worktree.id]?.pullRequest?.state == "MERGED" + } + + func orderedPinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { + let archivedSet = archivedWorktreeIDSet + return pinnedWorktreeIDs.filter { id in + if archivedSet.contains(id) { + return false + } + if let worktree = repository.worktrees[id: id] { + return !isMainWorktree(worktree) + } + return false + } + } + + func orderedPinnedWorktrees(in repository: Repository) -> [Worktree] { + orderedPinnedWorktreeIDs(in: repository).compactMap { repository.worktrees[id: $0] } + } + + func replacingPinnedWorktreeIDs( + in repository: Repository, + with reordered: [Worktree.ID] + ) -> [Worktree.ID] { + let repoPinnedIDs = Set(orderedPinnedWorktreeIDs(in: repository)) + var iterator = reordered.makeIterator() + return pinnedWorktreeIDs.map { id in + if repoPinnedIDs.contains(id) { + return iterator.next() ?? id + } + return id + } + } + + func orderedUnpinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { + let mainID = repository.worktrees.first(where: { isMainWorktree($0) })?.id + let pinnedSet = Set(pinnedWorktreeIDs) + let archivedSet = archivedWorktreeIDSet + let available = repository.worktrees.filter { worktree in + worktree.id != mainID + && !pinnedSet.contains(worktree.id) + && !archivedSet.contains(worktree.id) + } + let orderedIDs = worktreeOrderByRepository[repository.id] ?? [] + let availableIDs = Set(available.map(\.id)) + let orderedIDSet = Set(orderedIDs) + var seen: Set = [] + var missing: [Worktree.ID] = [] + for worktree in available where !orderedIDSet.contains(worktree.id) { + if seen.insert(worktree.id).inserted { + missing.append(worktree.id) + } + } + var ordered: [Worktree.ID] = [] + for id in orderedIDs { + if availableIDs.contains(id), + seen.insert(id).inserted + { + ordered.append(id) + } + } + return missing + ordered + } + + func orderedUnpinnedWorktrees(in repository: Repository) -> [Worktree] { + orderedUnpinnedWorktreeIDs(in: repository).compactMap { repository.worktrees[id: $0] } + } + + func orderedWorktrees(in repository: Repository) -> [Worktree] { + var ordered: [Worktree] = [] + if let mainWorktree = repository.worktrees.first(where: { isMainWorktree($0) }) { + if !isWorktreeArchived(mainWorktree.id) { + ordered.append(mainWorktree) + } + } + ordered.append(contentsOf: orderedPinnedWorktrees(in: repository)) + ordered.append(contentsOf: orderedUnpinnedWorktrees(in: repository)) + return ordered + } + + func isWorktreePinned(_ worktree: Worktree) -> Bool { + pinnedWorktreeIDs.contains(worktree.id) + } + + var confirmWorktreeAlert: RepositoriesFeature.Alert? { + guard let alert else { return nil } + for button in alert.buttons { + if case .confirmArchiveWorktree(let worktreeID, let repositoryID)? = button.action.action { + return .confirmArchiveWorktree(worktreeID, repositoryID) + } + if case .confirmArchiveWorktrees(let targets)? = button.action.action { + return .confirmArchiveWorktrees(targets) + } + } + return nil + } + + func isRemovingRepository(_ repository: Repository) -> Bool { + removingRepositoryIDs.contains(repository.id) + } + + func worktreeRowSections(in repository: Repository) -> WorktreeRowSections { + let mainWorktree = repository.worktrees.first(where: { isMainWorktree($0) }) + let pinnedWorktrees = orderedPinnedWorktrees(in: repository) + let unpinnedWorktrees = orderedUnpinnedWorktrees(in: repository) + let pendingEntries = pendingWorktrees.filter { $0.repositoryID == repository.id } + let mainRow: WorktreeRowModel? = + if let mainWorktree, !isWorktreeArchived(mainWorktree.id) { + makeWorktreeRow( + mainWorktree, + repositoryID: repository.id, + isPinned: false, + isMainWorktree: true + ) + } else { + nil + } + var pinnedRows: [WorktreeRowModel] = [] + for worktree in pinnedWorktrees { + pinnedRows.append( + makeWorktreeRow( + worktree, + repositoryID: repository.id, + isPinned: true, + isMainWorktree: false + ) + ) + } + var pendingRows: [WorktreeRowModel] = [] + for pending in pendingEntries { + pendingRows.append(makePendingWorktreeRow(pending)) + } + var unpinnedRows: [WorktreeRowModel] = [] + for worktree in unpinnedWorktrees { + unpinnedRows.append( + makeWorktreeRow( + worktree, + repositoryID: repository.id, + isPinned: false, + isMainWorktree: false + ) + ) + } + return WorktreeRowSections( + main: mainRow, + pinned: pinnedRows, + pending: pendingRows, + unpinned: unpinnedRows + ) + } + + func worktreeRows(in repository: Repository) -> [WorktreeRowModel] { + let sections = worktreeRowSections(in: repository) + return sections.allRows + } + + func orderedWorktreeRows() -> [WorktreeRowModel] { + orderedWorktreeRows(includingRepositoryIDs: Set(repositories.map(\.id))) + } + + func orderedWorktreeRows(includingRepositoryIDs: Set) -> [WorktreeRowModel] { + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + return orderedRepositoryIDs() + .filter { includingRepositoryIDs.contains($0) } + .compactMap { repositoriesByID[$0] } + .flatMap { worktreeRows(in: $0) } + } +} + +struct WorktreeRowSections: Equatable { + let main: WorktreeRowModel? + let pinned: [WorktreeRowModel] + let pending: [WorktreeRowModel] + let unpinned: [WorktreeRowModel] + + var allRows: [WorktreeRowModel] { + var rows: [WorktreeRowModel] = [] + if let main { + rows.append(main) + } + rows.append(contentsOf: pinned) + rows.append(contentsOf: pending) + rows.append(contentsOf: unpinned) + return rows + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift new file mode 100644 index 00000000..16a047fa --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift @@ -0,0 +1,319 @@ +import Foundation +import IdentifiedCollections + +struct FailedWorktreeCleanup { + let didRemoveWorktree: Bool + let didUpdatePinned: Bool + let didUpdateOrder: Bool + let worktree: Worktree? +} + +func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) { + state.pendingWorktrees.removeAll { $0.id == id } +} + +func requestCanvasFocus( + _ target: CanvasFocusRequest.Target, + openedWorktreeID: Worktree.ID, + state: inout RepositoriesFeature.State +) { + state.nextCanvasFocusRequestID += 1 + state.pendingCanvasFocusRequest = CanvasFocusRequest( + id: state.nextCanvasFocusRequestID, + target: target + ) + state.openedWorktreeIDs.insert(openedWorktreeID) +} + +func updatePendingWorktreeProgress( + _ id: String, + progress: WorktreeCreationProgress, + state: inout RepositoriesFeature.State +) { + guard let index = state.pendingWorktrees.firstIndex(where: { $0.id == id }) else { + return + } + state.pendingWorktrees[index].progress = progress +} + +func insertWorktree( + _ worktree: Worktree, + repositoryID: Repository.ID, + state: inout RepositoriesFeature.State +) { + guard let index = state.repositories.index(id: repositoryID) else { return } + let repository = state.repositories[index] + if repository.worktrees[id: worktree.id] != nil { + return + } + var worktrees = repository.worktrees + worktrees.insert(worktree, at: 0) + state.repositories[index] = Repository( + id: repository.id, + rootURL: repository.rootURL, + name: repository.name, + worktrees: worktrees + ) +} + +@discardableResult +func removeWorktree( + _ worktreeID: Worktree.ID, + repositoryID: Repository.ID, + state: inout RepositoriesFeature.State +) -> Bool { + guard let index = state.repositories.index(id: repositoryID) else { return false } + let repository = state.repositories[index] + guard repository.worktrees[id: worktreeID] != nil else { return false } + var worktrees = repository.worktrees + worktrees.remove(id: worktreeID) + state.repositories[index] = Repository( + id: repository.id, + rootURL: repository.rootURL, + name: repository.name, + worktrees: worktrees + ) + return true +} + +func cleanupFailedWorktree( + repositoryID: Repository.ID, + name: String?, + baseDirectory: URL, + state: inout RepositoriesFeature.State +) -> FailedWorktreeCleanup { + guard let name, !name.isEmpty else { + return FailedWorktreeCleanup( + didRemoveWorktree: false, + didUpdatePinned: false, + didUpdateOrder: false, + worktree: nil + ) + } + let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL + let normalizedBaseDirectory = baseDirectory.standardizedFileURL + let worktreeURL = + normalizedBaseDirectory + .appending(path: name, directoryHint: .isDirectory) + .standardizedFileURL + guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: normalizedBaseDirectory) else { + return FailedWorktreeCleanup( + didRemoveWorktree: false, + didUpdatePinned: false, + didUpdateOrder: false, + worktree: nil + ) + } + let worktreeID = worktreeURL.path(percentEncoded: false) + let worktree = + state.repositories[id: repositoryID]?.worktrees[id: worktreeID] + ?? Worktree( + id: worktreeID, + name: name, + detail: "", + workingDirectory: worktreeURL, + repositoryRootURL: repositoryRootURL + ) + let cleanup = cleanupWorktreeState( + worktreeID, + repositoryID: repositoryID, + state: &state + ) + return FailedWorktreeCleanup( + didRemoveWorktree: cleanup.didRemoveWorktree, + didUpdatePinned: cleanup.didUpdatePinned, + didUpdateOrder: cleanup.didUpdateOrder, + worktree: worktree + ) +} + +func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { + PathPolicy.contains(path, in: baseDirectory) +} + +struct WorktreeCleanupStateResult { + let didRemoveWorktree: Bool + let didUpdatePinned: Bool + let didUpdateOrder: Bool +} + +func cleanupWorktreeState( + _ worktreeID: Worktree.ID, + repositoryID: Repository.ID, + state: inout RepositoriesFeature.State +) -> WorktreeCleanupStateResult { + let didRemoveWorktree = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) + state.pendingWorktrees.removeAll { $0.id == worktreeID } + state.pendingSetupScriptWorktreeIDs.remove(worktreeID) + state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) + state.archivingWorktreeIDs.remove(worktreeID) + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) + state.deletingWorktreeIDs.remove(worktreeID) + state.worktreeInfoByID.removeValue(forKey: worktreeID) + let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) + if didUpdatePinned { + state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } + } + var didUpdateOrder = false + if var order = state.worktreeOrderByRepository[repositoryID] { + let countBefore = order.count + order.removeAll { $0 == worktreeID } + if order.count != countBefore { + didUpdateOrder = true + if order.isEmpty { + state.worktreeOrderByRepository.removeValue(forKey: repositoryID) + } else { + state.worktreeOrderByRepository[repositoryID] = order + } + } + } + return WorktreeCleanupStateResult( + didRemoveWorktree: didRemoveWorktree, + didUpdatePinned: didUpdatePinned, + didUpdateOrder: didUpdateOrder + ) +} + +nonisolated func archiveScriptCommand(_ script: String) -> String { + let normalized = script.replacing("\n", with: "\\n") + return "bash -lc \(shellQuote(normalized))" +} + +nonisolated func worktreeCreateCommand( + baseDirectoryURL: URL, + name: String, + copyIgnored: Bool, + copyUntracked: Bool, + baseRef: String +) -> String { + let baseDir = baseDirectoryURL.path(percentEncoded: false) + var parts = ["wt", "--base-dir", baseDir, "sw"] + if copyIgnored { + parts.append("--copy-ignored") + } + if copyUntracked { + parts.append("--copy-untracked") + } + if !baseRef.isEmpty { + parts.append("--from") + parts.append(baseRef) + } + if copyIgnored || copyUntracked { + parts.append("--verbose") + } + parts.append(name) + return parts.map(shellQuote).joined(separator: " ") +} + +nonisolated func shellQuote(_ value: String) -> String { + "'\(value.replacing("'", with: "'\"'\"'"))'" +} + +func updateWorktreeName( + _ worktreeID: Worktree.ID, + name: String, + state: inout RepositoriesFeature.State +) { + for index in state.repositories.indices { + var repository = state.repositories[index] + guard let worktreeIndex = repository.worktrees.index(id: worktreeID) else { + continue + } + let worktree = repository.worktrees[worktreeIndex] + guard worktree.name != name else { + return + } + var worktrees = repository.worktrees + worktrees[id: worktreeID] = Worktree( + id: worktree.id, + name: name, + detail: worktree.detail, + workingDirectory: worktree.workingDirectory, + repositoryRootURL: worktree.repositoryRootURL, + createdAt: worktree.createdAt + ) + repository = Repository( + id: repository.id, + rootURL: repository.rootURL, + name: repository.name, + worktrees: worktrees + ) + state.repositories[index] = repository + return + } +} + +@discardableResult +func updateWorktreeLineChanges( + worktreeID: Worktree.ID, + added: Int, + removed: Int, + state: inout RepositoriesFeature.State +) -> Bool { + var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() + if added == 0 && removed == 0 { + entry.addedLines = nil + entry.removedLines = nil + } else { + entry.addedLines = added + entry.removedLines = removed + } + let previousEntry = state.worktreeInfoByID[worktreeID] + if entry.isEmpty { + guard previousEntry != nil else { + return false + } + state.worktreeInfoByID.removeValue(forKey: worktreeID) + return true + } + guard previousEntry != entry else { + return false + } + state.worktreeInfoByID[worktreeID] = entry + return true +} + +func updateWorktreePullRequest( + worktreeID: Worktree.ID, + pullRequest: GithubPullRequest?, + state: inout RepositoriesFeature.State +) { + var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() + entry.pullRequest = pullRequest + if entry.isEmpty { + state.worktreeInfoByID.removeValue(forKey: worktreeID) + } else { + state.worktreeInfoByID[worktreeID] = entry + } +} + +nonisolated func normalizedLineChanges(_ entry: WorktreeInfoEntry?) -> (added: Int, removed: Int)? { + guard let added = entry?.addedLines, let removed = entry?.removedLines else { + return nil + } + return normalizedLineChanges(added: added, removed: removed) +} + +nonisolated func normalizedLineChanges( + added: Int, + removed: Int +) -> (added: Int, removed: Int)? { + guard added != 0 || removed != 0 else { + return nil + } + return (added, removed) +} + +nonisolated func lineChangesEqual( + _ lhs: (added: Int, removed: Int)?, + _ rhs: (added: Int, removed: Int)? +) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case (.some(let lhs), .some(let rhs)): + return lhs.added == rhs.added && lhs.removed == rhs.removed + default: + return false + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 9644c358..6388f836 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -9,8 +9,8 @@ nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) nonisolated let worktreeCreationProgressLineLimit = 200 nonisolated let worktreeCreationProgressUpdateStride = 20 nonisolated let archiveScriptProgressLineLimit = 200 -private let secondsPerDay: Double = 86_400 -private let repositoriesLogger = SupaLogger("RepositoriesFeature") +let secondsPerDay: Double = 86_400 +let repositoriesLogger = SupaLogger("RepositoriesFeature") nonisolated struct WorktreeCreationProgressUpdateThrottle { private let stride: Int @@ -449,838 +449,7 @@ struct RepositoriesFeature { var body: some Reducer { CombineReducers { Reduce { state, action in - switch action { - case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement: - return .none - - case .activeAgents(.entryTapped(let id)): - guard let entry = state.activeAgents.entries[id: id] else { return .none } - if state.isShowingCanvas { - requestCanvasFocus(.tab(entry.tabID), openedWorktreeID: entry.worktreeID, state: &state) - return .run { _ in - _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) - } - } - let isPlainFolder = - state.repositories[id: entry.worktreeID]?.kind == .plain - if isPlainFolder { - state.pendingTerminalFocusWorktreeIDs.insert(entry.worktreeID) - } - return .run { send in - // Focus the target surface (which selects its tab) before making the - // terminal target visible, so it shows the right tab immediately instead - // of flashing its previously-focused tab. Plain folders are represented - // by their repository id, not a real worktree row, so they must select the - // repository rather than attempting a worktree selection. - _ = await terminalClient.focusSurface(entry.worktreeID, entry.surfaceID) - if isPlainFolder { - await send(.selectRepository(entry.worktreeID)) - } else { - await send(.selectWorktree(entry.worktreeID, focusTerminal: true)) - } - } - - case .activeAgents: - return .none - - case .task: - state.snapshotPersistencePhase = .restoring - return .run { send in - let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() - let archived = await repositoryPersistence.loadArchivedWorktrees() - let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() - let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() - let worktreeOrderByRepository = - await repositoryPersistence.loadWorktreeOrderByRepository() - let repositorySnapshot = await repositoryPersistence.loadRepositorySnapshot() - await send(.pinnedWorktreeIDsLoaded(pinned)) - await send(.archivedWorktreesLoaded(archived)) - await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) - await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) - await send(.lastFocusedWorktreeIDLoaded(lastFocused)) - await send(.repositorySnapshotLoaded(repositorySnapshot)) - await send(.loadPersistedRepositories) - } - - case .repositorySnapshotLoaded(let repositories): - guard let repositories, !repositories.isEmpty else { - return .none - } - state.isRefreshingWorktrees = false - let roots = repositories.map(\.rootURL) - let previousSelection = state.selectedWorktreeID - let previousSelectedWorktree = state.worktree(for: previousSelection) - let incomingRepositories = IdentifiedArray(uniqueElements: repositories) - let repositoriesChanged = incomingRepositories != state.repositories - _ = applyRepositories( - repositories, - roots: roots, - shouldPruneArchivedWorktrees: true, - state: &state, - animated: false - ) - state.repositoryRoots = roots - state.isInitialLoadComplete = true - state.loadFailuresByID = [:] - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) - let selectionChanged = selectionDidChange( - previousSelectionID: previousSelection, - previousSelectedWorktree: previousSelectedWorktree, - selectedWorktreeID: state.selectedWorktreeID, - selectedWorktree: selectedWorktree - ) - var allEffects: [Effect] = [] - if repositoriesChanged { - allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) - } - if selectionChanged { - allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) - } - return .merge(allEffects) - - case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): - state.pinnedWorktreeIDs = pinnedWorktreeIDs - return .none - - case .archivedWorktreesLoaded(let archivedWorktrees): - state.archivedWorktrees = archivedWorktrees - return .none - - case .setArchivedAutoDeletePeriod(let period): - state.archivedAutoDeletePeriod = period - guard period != nil else { return .none } - return .send(.autoDeleteExpiredArchivedWorktrees) - - case .autoDeleteExpiredArchivedWorktrees: - guard let period = state.archivedAutoDeletePeriod else { - return .none - } - let cutoff = now.addingTimeInterval(-Double(period.rawValue) * secondsPerDay) - let expiredEntries = state.archivedWorktrees.filter { $0.archivedAt <= cutoff } - guard !expiredEntries.isEmpty else { - return .none - } - @Shared(.settingsFile) var settingsFile - var deleteEffects: [Effect] = [] - for entry in expiredEntries { - guard - let (worktree, repository) = findWorktreeAndRepository( - worktreeID: entry.id, - state: state - ), - !state.isMainWorktree(worktree), - !state.deletingWorktreeIDs.contains(entry.id) - else { - continue - } - let shouldDeleteBranch = - settingsFile.global.deleteBranchOnDeleteWorktree - && state.prowlCreatedWorktreeIDs.contains(worktree.id) - deleteEffects.append( - .send( - .worktreeLifecycle( - .deleteWorktreeConfirmed( - worktree.id, - repository.id, - deleteBranch: shouldDeleteBranch - )) - ) - ) - } - guard !deleteEffects.isEmpty else { - return .none - } - return .merge(deleteEffects) - - case .repositoryOrderIDsLoaded(let repositoryOrderIDs): - state.repositoryOrderIDs = repositoryOrderIDs - return .none - - case .worktreeOrderByRepositoryLoaded(let worktreeOrderByRepository): - state.worktreeOrderByRepository = worktreeOrderByRepository - return .none - - case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): - state.lastFocusedWorktreeID = lastFocusedWorktreeID - if state.launchRestoreMode == .lastFocusedWorktree { - state.shouldRestoreLastFocusedWorktree = true - } - return .none - - case .setOpenPanelPresented(let isPresented): - state.isOpenPanelPresented = isPresented - return .none - - case .loadPersistedRepositories: - state.alert = nil - state.isRefreshingWorktrees = false - return .run { send in - let entries = await loadPersistedRepositoryEntries() - let roots = entries.map { URL(fileURLWithPath: $0.path) } - let (repositories, failures) = await loadRepositoriesData(entries) - await send( - .repositoriesLoaded( - repositories, - failures: failures, - roots: roots, - animated: false - ) - ) - } - .cancellable(id: CancelID.load, cancelInFlight: true) - - case .refreshWorktrees: - state.isRefreshingWorktrees = true - return .send(.reloadRepositories(animated: false)) - - case .reloadRepositories(let animated): - state.alert = nil - let roots = state.repositoryRoots - guard !roots.isEmpty else { - state.isRefreshingWorktrees = false - return .none - } - return loadRepositories(fallbackRoots: roots, animated: animated) - - case .repositoriesLoaded(let repositories, let failures, let roots, let animated): - state.isRefreshingWorktrees = false - let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring - if failures.isEmpty, state.snapshotPersistencePhase != .active { - state.snapshotPersistencePhase = .active - } - let previousSelection = state.selectedWorktreeID - let previousSelectedWorktree = state.worktree(for: previousSelection) - let incomingRepositories = IdentifiedArray(uniqueElements: repositories) - let repositoriesChanged = incomingRepositories != state.repositories - let applyResult = applyRepositories( - repositories, - roots: roots, - shouldPruneArchivedWorktrees: failures.isEmpty, - state: &state, - animated: animated - ) - state.repositoryRoots = roots - state.isInitialLoadComplete = true - state.loadFailuresByID = Dictionary( - uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } - ) - let selectedWorktree = state.worktree(for: state.selectedWorktreeID) - let selectionChanged = selectionDidChange( - previousSelectionID: previousSelection, - previousSelectedWorktree: previousSelectedWorktree, - selectedWorktreeID: state.selectedWorktreeID, - selectedWorktree: selectedWorktree - ) - var allEffects: [Effect] = [] - if repositoriesChanged || wasRestoringSnapshot { - allEffects.append(.send(.delegate(.repositoriesChanged(state.repositories)))) - } - if selectionChanged { - allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) - } - if applyResult.didPrunePinned { - let pinnedWorktreeIDs = state.pinnedWorktreeIDs - allEffects.append( - .run { _ in - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }) - } - if applyResult.didPruneRepositoryOrder { - let repositoryOrderIDs = state.repositoryOrderIDs - allEffects.append( - .run { _ in - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) - }) - } - if applyResult.didPruneWorktreeOrder { - let worktreeOrderByRepository = state.worktreeOrderByRepository - allEffects.append( - .run { _ in - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) - }) - } - if applyResult.didPruneArchivedWorktrees { - let archivedWorktrees = state.archivedWorktrees - allEffects.append( - .run { _ in - await repositoryPersistence.saveArchivedWorktrees(archivedWorktrees) - } - ) - } - if failures.isEmpty, !wasRestoringSnapshot { - let repositories = Array(state.repositories) - allEffects.append( - .run { _ in - await repositoryPersistence.saveRepositorySnapshot(repositories) - } - ) - } - if state.archivedAutoDeletePeriod != nil { - allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) - } - if repositoriesChanged, - let effect = detectCodeHostsEffect(for: state.repositories) - { - allEffects.append(effect) - } - return .merge(allEffects) - - case .refreshAllCustomTitles: - // Fan out across the current repository list, reading each - // per-repo settings file via `@Shared`. Runs in a reducer - // effect (not in a view body), so even when the first cache - // miss triggers a `settingsFile` write the resulting view - // re-render can't loop back into this action. - let repositoriesForTitleRefresh = Array(state.repositories) - return .run { send in - var dict: [Repository.ID: String] = [:] - for repository in repositoriesForTitleRefresh { - @Shared(.repositorySettings(repository.rootURL)) var settings - let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmed, !trimmed.isEmpty { - dict[repository.id] = trimmed - } - } - await send(.customTitlesLoaded(dict)) - } - - case .refreshCustomTitle(let rootURL): - guard let repository = state.repositories.first(where: { $0.rootURL == rootURL }) else { - return .none - } - let repositoryID = repository.id - return .run { send in - @Shared(.repositorySettings(rootURL)) var settings - let trimmed = settings.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = (trimmed?.isEmpty ?? true) ? nil : trimmed - await send(.customTitleUpdated(repositoryID, normalized)) - } - - case .customTitlesLoaded(let dict): - guard state.repositoryCustomTitles != dict else { return .none } - state.repositoryCustomTitles = dict - return .none - - case .customTitleUpdated(let id, let title): - if let title { - guard state.repositoryCustomTitles[id] != title else { return .none } - state.repositoryCustomTitles[id] = title - } else { - guard state.repositoryCustomTitles[id] != nil else { return .none } - state.repositoryCustomTitles.removeValue(forKey: id) - } - return .none - - case .codeHostsDetected(let codeHostByRepositoryID): - let knownIDs = Set(state.repositories.ids) - var updated = state.codeHostByRepositoryID.filter { knownIDs.contains($0.key) } - for (id, host) in codeHostByRepositoryID where knownIDs.contains(id) { - updated[id] = host - } - state.codeHostByRepositoryID = updated - return .none - - case .selectArchivedWorktrees: - state.isShelfActive = false - recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) - state.selection = .archivedWorktrees - state.sidebarSelectedWorktreeIDs = [] - return .send(.delegate(.selectedWorktreeChanged(nil))) - - case .selectCanvas: - // Remember the current worktree so toggleCanvas can restore it. - let canvasSeedWorktree = state.selectedTerminalWorktree - state.preCanvasWorktreeID = state.selectedWorktreeID - state.preCanvasTerminalTargetID = canvasSeedWorktree?.id - state.isShelfActive = false - state.selection = .canvas - state.sidebarSelectedWorktreeIDs = [] - // Canvas only renders cards for worktrees that already have a live - // terminal surface. Normal/Shelf get the previously-focused worktree - // opened lazily when its view mounts and calls `ensureInitialTab`; - // Canvas mounts no such per-worktree view, so launching straight into - // Canvas would show an empty board. Seed the surface for the worktree - // we're entering from so at least that card appears — matching the - // single-tab restore Normal and Shelf perform. `ensureInitialTab` is - // idempotent, so this no-ops once the worktree already has tabs. - return .run { _ in - if let canvasSeedWorktree { - await terminalClient.send( - .ensureInitialTab(canvasSeedWorktree, runSetupScriptIfNew: false, focusing: false) - ) - } - await terminalClient.send(.setCanvasMode(true)) - } - - case .selectShelf: - guard !state.isShelfActive else { return .none } - return .send(.toggleShelf) - - case .selectTabbed: - if state.isShowingCanvas { - return .send(.toggleCanvas) - } - if state.isShowingShelf { - return .send(.toggleShelf) - } - return .none - - case .setTopSegment(let segment): - switch segment { - case .tabbed: - return .send(.selectTabbed) - case .canvas: - return .send(.selectCanvas) - case .shelf: - return .send(.selectShelf) - } - - case .toggleCanvas: - if state.isShowingCanvas { - // Exit canvas: prefer the card focused in canvas, then the worktree - // we came from, then the first available worktree. - let targetID = - terminalClient.canvasFocusedWorktreeID() - ?? state.preCanvasTerminalTargetID - ?? state.preCanvasWorktreeID - ?? state.lastFocusedWorktreeID - ?? state.orderedWorktreeRows().first?.id - guard let targetID else { return .none } - if state.worktree(for: targetID) == nil, - let repository = state.repositories[id: targetID], - repository.kind == .plain - { - state.pendingTerminalFocusWorktreeIDs.insert(targetID) - return .send(.selectRepository(targetID)) - } - return .send(.selectWorktree(targetID, focusTerminal: true)) - } else { - // Enter canvas if there are any open worktrees. - guard !state.orderedWorktreeRows().isEmpty else { return .none } - return .send(.selectCanvas) - } - - case .selectNextShelfBook: - guard let book = shelfBook(atOffset: 1, state: state) else { return .none } - return shelfBookSelectionEffect(for: book) - - case .selectPreviousShelfBook: - guard let book = shelfBook(atOffset: -1, state: state) else { return .none } - return shelfBookSelectionEffect(for: book) - - case .selectShelfBook(let index): - let books = state.orderedShelfBooks() - let zeroBased = index - 1 - guard books.indices.contains(zeroBased) else { return .none } - return shelfBookSelectionEffect(for: books[zeroBased]) - - case .markWorktreeOpened(let worktreeID): - state.openedWorktreeIDs.insert(worktreeID) - return .none - - case .markWorktreeClosed(let worktreeID): - // Closing the last tab of a book retires the book from the - // Shelf. If this book was the one currently open on the - // Shelf, move focus to the neighboring book — the one after - // the closed book if there is one, otherwise the one before - // — so the user lands close to where they were instead of - // always snapping back to the first spine. - let replacement = replacementBookAfterClosing( - worktreeID: worktreeID, - state: state - ) - state.openedWorktreeIDs.remove(worktreeID) - if let replacement { - return shelfBookSelectionEffect(for: replacement) - } - return .none - - case .toggleShelf: - if state.isShelfActive { - state.isShelfActive = false - return .none - } - // Entering Shelf requires at least one book to render. - guard !state.orderedWorktreeRows().isEmpty else { return .none } - // Shelf is mutually exclusive with Canvas / archived views: when entering - // Shelf we need a worktree- or repository-scoped selection. - let needsRedirect: Bool - switch state.selection { - case .some(.worktree), .some(.repository): - needsRedirect = false - case .some(.canvas), .some(.archivedWorktrees), .none: - needsRedirect = true - } - state.isShelfActive = true - if !needsRedirect { - // The current selection is the open book — make sure it's - // registered as opened so the Shelf renders at least this - // spine. Guards the case where `selection` was set without - // going through `.selectWorktree` / `.selectRepository`. - // - // Also request terminal focus for this worktree so that - // `ShelfOpenBookView.onAppear` forces focus onto the - // surface (`forceAutoFocus: shouldFocusTerminal(for:)`). - // Without this, entering Shelf via keyboard shortcut - // leaves the first responder on the (now-dismissed) menu - // path, and `applySurfaceActivity`'s "only refocus if the - // current responder is a GhosttySurfaceView" guard skips - // the surface — user can't type until a second - // interaction (tab switch, etc.) forces focus through. - switch state.selection { - case .some(.worktree(let id)): - state.openedWorktreeIDs.insert(id) - state.pendingTerminalFocusWorktreeIDs.insert(id) - case .some(.repository(let id)) - where state.repositories[id: id]?.kind == .plain: - state.openedWorktreeIDs.insert(id) - state.pendingTerminalFocusWorktreeIDs.insert(id) - default: - break - } - return .none - } - // Same fallback chain as `toggleCanvas`'s exit path: prefer - // the card the user was actively focused on in Canvas so a - // Canvas → Shelf switch opens *that* card as the active book, - // not whatever was selected before Canvas was entered. - let targetID = - terminalClient.canvasFocusedWorktreeID() - ?? state.preCanvasTerminalTargetID - ?? state.preCanvasWorktreeID - ?? state.lastFocusedWorktreeID - ?? state.orderedWorktreeRows().first?.id - guard let targetID else { return .none } - if state.worktree(for: targetID) == nil, - let repository = state.repositories[id: targetID], - repository.kind == .plain - { - state.pendingTerminalFocusWorktreeIDs.insert(targetID) - return .send(.selectRepository(targetID)) - } - return .send(.selectWorktree(targetID, focusTerminal: true)) - - case .setSidebarSelectedWorktreeIDs(let worktreeIDs): - let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) - var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) - if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { - nextWorktreeIDs.insert(selectedWorktreeID) - } - state.sidebarSelectedWorktreeIDs = nextWorktreeIDs - return .none - - case .selectRepository(let repositoryID): - // `inout state` cannot be captured by a closure, so use the - // begin/end token API rather than the `interval` helper. - let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") - defer { repositoriesLogger.endInterval(selectRepoToken) } - guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } - recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) - state.selection = .repository(repositoryID) - state.sidebarSelectedWorktreeIDs = [] - if state.repositories[id: repositoryID]?.kind == .plain { - // Plain folder selection opens the folder as a Shelf book. - state.openedWorktreeIDs.insert(repositoryID) - } - return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) - - case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): - let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") - defer { repositoriesLogger.endInterval(selectWtToken) } - setSingleWorktreeSelection(worktreeID, state: &state, recordHistory: recordHistory) - if focusTerminal, let worktreeID { - state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) - } - if let worktreeID { - state.openedWorktreeIDs.insert(worktreeID) - } - let selectedWorktree = state.worktree(for: worktreeID) - return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) - - case .focusCanvasRepository(let repositoryID): - guard state.isShowingCanvas, - let worktree = state.canvasNavigationWorktree(forRepositoryID: repositoryID) - else { - return .none - } - requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) - return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) - } - - case .focusCanvasWorktree(let worktreeID): - guard state.isShowingCanvas, - let worktree = state.worktree(for: worktreeID) - else { - return .none - } - requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) - return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) - } - - case .selectNextWorktree: - // In Shelf, the vertical arrow pair maps to tab navigation - // within the open book — horizontal (← / →) is already book - // navigation, so the two axes match the Shelf layout. - if state.isShelfActive, let worktree = state.selectedTerminalWorktree { - return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: "next_tab")) - } - } - guard let id = state.worktreeID(byOffset: 1) else { return .none } - return .send(.selectWorktree(id)) - - case .selectPreviousWorktree: - if state.isShelfActive, let worktree = state.selectedTerminalWorktree { - return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) - } - } - guard let id = state.worktreeID(byOffset: -1) else { return .none } - return .send(.selectWorktree(id)) - - case .consumeCanvasFocusRequest(let id): - if state.pendingCanvasFocusRequest?.id == id { - state.pendingCanvasFocusRequest = nil - } - return .none - - case .requestCanvasCommand(let command): - state.nextCanvasCommandRequestID += 1 - state.pendingCanvasCommandRequest = CanvasCommandRequest( - id: state.nextCanvasCommandRequestID, - command: command - ) - return .none - - case .consumeCanvasCommandRequest(let id): - if state.pendingCanvasCommandRequest?.id == id { - state.pendingCanvasCommandRequest = nil - } - return .none - - case .worktreeHistoryBack: - return navigateWorktreeHistory(direction: .backward, state: &state) - - case .worktreeHistoryForward: - return navigateWorktreeHistory(direction: .forward, state: &state) - - case .revealSelectedWorktreeInSidebar: - guard let worktreeID = state.selectedWorktreeID, - let repositoryID = state.repositoryID(containing: worktreeID) - else { return .none } - state.$collapsedRepositoryIDs.withLock { - $0.removeAll { $0 == repositoryID } - } - state.nextPendingSidebarRevealID += 1 - state.pendingSidebarReveal = .init( - id: state.nextPendingSidebarRevealID, - worktreeID: worktreeID - ) - return .none - - case .consumePendingSidebarReveal(let revealID): - guard state.pendingSidebarReveal?.id == revealID else { return .none } - state.pendingSidebarReveal = nil - return .none - - case .requestRenameBranchPrompt(let worktreeID): - guard state.worktree(for: worktreeID) != nil else { return .none } - state.nextPendingRenameBranchRequestID += 1 - state.pendingRenameBranchRequest = .init( - id: state.nextPendingRenameBranchRequestID, - worktreeID: worktreeID - ) - return .none - - case .consumePendingRenameBranchRequest(let requestID): - guard state.pendingRenameBranchRequest?.id == requestID else { return .none } - state.pendingRenameBranchRequest = nil - return .none - - case .requestRenameBranch(let worktreeID, let branchName): - guard let worktree = state.worktree(for: worktreeID) else { return .none } - let trimmed = branchName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - state.alert = messageAlert( - title: "Branch name required", - message: "Enter a branch name to rename." - ) - return .none - } - guard !trimmed.contains(where: \.isWhitespace) else { - state.alert = messageAlert( - title: "Branch name invalid", - message: "Branch names can't contain spaces." - ) - return .none - } - if trimmed == worktree.name { - return .none - } - analyticsClient.capture("branch_renamed", nil) - return .run { send in - do { - try await gitClient.renameBranch(worktree.workingDirectory, trimmed) - await send(.reloadRepositories(animated: true)) - } catch { - await send( - .presentAlert( - title: "Unable to rename branch", - message: error.localizedDescription - ) - ) - } - } - - case .worktreeCreationPrompt(.presented(.delegate(.cancel))): - return .send(.worktreeCreation(.promptCanceled)) - - case .worktreeCreationPrompt( - .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) - ): - return .send( - .worktreeCreation( - .startPromptedWorktreeCreation( - repositoryID: repositoryID, - branchName: branchName, - baseRef: baseRef - ) - ) - ) - - case .worktreeCreationPrompt(.dismiss): - return .send(.worktreeCreation(.promptDismissed)) - - case .worktreeCreationPrompt: - return .none - - case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): - return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) - - case .alert(.presented(.confirmArchiveWorktrees(let targets))): - return .merge( - targets.map { target in - .send(.worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) - } - ) - - case .alert(.presented(.confirmForceDeleteBranch(let request))): - return .send(.worktreeLifecycle(.forceDeleteBranchConfirmed(request))) - - case .alert(.presented(.confirmRemoveRepository(let repositoryID))): - guard let repository = state.repositories[id: repositoryID] else { - return .none - } - if state.removingRepositoryIDs.contains(repository.id) { - return .none - } - state.alert = nil - state.removingRepositoryIDs.insert(repository.id) - let selectionWasRemoved = - state.selectedWorktreeID.map { id in - repository.worktrees.contains(where: { $0.id == id }) - } ?? false - return .send( - .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) - - case .presentAlert(let title, let message): - state.alert = messageAlert(title: title, message: message) - return .none - - case .showToast(let toast): - state.statusToast = toast - switch toast { - case .inProgress: - return .cancel(id: CancelID.toastAutoDismiss) - case .success, .warning: - return .run { send in - try? await ContinuousClock().sleep(for: .seconds(3)) - await send(.dismissToast) - } - .cancellable(id: CancelID.toastAutoDismiss, cancelInFlight: true) - } - - case .dismissToast: - state.statusToast = nil - return .none - - case .worktreeInfoEvent(let event): - switch event { - case .branchChanged(let worktreeID): - guard let worktree = state.worktree(for: worktreeID) else { - return .none - } - let worktreeURL = worktree.workingDirectory - let gitClient = gitClient - return .run { send in - if let name = await gitClient.branchName(worktreeURL) { - await send(.worktreeBranchNameLoaded(worktreeID: worktreeID, name: name)) - } - } - case .filesChanged(let worktreeID): - guard let worktree = state.worktree(for: worktreeID) else { - return .none - } - @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings - guard repositorySettings.observesLineDiffsAutomatically else { - return .none - } - let worktreeURL = worktree.workingDirectory - let gitClient = gitClient - let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) - return .run { send in - if let changes = await gitClient.lineChanges(worktreeURL) { - let nextLineChanges = normalizedLineChanges(added: changes.added, removed: changes.removed) - guard !lineChangesEqual(nextLineChanges, previousLineChanges) else { - return - } - await send( - .worktreeLineChangesLoaded( - worktreeID: worktreeID, - added: changes.added, - removed: changes.removed - ) - ) - } - } - case .repositoryWorktreesChanged: - return .send(.reloadRepositories(animated: true)) - case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): - return .send( - .githubIntegration( - .repositoryPullRequestRefreshRequested( - repositoryRootURL: repositoryRootURL, - worktreeIDs: worktreeIDs - ) - ) - ) - } - - case .worktreeBranchNameLoaded(let worktreeID, let name): - updateWorktreeName(worktreeID, name: name, state: &state) - return .none - - case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): - updateWorktreeLineChanges( - worktreeID: worktreeID, - added: added, - removed: removed, - state: &state - ) - return .none - - case .alert(.dismiss): - dismissCurrentForceDeleteBranchRequest(state: &state) - return .none - - case .alert: - return .none - - case .delegate: - return .none - } + reduceCore(state: &state, action: action) } worktreeCreationReducer @@ -1297,382 +466,6 @@ struct RepositoriesFeature { } } - func detectCodeHostsEffect(for repositories: IdentifiedArrayOf) -> Effect? { - let targets = - repositories - .filter { $0.capabilities.supportsCodeHost } - .map { (id: $0.id, rootURL: $0.rootURL) } - guard !targets.isEmpty else { return nil } - let gitClient = gitClient - return .run { send in - var detected: [Repository.ID: CodeHost] = [:] - await withTaskGroup(of: (Repository.ID, CodeHost).self) { group in - for target in targets { - group.addTask { - let host = await gitClient.repositoryWebURL(target.rootURL)?.host - return (target.id, CodeHost.from(host: host)) - } - } - for await (id, host) in group { - detected[id] = host - } - } - // `codeHost(for:)` defaults to `.unknown`, so storing `.unknown` - // explicitly is a no-op. Skip the round trip when nothing is known. - let meaningful = detected.filter { $0.value != .unknown } - guard !meaningful.isEmpty else { return } - await send(.codeHostsDetected(meaningful)) - } - } - - func loadPersistedRepositoryEntries( - fallbackRoots: [URL] = [] - ) async -> [PersistedRepositoryEntry] { - let entries = await repositoryPersistence.loadRepositoryEntries() - let resolvedEntries: [PersistedRepositoryEntry] - if !entries.isEmpty { - resolvedEntries = entries - } else { - let loadedPaths = await repositoryPersistence.loadRoots() - let pathSource = - if !loadedPaths.isEmpty { - loadedPaths - } else { - fallbackRoots.map { $0.path(percentEncoded: false) } - } - resolvedEntries = RepositoryEntryNormalizer.normalize( - pathSource.map { PersistedRepositoryEntry(path: $0, kind: .git) } - ) - } - return await upgradedRepositoryEntriesIfNeeded(resolvedEntries) - } - - func upgradedRepositoryEntriesIfNeeded( - _ entries: [PersistedRepositoryEntry] - ) async -> [PersistedRepositoryEntry] { - let upgradedEntries = await withTaskGroup(of: (Int, PersistedRepositoryEntry).self) { group in - for (index, entry) in entries.enumerated() { - let gitClient = self.gitClient - group.addTask { - let normalizedPath = URL(fileURLWithPath: entry.path) - .standardizedFileURL - .path(percentEncoded: false) - do { - let repoRoot = try await gitClient.repoRoot(URL(fileURLWithPath: normalizedPath)) - let normalizedRepoRoot = repoRoot.standardizedFileURL.path(percentEncoded: false) - switch entry.kind { - case .plain: - if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) - } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) - case .git: - if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) - } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) - } - } catch { - if entry.kind == .git, - Self.isNotGitRepositoryError(error), - FileManager.default.fileExists(atPath: normalizedPath) - { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) - } - } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: entry.kind)) - } - } - - var results = [PersistedRepositoryEntry?](repeating: nil, count: entries.count) - for await (index, entry) in group { - results[index] = entry - } - return results.compactMap { $0 } - } - - let normalizedEntries = RepositoryEntryNormalizer.normalize(upgradedEntries) - if normalizedEntries != entries { - await repositoryPersistence.saveRepositoryEntries(normalizedEntries) - } - return normalizedEntries - } - - nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { - guard case GitClientError.commandFailed(_, let message) = error else { - return false - } - return message.localizedCaseInsensitiveContains("not a git repository") - } - - nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { - let detail: String - if case GitClientError.commandFailed(_, let message) = error, - !message.isEmpty - { - detail = message - } else { - detail = error.localizedDescription - } - return "\(path): \(detail)" - } - - func loadRepositories( - fallbackRoots: [URL] = [], - animated: Bool = false - ) -> Effect { - let gitClient = gitClient - return .run { [animated, fallbackRoots] send in - let entries = await loadPersistedRepositoryEntries(fallbackRoots: fallbackRoots) - let roots = entries.map { URL(fileURLWithPath: $0.path) } - for entry in entries where entry.kind == .git { - _ = try? await gitClient.pruneWorktrees(URL(fileURLWithPath: entry.path)) - } - let (repositories, failures) = await loadRepositoriesData(entries) - await send( - .repositoriesLoaded( - repositories, - failures: failures, - roots: roots, - animated: animated - ) - ) - } - .cancellable(id: CancelID.load, cancelInFlight: true) - } - - private struct WorktreesFetchResult: Sendable { - let entry: PersistedRepositoryEntry - let repository: Repository? - let errorMessage: String? - } - - func loadRepositoriesData(_ entries: [PersistedRepositoryEntry]) async -> ([Repository], [LoadFailure]) { - let fetchResults = await withTaskGroup(of: WorktreesFetchResult.self) { group in - for entry in entries { - let gitClient = self.gitClient - group.addTask { - let rootURL = URL(fileURLWithPath: entry.path).standardizedFileURL - switch entry.kind { - case .git: - do { - let worktrees = try await gitClient.worktrees(rootURL) - return WorktreesFetchResult( - entry: entry, - repository: Repository( - id: rootURL.path(percentEncoded: false), - rootURL: rootURL, - name: Repository.name(for: rootURL), - kind: .git, - worktrees: IdentifiedArray(worktrees, uniquingIDsWith: { current, _ in current }) - ), - errorMessage: nil - ) - } catch { - return WorktreesFetchResult( - entry: entry, - repository: nil, - errorMessage: error.localizedDescription - ) - } - case .plain: - return WorktreesFetchResult( - entry: entry, - repository: Repository( - id: rootURL.path(percentEncoded: false), - rootURL: rootURL, - name: Repository.name(for: rootURL), - kind: .plain, - worktrees: IdentifiedArray() - ), - errorMessage: nil - ) - } - } - } - - var resultsByRootID: [Repository.ID: WorktreesFetchResult] = [:] - for await result in group { - let rootID = URL(fileURLWithPath: result.entry.path).standardizedFileURL.path(percentEncoded: false) - resultsByRootID[rootID] = result - } - return resultsByRootID - } - - var loaded: [Repository] = [] - var failures: [LoadFailure] = [] - for entry in entries { - let normalizedRoot = URL(fileURLWithPath: entry.path).standardizedFileURL - let rootID = normalizedRoot.path(percentEncoded: false) - guard let result = fetchResults[rootID] else { continue } - if let repository = result.repository { - loaded.append(repository) - } else { - failures.append( - LoadFailure( - rootID: rootID, - message: result.errorMessage ?? "Unknown error" - ) - ) - } - } - return (loaded, failures) - } - - func applyRepositories( - _ repositories: [Repository], - roots: [URL], - shouldPruneArchivedWorktrees: Bool, - state: inout State, - animated: Bool - ) -> ApplyRepositoriesResult { - let previousCounts = Dictionary( - uniqueKeysWithValues: state.repositories.map { ($0.id, $0.worktrees.count) } - ) - let repositoryIDs = Set(repositories.map(\.id)) - let newCounts = Dictionary( - uniqueKeysWithValues: repositories.map { ($0.id, $0.worktrees.count) } - ) - var addedCounts: [Repository.ID: Int] = [:] - for (id, newCount) in newCounts { - let oldCount = previousCounts[id] ?? 0 - let added = newCount - oldCount - if added > 0 { - addedCounts[id] = added - } - } - let filteredPendingWorktrees = state.pendingWorktrees.filter { pending in - guard repositoryIDs.contains(pending.repositoryID) else { return false } - guard let remaining = addedCounts[pending.repositoryID], remaining > 0 else { return true } - addedCounts[pending.repositoryID] = remaining - 1 - return false - } - let availableWorktreeIDs = Set(repositories.flatMap { $0.worktrees.map(\.id) }) - let filteredDeletingIDs = state.deletingWorktreeIDs.intersection(availableWorktreeIDs) - let filteredSetupScriptIDs = state.pendingSetupScriptWorktreeIDs.filter { - availableWorktreeIDs.contains($0) - } - let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { - availableWorktreeIDs.contains($0) - } - let filteredArchivingIDs = state.archivingWorktreeIDs - let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { - availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) - } - let filteredWorktreeInfo = state.worktreeInfoByID.filter { - availableWorktreeIDs.contains($0.key) - } - state.$prowlCreatedWorktreeIDs.withLock { - $0.removeAll { !availableWorktreeIDs.contains($0) } - } - let identifiedRepositories = IdentifiedArray(uniqueElements: repositories) - if animated { - withAnimation { - state.repositories = identifiedRepositories - state.pendingWorktrees = filteredPendingWorktrees - state.deletingWorktreeIDs = filteredDeletingIDs - state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs - state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs - state.archivingWorktreeIDs = filteredArchivingIDs - state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress - state.worktreeInfoByID = filteredWorktreeInfo - } - } else { - state.repositories = identifiedRepositories - state.pendingWorktrees = filteredPendingWorktrees - state.deletingWorktreeIDs = filteredDeletingIDs - state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs - state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs - state.archivingWorktreeIDs = filteredArchivingIDs - state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress - state.worktreeInfoByID = filteredWorktreeInfo - } - let didPrunePinned = prunePinnedWorktreeIDs(state: &state) - let didPruneRepositoryOrder = pruneRepositoryOrderIDs(roots: roots, state: &state) - let didPruneWorktreeOrder = pruneWorktreeOrderByRepository(roots: roots, state: &state) - let didPruneArchivedWorktrees = - shouldPruneArchivedWorktrees - ? pruneArchivedWorktrees(availableWorktreeIDs: availableWorktreeIDs, state: &state) - : false - if !state.isShowingArchivedWorktrees, !state.isShowingCanvas, - !isSidebarSelectionValid(state.selection, state: state) - { - state.selection = nil - } - if state.shouldRestoreLastFocusedWorktree { - state.shouldRestoreLastFocusedWorktree = false - if state.selection == nil, - isSelectionValid(state.lastFocusedWorktreeID, state: state) - { - state.selection = state.lastFocusedWorktreeID.map(SidebarSelection.worktree) - } - } - if state.selection == nil, state.shouldSelectFirstAfterReload { - state.selection = firstAvailableWorktreeID(from: repositories, state: state) - .map(SidebarSelection.worktree) - state.shouldSelectFirstAfterReload = false - } - return ApplyRepositoriesResult( - didPrunePinned: didPrunePinned, - didPruneRepositoryOrder: didPruneRepositoryOrder, - didPruneWorktreeOrder: didPruneWorktreeOrder, - didPruneArchivedWorktrees: didPruneArchivedWorktrees - ) - } - - func messageAlert(title: String, message: String) -> AlertState { - AlertState { - TextState(title) - } actions: { - ButtonState(role: .cancel) { - TextState("OK") - } - } message: { - TextState(message) - } - } - - func confirmationAlertForRepositoryRemoval( - repositoryID: Repository.ID, - state: State - ) -> AlertState? { - guard let repository = state.repositories[id: repositoryID] else { - return nil - } - return AlertState { - TextState("Remove repository?") - } actions: { - ButtonState(role: .destructive, action: .confirmRemoveRepository(repository.id)) { - TextState("Remove repository") - } - ButtonState(role: .cancel) { - TextState("Cancel") - } - } message: { - TextState( - "This removes the repository from Prowl. " - + "Worktrees and the main repository folder stay on disk." - ) - } - } - - func selectionDidChange( - previousSelectionID: Worktree.ID?, - previousSelectedWorktree: Worktree?, - selectedWorktreeID: Worktree.ID?, - selectedWorktree: Worktree? - ) -> Bool { - if previousSelectionID != selectedWorktreeID { - return true - } - if previousSelectedWorktree?.workingDirectory != selectedWorktree?.workingDirectory { - return true - } - if previousSelectedWorktree?.repositoryRootURL != selectedWorktree?.repositoryRootURL { - return true - } - return false - } } // Sub-reducers are in separate files: @@ -1681,1250 +474,3 @@ struct RepositoriesFeature { // - RepositoriesFeature+WorktreeOrdering.swift // - RepositoriesFeature+GithubIntegration.swift // - RepositoriesFeature+RepositoryManagement.swift - -extension RepositoriesFeature.State { - var selectedWorktreeID: Worktree.ID? { - selection?.worktreeID - } - - var canNavigateWorktreeHistoryBackward: Bool { - guard canUseWorktreeHistory else { return false } - return canNavigateWorktreeHistory(stack: worktreeHistoryBackStack) - } - - var canNavigateWorktreeHistoryForward: Bool { - guard canUseWorktreeHistory else { return false } - return canNavigateWorktreeHistory(stack: worktreeHistoryForwardStack) - } - - var selectedRepositoryID: Repository.ID? { - guard case .repository(let repositoryID) = selection else { return nil } - return repositoryID - } - - var selectedRepository: Repository? { - guard let selectedRepositoryID else { return nil } - return repositories[id: selectedRepositoryID] - } - - var selectedTerminalWorktree: Worktree? { - if let selectedWorktreeID { - return worktree(for: selectedWorktreeID) - } - guard let selectedRepository, - selectedRepository.capabilities.supportsRunnableFolderActions, - !selectedRepository.capabilities.supportsWorktrees - else { - return nil - } - return Worktree( - id: selectedRepository.id, - name: selectedRepository.name, - detail: selectedRepository.rootURL.path(percentEncoded: false), - workingDirectory: selectedRepository.rootURL, - repositoryRootURL: selectedRepository.rootURL - ) - } - - var terminalStateIDs: Set { - Set( - repositories.flatMap { repository -> [Worktree.ID] in - if repository.capabilities.supportsWorktrees { - repository.worktrees.map(\.id) - } else if repository.capabilities.supportsRunnableFolderActions { - [repository.id] - } else { - [] - } - } - ) - } - - var expandedRepositoryIDs: Set { - let repositoryIDs = Set(repositories.map(\.id)) - let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) - let pendingRepositoryIDs = Set(pendingWorktrees.map(\.repositoryID)) - return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) - } - - func worktreeID(byOffset offset: Int) -> Worktree.ID? { - let rows = orderedWorktreeRows(includingRepositoryIDs: expandedRepositoryIDs) - guard !rows.isEmpty else { return nil } - if let currentID = selectedWorktreeID, - let currentIndex = rows.firstIndex(where: { $0.id == currentID }) - { - return rows[(currentIndex + offset + rows.count) % rows.count].id - } - return rows[offset > 0 ? 0 : rows.count - 1].id - } - - var isShowingArchivedWorktrees: Bool { - selection == .archivedWorktrees - } - - var isShowingCanvas: Bool { - selection == .canvas - } - - var isShowingShelf: Bool { - // Shelf needs at least one repository to render. Guarding here (not just - // on entry) also covers the launch race where the repository snapshot - // briefly repopulates books and flips `isShelfActive` on before the empty - // entries file reconciles repos back to zero — without this a zero-repo - // launch with "Default View = Shelf" would stick on an empty Shelf instead - // of falling back to Normal. - isShelfActive && !repositories.isEmpty - } - - var topSegment: TopSegment { - if isShowingCanvas { return .canvas } - if isShowingShelf { return .shelf } - return .tabbed - } - - private var canUseWorktreeHistory: Bool { - // History navigation only makes sense when a worktree is the current - // anchor. With selection on `.repository`, `.archivedWorktrees`, or `nil`, - // there is no "current" position to step away from, so the menu items - // (and their shortcuts) must be disabled. - !isShowingShelf && !isShowingCanvas && selectedWorktreeID != nil - } - - var archivedWorktreeIDSet: Set { - Set(archivedWorktrees.map(\.id)) - } - - func isWorktreeArchived(_ id: Worktree.ID) -> Bool { - archivedWorktreeIDSet.contains(id) - } - - private func canNavigateWorktreeHistory(stack: [Worktree.ID]) -> Bool { - stack.reversed().contains { id in - id != selectedWorktreeID && isSelectionValid(id, state: self) - } - } - - func worktreeInfo(for worktreeID: Worktree.ID) -> WorktreeInfoEntry? { - worktreeInfoByID[worktreeID] - } - - func codeHost(for repositoryID: Repository.ID) -> CodeHost { - codeHostByRepositoryID[repositoryID] ?? .unknown - } - - func codeHost(forWorktreeID worktreeID: Worktree.ID?) -> CodeHost { - guard let worktreeID, let repositoryID = repositoryID(containing: worktreeID) else { - return .unknown - } - return codeHost(for: repositoryID) - } - - func worktreesForInfoWatcher() -> [Worktree] { - let worktrees = repositories.flatMap(\.worktrees) - guard !isShowingArchivedWorktrees else { - return worktrees - } - let archivedSet = archivedWorktreeIDSet - return worktrees.filter { !archivedSet.contains($0.id) } - } - - struct ArchivedWorktreeGroup: Equatable { - var repository: Repository - var worktrees: [Worktree] - } - - func archivedWorktreesByRepository() -> [ArchivedWorktreeGroup] { - let archivedSet = archivedWorktreeIDSet - var groups: [ArchivedWorktreeGroup] = [] - for repository in repositories { - let worktrees = Array(repository.worktrees.filter { archivedSet.contains($0.id) }) - if !worktrees.isEmpty { - groups.append(ArchivedWorktreeGroup(repository: repository, worktrees: worktrees)) - } - } - return groups - } - - var canCreateWorktree: Bool { - if repositories.isEmpty { - return false - } - if let repository = repositoryForWorktreeCreation(self) { - return !removingRepositoryIDs.contains(repository.id) - } - return false - } - - func worktree(for id: Worktree.ID?) -> Worktree? { - guard let id else { return nil } - for repository in repositories { - if let worktree = repository.worktrees[id: id] { - return worktree - } - } - return nil - } - - func canvasNavigationWorktree(forRepositoryID repositoryID: Repository.ID) -> Worktree? { - guard let repository = repositories[id: repositoryID] else { return nil } - if repository.capabilities.supportsWorktrees { - return worktreeRows(in: repository) - .compactMap { worktree(for: $0.id) } - .first - } - guard repository.capabilities.supportsRunnableFolderActions else { return nil } - return Worktree( - id: repository.id, - name: repository.name, - detail: repository.rootURL.path(percentEncoded: false), - workingDirectory: repository.rootURL, - repositoryRootURL: repository.rootURL - ) - } - - func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { - guard let id else { return nil } - return pendingWorktrees.first(where: { $0.id == id }) - } - - func archiveScriptProgress(for id: Worktree.ID?) -> ArchiveScriptProgress? { - guard let id else { return nil } - return archiveScriptProgressByWorktreeID[id] - } - - func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { - pendingTerminalFocusWorktreeIDs.contains(worktreeID) - } - - private func makePendingWorktreeRow(_ pending: PendingWorktree) -> WorktreeRowModel { - let isDeleting = removingRepositoryIDs.contains(pending.repositoryID) - return WorktreeRowModel( - id: pending.id, - repositoryID: pending.repositoryID, - name: pending.progress.titleText, - detail: pending.progress.detailText, - info: worktreeInfo(for: pending.id), - isPinned: false, - isMainWorktree: false, - isPending: true, - isArchiving: false, - isDeleting: isDeleting, - isRemovable: false - ) - } - - private func makeWorktreeRow( - _ worktree: Worktree, - repositoryID: Repository.ID, - isPinned: Bool, - isMainWorktree: Bool - ) -> WorktreeRowModel { - let isDeleting = - removingRepositoryIDs.contains(repositoryID) - || deletingWorktreeIDs.contains(worktree.id) - let isArchiving = archivingWorktreeIDs.contains(worktree.id) - return WorktreeRowModel( - id: worktree.id, - repositoryID: repositoryID, - name: worktree.name, - detail: worktree.detail, - info: worktreeInfo(for: worktree.id), - isPinned: isPinned, - isMainWorktree: isMainWorktree, - isPending: false, - isArchiving: isArchiving, - isDeleting: isDeleting, - isRemovable: !isDeleting && !isArchiving - ) - } - - func selectedRow(for id: Worktree.ID?) -> WorktreeRowModel? { - guard let id else { return nil } - if isWorktreeArchived(id) { - return nil - } - if let pending = pendingWorktree(for: id) { - return makePendingWorktreeRow(pending) - } - for repository in repositories { - if let worktree = repository.worktrees[id: id] { - return makeWorktreeRow( - worktree, - repositoryID: repository.id, - isPinned: pinnedWorktreeIDs.contains(worktree.id), - isMainWorktree: isMainWorktree(worktree) - ) - } - } - return nil - } - - func repositoryName(for id: Repository.ID) -> String? { - repositories[id: id]?.name - } - - func orderedRepositoryRoots() -> [URL] { - let rootsByID = Dictionary( - uniqueKeysWithValues: repositoryRoots.map { - ($0.standardizedFileURL.path(percentEncoded: false), $0.standardizedFileURL) - } - ) - var ordered: [URL] = [] - var seen: Set = [] - for id in repositoryOrderIDs { - if let rootURL = rootsByID[id], seen.insert(id).inserted { - ordered.append(rootURL) - } - } - for rootURL in repositoryRoots { - let id = rootURL.standardizedFileURL.path(percentEncoded: false) - if seen.insert(id).inserted { - ordered.append(rootURL.standardizedFileURL) - } - } - if ordered.isEmpty { - ordered = repositories.map(\.rootURL) - } - return ordered - } - - func orderedRepositoryIDs() -> [Repository.ID] { - orderedRepositoryRoots().map { $0.standardizedFileURL.path(percentEncoded: false) } - } - - func repositoryID(for worktreeID: Worktree.ID?) -> Repository.ID? { - selectedRow(for: worktreeID)?.repositoryID - } - - func repositoryID(containing worktreeID: Worktree.ID) -> Repository.ID? { - for repository in repositories where repository.worktrees[id: worktreeID] != nil { - return repository.id - } - return nil - } - - func isMainWorktree(_ worktree: Worktree) -> Bool { - worktree.isMain - } - - func isWorktreeMerged(_ worktree: Worktree) -> Bool { - worktreeInfoByID[worktree.id]?.pullRequest?.state == "MERGED" - } - - func orderedPinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { - let archivedSet = archivedWorktreeIDSet - return pinnedWorktreeIDs.filter { id in - if archivedSet.contains(id) { - return false - } - if let worktree = repository.worktrees[id: id] { - return !isMainWorktree(worktree) - } - return false - } - } - - func orderedPinnedWorktrees(in repository: Repository) -> [Worktree] { - orderedPinnedWorktreeIDs(in: repository).compactMap { repository.worktrees[id: $0] } - } - - func replacingPinnedWorktreeIDs( - in repository: Repository, - with reordered: [Worktree.ID] - ) -> [Worktree.ID] { - let repoPinnedIDs = Set(orderedPinnedWorktreeIDs(in: repository)) - var iterator = reordered.makeIterator() - return pinnedWorktreeIDs.map { id in - if repoPinnedIDs.contains(id) { - return iterator.next() ?? id - } - return id - } - } - - func orderedUnpinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { - let mainID = repository.worktrees.first(where: { isMainWorktree($0) })?.id - let pinnedSet = Set(pinnedWorktreeIDs) - let archivedSet = archivedWorktreeIDSet - let available = repository.worktrees.filter { worktree in - worktree.id != mainID - && !pinnedSet.contains(worktree.id) - && !archivedSet.contains(worktree.id) - } - let orderedIDs = worktreeOrderByRepository[repository.id] ?? [] - let availableIDs = Set(available.map(\.id)) - let orderedIDSet = Set(orderedIDs) - var seen: Set = [] - var missing: [Worktree.ID] = [] - for worktree in available where !orderedIDSet.contains(worktree.id) { - if seen.insert(worktree.id).inserted { - missing.append(worktree.id) - } - } - var ordered: [Worktree.ID] = [] - for id in orderedIDs { - if availableIDs.contains(id), - seen.insert(id).inserted - { - ordered.append(id) - } - } - return missing + ordered - } - - func orderedUnpinnedWorktrees(in repository: Repository) -> [Worktree] { - orderedUnpinnedWorktreeIDs(in: repository).compactMap { repository.worktrees[id: $0] } - } - - func orderedWorktrees(in repository: Repository) -> [Worktree] { - var ordered: [Worktree] = [] - if let mainWorktree = repository.worktrees.first(where: { isMainWorktree($0) }) { - if !isWorktreeArchived(mainWorktree.id) { - ordered.append(mainWorktree) - } - } - ordered.append(contentsOf: orderedPinnedWorktrees(in: repository)) - ordered.append(contentsOf: orderedUnpinnedWorktrees(in: repository)) - return ordered - } - - func isWorktreePinned(_ worktree: Worktree) -> Bool { - pinnedWorktreeIDs.contains(worktree.id) - } - - var confirmWorktreeAlert: RepositoriesFeature.Alert? { - guard let alert else { return nil } - for button in alert.buttons { - if case .confirmArchiveWorktree(let worktreeID, let repositoryID)? = button.action.action { - return .confirmArchiveWorktree(worktreeID, repositoryID) - } - if case .confirmArchiveWorktrees(let targets)? = button.action.action { - return .confirmArchiveWorktrees(targets) - } - } - return nil - } - - func isRemovingRepository(_ repository: Repository) -> Bool { - removingRepositoryIDs.contains(repository.id) - } - - func worktreeRowSections(in repository: Repository) -> WorktreeRowSections { - let mainWorktree = repository.worktrees.first(where: { isMainWorktree($0) }) - let pinnedWorktrees = orderedPinnedWorktrees(in: repository) - let unpinnedWorktrees = orderedUnpinnedWorktrees(in: repository) - let pendingEntries = pendingWorktrees.filter { $0.repositoryID == repository.id } - let mainRow: WorktreeRowModel? = - if let mainWorktree, !isWorktreeArchived(mainWorktree.id) { - makeWorktreeRow( - mainWorktree, - repositoryID: repository.id, - isPinned: false, - isMainWorktree: true - ) - } else { - nil - } - var pinnedRows: [WorktreeRowModel] = [] - for worktree in pinnedWorktrees { - pinnedRows.append( - makeWorktreeRow( - worktree, - repositoryID: repository.id, - isPinned: true, - isMainWorktree: false - ) - ) - } - var pendingRows: [WorktreeRowModel] = [] - for pending in pendingEntries { - pendingRows.append(makePendingWorktreeRow(pending)) - } - var unpinnedRows: [WorktreeRowModel] = [] - for worktree in unpinnedWorktrees { - unpinnedRows.append( - makeWorktreeRow( - worktree, - repositoryID: repository.id, - isPinned: false, - isMainWorktree: false - ) - ) - } - return WorktreeRowSections( - main: mainRow, - pinned: pinnedRows, - pending: pendingRows, - unpinned: unpinnedRows - ) - } - - func worktreeRows(in repository: Repository) -> [WorktreeRowModel] { - let sections = worktreeRowSections(in: repository) - return sections.allRows - } - - func orderedWorktreeRows() -> [WorktreeRowModel] { - orderedWorktreeRows(includingRepositoryIDs: Set(repositories.map(\.id))) - } - - func orderedWorktreeRows(includingRepositoryIDs: Set) -> [WorktreeRowModel] { - let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) - return orderedRepositoryIDs() - .filter { includingRepositoryIDs.contains($0) } - .compactMap { repositoriesByID[$0] } - .flatMap { worktreeRows(in: $0) } - } -} - -struct WorktreeRowSections: Equatable { - let main: WorktreeRowModel? - let pinned: [WorktreeRowModel] - let pending: [WorktreeRowModel] - let unpinned: [WorktreeRowModel] - - var allRows: [WorktreeRowModel] { - var rows: [WorktreeRowModel] = [] - if let main { - rows.append(main) - } - rows.append(contentsOf: pinned) - rows.append(contentsOf: pending) - rows.append(contentsOf: unpinned) - return rows - } -} - -struct FailedWorktreeCleanup { - let didRemoveWorktree: Bool - let didUpdatePinned: Bool - let didUpdateOrder: Bool - let worktree: Worktree? -} - -func removePendingWorktree(_ id: String, state: inout RepositoriesFeature.State) { - state.pendingWorktrees.removeAll { $0.id == id } -} - -func requestCanvasFocus( - _ target: CanvasFocusRequest.Target, - openedWorktreeID: Worktree.ID, - state: inout RepositoriesFeature.State -) { - state.nextCanvasFocusRequestID += 1 - state.pendingCanvasFocusRequest = CanvasFocusRequest( - id: state.nextCanvasFocusRequestID, - target: target - ) - state.openedWorktreeIDs.insert(openedWorktreeID) -} - -func updatePendingWorktreeProgress( - _ id: String, - progress: WorktreeCreationProgress, - state: inout RepositoriesFeature.State -) { - guard let index = state.pendingWorktrees.firstIndex(where: { $0.id == id }) else { - return - } - state.pendingWorktrees[index].progress = progress -} - -func insertWorktree( - _ worktree: Worktree, - repositoryID: Repository.ID, - state: inout RepositoriesFeature.State -) { - guard let index = state.repositories.index(id: repositoryID) else { return } - let repository = state.repositories[index] - if repository.worktrees[id: worktree.id] != nil { - return - } - var worktrees = repository.worktrees - worktrees.insert(worktree, at: 0) - state.repositories[index] = Repository( - id: repository.id, - rootURL: repository.rootURL, - name: repository.name, - worktrees: worktrees - ) -} - -@discardableResult -func removeWorktree( - _ worktreeID: Worktree.ID, - repositoryID: Repository.ID, - state: inout RepositoriesFeature.State -) -> Bool { - guard let index = state.repositories.index(id: repositoryID) else { return false } - let repository = state.repositories[index] - guard repository.worktrees[id: worktreeID] != nil else { return false } - var worktrees = repository.worktrees - worktrees.remove(id: worktreeID) - state.repositories[index] = Repository( - id: repository.id, - rootURL: repository.rootURL, - name: repository.name, - worktrees: worktrees - ) - return true -} - -func cleanupFailedWorktree( - repositoryID: Repository.ID, - name: String?, - baseDirectory: URL, - state: inout RepositoriesFeature.State -) -> FailedWorktreeCleanup { - guard let name, !name.isEmpty else { - return FailedWorktreeCleanup( - didRemoveWorktree: false, - didUpdatePinned: false, - didUpdateOrder: false, - worktree: nil - ) - } - let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL - let normalizedBaseDirectory = baseDirectory.standardizedFileURL - let worktreeURL = - normalizedBaseDirectory - .appending(path: name, directoryHint: .isDirectory) - .standardizedFileURL - guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: normalizedBaseDirectory) else { - return FailedWorktreeCleanup( - didRemoveWorktree: false, - didUpdatePinned: false, - didUpdateOrder: false, - worktree: nil - ) - } - let worktreeID = worktreeURL.path(percentEncoded: false) - let worktree = - state.repositories[id: repositoryID]?.worktrees[id: worktreeID] - ?? Worktree( - id: worktreeID, - name: name, - detail: "", - workingDirectory: worktreeURL, - repositoryRootURL: repositoryRootURL - ) - let cleanup = cleanupWorktreeState( - worktreeID, - repositoryID: repositoryID, - state: &state - ) - return FailedWorktreeCleanup( - didRemoveWorktree: cleanup.didRemoveWorktree, - didUpdatePinned: cleanup.didUpdatePinned, - didUpdateOrder: cleanup.didUpdateOrder, - worktree: worktree - ) -} - -func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { - PathPolicy.contains(path, in: baseDirectory) -} - -struct WorktreeCleanupStateResult { - let didRemoveWorktree: Bool - let didUpdatePinned: Bool - let didUpdateOrder: Bool -} - -func cleanupWorktreeState( - _ worktreeID: Worktree.ID, - repositoryID: Repository.ID, - state: inout RepositoriesFeature.State -) -> WorktreeCleanupStateResult { - let didRemoveWorktree = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) - state.pendingWorktrees.removeAll { $0.id == worktreeID } - state.pendingSetupScriptWorktreeIDs.remove(worktreeID) - state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) - state.archivingWorktreeIDs.remove(worktreeID) - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) - state.deletingWorktreeIDs.remove(worktreeID) - state.worktreeInfoByID.removeValue(forKey: worktreeID) - let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) - if didUpdatePinned { - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } - } - var didUpdateOrder = false - if var order = state.worktreeOrderByRepository[repositoryID] { - let countBefore = order.count - order.removeAll { $0 == worktreeID } - if order.count != countBefore { - didUpdateOrder = true - if order.isEmpty { - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) - } else { - state.worktreeOrderByRepository[repositoryID] = order - } - } - } - return WorktreeCleanupStateResult( - didRemoveWorktree: didRemoveWorktree, - didUpdatePinned: didUpdatePinned, - didUpdateOrder: didUpdateOrder - ) -} - -nonisolated func archiveScriptCommand(_ script: String) -> String { - let normalized = script.replacing("\n", with: "\\n") - return "bash -lc \(shellQuote(normalized))" -} - -nonisolated func worktreeCreateCommand( - baseDirectoryURL: URL, - name: String, - copyIgnored: Bool, - copyUntracked: Bool, - baseRef: String -) -> String { - let baseDir = baseDirectoryURL.path(percentEncoded: false) - var parts = ["wt", "--base-dir", baseDir, "sw"] - if copyIgnored { - parts.append("--copy-ignored") - } - if copyUntracked { - parts.append("--copy-untracked") - } - if !baseRef.isEmpty { - parts.append("--from") - parts.append(baseRef) - } - if copyIgnored || copyUntracked { - parts.append("--verbose") - } - parts.append(name) - return parts.map(shellQuote).joined(separator: " ") -} - -nonisolated func shellQuote(_ value: String) -> String { - "'\(value.replacing("'", with: "'\"'\"'"))'" -} - -private func updateWorktreeName( - _ worktreeID: Worktree.ID, - name: String, - state: inout RepositoriesFeature.State -) { - for index in state.repositories.indices { - var repository = state.repositories[index] - guard let worktreeIndex = repository.worktrees.index(id: worktreeID) else { - continue - } - let worktree = repository.worktrees[worktreeIndex] - guard worktree.name != name else { - return - } - var worktrees = repository.worktrees - worktrees[id: worktreeID] = Worktree( - id: worktree.id, - name: name, - detail: worktree.detail, - workingDirectory: worktree.workingDirectory, - repositoryRootURL: worktree.repositoryRootURL, - createdAt: worktree.createdAt - ) - repository = Repository( - id: repository.id, - rootURL: repository.rootURL, - name: repository.name, - worktrees: worktrees - ) - state.repositories[index] = repository - return - } -} - -@discardableResult -func updateWorktreeLineChanges( - worktreeID: Worktree.ID, - added: Int, - removed: Int, - state: inout RepositoriesFeature.State -) -> Bool { - var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() - if added == 0 && removed == 0 { - entry.addedLines = nil - entry.removedLines = nil - } else { - entry.addedLines = added - entry.removedLines = removed - } - let previousEntry = state.worktreeInfoByID[worktreeID] - if entry.isEmpty { - guard previousEntry != nil else { - return false - } - state.worktreeInfoByID.removeValue(forKey: worktreeID) - return true - } - guard previousEntry != entry else { - return false - } - state.worktreeInfoByID[worktreeID] = entry - return true -} - -func updateWorktreePullRequest( - worktreeID: Worktree.ID, - pullRequest: GithubPullRequest?, - state: inout RepositoriesFeature.State -) { - var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() - entry.pullRequest = pullRequest - if entry.isEmpty { - state.worktreeInfoByID.removeValue(forKey: worktreeID) - } else { - state.worktreeInfoByID[worktreeID] = entry - } -} - -nonisolated private func normalizedLineChanges(_ entry: WorktreeInfoEntry?) -> (added: Int, removed: Int)? { - guard let added = entry?.addedLines, let removed = entry?.removedLines else { - return nil - } - return normalizedLineChanges(added: added, removed: removed) -} - -nonisolated private func normalizedLineChanges( - added: Int, - removed: Int -) -> (added: Int, removed: Int)? { - guard added != 0 || removed != 0 else { - return nil - } - return (added, removed) -} - -nonisolated private func lineChangesEqual( - _ lhs: (added: Int, removed: Int)?, - _ rhs: (added: Int, removed: Int)? -) -> Bool { - switch (lhs, rhs) { - case (nil, nil): - return true - case (.some(let lhs), .some(let rhs)): - return lhs.added == rhs.added && lhs.removed == rhs.removed - default: - return false - } -} - -func queuePullRequestRefresh( - repositoryID: Repository.ID, - repositoryRootURL: URL, - worktreeIDs: [Worktree.ID], - refreshesByRepositoryID: inout [Repository.ID: RepositoriesFeature.PendingPullRequestRefresh] -) { - if var pending = refreshesByRepositoryID[repositoryID] { - var seenWorktreeIDs = Set(pending.worktreeIDs) - for worktreeID in worktreeIDs where seenWorktreeIDs.insert(worktreeID).inserted { - pending.worktreeIDs.append(worktreeID) - } - refreshesByRepositoryID[repositoryID] = pending - } else { - refreshesByRepositoryID[repositoryID] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: repositoryRootURL, - worktreeIDs: worktreeIDs - ) - } -} - -func reorderedUnpinnedWorktreeIDs( - for worktreeID: Worktree.ID, - in repository: Repository, - state: RepositoriesFeature.State -) -> [Worktree.ID] { - var ordered = state.orderedUnpinnedWorktreeIDs(in: repository) - guard let index = ordered.firstIndex(of: worktreeID) else { - return ordered - } - ordered.remove(at: index) - ordered.insert(worktreeID, at: 0) - return ordered -} - -func restoreSelection( - _ id: Worktree.ID?, - pendingID: Worktree.ID, - state: inout RepositoriesFeature.State -) { - guard state.selection == .worktree(pendingID) else { return } - setSingleWorktreeSelection( - isSelectionValid(id, state: state) ? id : nil, - state: &state, - recordHistory: false - ) - pruneWorktreeHistoryTails(state: &state) -} - -func isSelectionValid( - _ id: Worktree.ID?, - state: RepositoriesFeature.State -) -> Bool { - state.selectedRow(for: id) != nil -} - -/// Choose the next book to open after `worktreeID`'s book is retired. -/// Prefer the book immediately *after* the closed one in Shelf order; -/// fall back to the one immediately *before* it; return `nil` when -/// Shelf is inactive, when the closed book isn't the currently open -/// one, or when no other books remain. -func replacementBookAfterClosing( - worktreeID: Worktree.ID, - state: RepositoriesFeature.State -) -> ShelfBook? { - guard state.isShelfActive, - state.selectedTerminalWorktree?.id == worktreeID - else { return nil } - let books = state.orderedShelfBooks() - guard let index = books.firstIndex(where: { $0.id == worktreeID }) else { - return nil - } - let remaining = books.enumerated().filter { $0.offset != index }.map(\.element) - guard !remaining.isEmpty else { return nil } - // After removing index `index`, the "next" book is now at position - // `index` in the reduced list (if it exists); otherwise the last one - // is the "previous" relative to what was closed. - if index < remaining.count { - return remaining[index] - } - return remaining.last -} - -/// Returns the Shelf book at `offset` positions from the currently open -/// book (wrapping around the book list). Returns nil if there are no -/// books. When there is no open book, offset > 0 picks the first book -/// and offset < 0 picks the last. -func shelfBook( - atOffset offset: Int, - state: RepositoriesFeature.State -) -> ShelfBook? { - let books = state.orderedShelfBooks() - guard !books.isEmpty else { return nil } - if let currentID = state.openShelfBookID, - let currentIndex = books.firstIndex(where: { $0.id == currentID }) - { - let nextIndex = (currentIndex + offset + books.count) % books.count - return books[nextIndex] - } - return offset > 0 ? books.first : books.last -} - -/// Dispatches the right selection action for a book — a worktree vs. -/// a plain folder requires different Reducer actions even though the -/// Shelf treats them uniformly. -func shelfBookSelectionEffect( - for book: ShelfBook -) -> Effect { - switch book.kind { - case .worktree: - return .send(.selectWorktree(book.id, focusTerminal: true, recordHistory: false)) - case .plainFolder: - return .send(.selectRepository(book.repositoryID)) - } -} - -private func isSidebarSelectionValid( - _ selection: SidebarSelection?, - state: RepositoriesFeature.State -) -> Bool { - switch selection { - case .worktree(let id): - return isSelectionValid(id, state: state) - case .repository(let id): - return state.repositories[id: id] != nil - case .archivedWorktrees, .canvas: - return true - case nil: - return false - } -} - -func setSingleWorktreeSelection( - _ worktreeID: Worktree.ID?, - state: inout RepositoriesFeature.State, - recordHistory: Bool = false -) { - if recordHistory { - recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: worktreeID, state: &state) - } - state.selection = worktreeID.map(SidebarSelection.worktree) - if let worktreeID { - state.sidebarSelectedWorktreeIDs = [worktreeID] - } else { - state.sidebarSelectedWorktreeIDs = [] - } -} - -private enum WorktreeHistoryDirection { - case backward - case forward -} - -private let worktreeHistoryStackLimit = 50 - -private func navigateWorktreeHistory( - direction: WorktreeHistoryDirection, - state: inout RepositoriesFeature.State -) -> Effect { - guard !state.isShowingShelf, !state.isShowingCanvas else { return .none } - guard let currentID = state.selectedWorktreeID else { return .none } - var sourceStack = - direction == .backward - ? state.worktreeHistoryBackStack - : state.worktreeHistoryForwardStack - guard let destinationID = popValidWorktreeHistoryDestination(from: &sourceStack, currentID: currentID, state: state) - else { - if direction == .backward { - state.worktreeHistoryBackStack = sourceStack - } else { - state.worktreeHistoryForwardStack = sourceStack - } - return .none - } - - if direction == .backward { - state.worktreeHistoryBackStack = sourceStack - pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryForwardStack) - } else { - state.worktreeHistoryForwardStack = sourceStack - pushWorktreeHistoryID(currentID, onto: &state.worktreeHistoryBackStack) - } - setSingleWorktreeSelection(destinationID, state: &state, recordHistory: false) - state.openedWorktreeIDs.insert(destinationID) - return .send(.delegate(.selectedWorktreeChanged(state.worktree(for: destinationID)))) -} - -private func recordWorktreeHistoryTransition( - from previousID: Worktree.ID?, - to nextID: Worktree.ID?, - state: inout RepositoriesFeature.State -) { - // Shelf / Canvas are mode switches, not worktree navigation — leave - // history frozen so users can resume Back/Forward where they left off. - guard !state.isShowingShelf, !state.isShowingCanvas else { return } - // No-op transitions (same worktree, or both endpoints nil) leave history alone. - if previousID == nextID { return } - // Any user-initiated selection change invalidates the redo path. Crucially - // this also fires when the user navigates to/from .repository or - // .archivedWorktrees (one or both IDs nil), so a stale forward stack - // can't carry over into an unrelated path. - state.worktreeHistoryForwardStack = [] - // Only push onto the back stack when leaving a still-valid worktree; we - // don't want non-worktree selections (repository / archived) showing up - // as Back targets. - guard let previousID, isSelectionValid(previousID, state: state) else { return } - pushWorktreeHistoryID(previousID, onto: &state.worktreeHistoryBackStack) -} - -private func popValidWorktreeHistoryDestination( - from stack: inout [Worktree.ID], - currentID: Worktree.ID, - state: RepositoriesFeature.State -) -> Worktree.ID? { - while let candidateID = stack.popLast() { - guard candidateID != currentID, isSelectionValid(candidateID, state: state) else { - continue - } - return candidateID - } - return nil -} - -private func pushWorktreeHistoryID(_ id: Worktree.ID, onto stack: inout [Worktree.ID]) { - stack.append(id) - if stack.count > worktreeHistoryStackLimit { - stack.removeFirst(stack.count - worktreeHistoryStackLimit) - } -} - -private func pruneWorktreeHistoryTails(state: inout RepositoriesFeature.State) { - pruneWorktreeHistoryTail(stack: &state.worktreeHistoryBackStack, state: state) - pruneWorktreeHistoryTail(stack: &state.worktreeHistoryForwardStack, state: state) -} - -private func pruneWorktreeHistoryTail( - stack: inout [Worktree.ID], - state: RepositoriesFeature.State -) { - while let last = stack.last, - last == state.selectedWorktreeID || !isSelectionValid(last, state: state) - { - stack.removeLast() - } -} - -func repositoryForWorktreeCreation( - _ state: RepositoriesFeature.State -) -> Repository? { - if let selectedRepository = state.selectedRepository, - selectedRepository.capabilities.supportsWorktrees - { - return selectedRepository - } - if let selectedWorktreeID = state.selectedWorktreeID { - if let pending = state.pendingWorktree(for: selectedWorktreeID) { - if let repository = state.repositories[id: pending.repositoryID], - repository.capabilities.supportsWorktrees - { - return repository - } - return nil - } - for repository in state.repositories - where repository.worktrees[id: selectedWorktreeID] != nil { - if repository.capabilities.supportsWorktrees { - return repository - } - return nil - } - } - if state.repositories.count == 1, - let repository = state.repositories.first, - repository.capabilities.supportsWorktrees - { - return repository - } - return nil -} - -private func prunePinnedWorktreeIDs(state: inout RepositoriesFeature.State) -> Bool { - let availableIDs = Set(state.repositories.flatMap { $0.worktrees.map(\.id) }) - let mainIDs = Set( - state.repositories.compactMap { repository in - repository.worktrees.first(where: { state.isMainWorktree($0) })?.id - } - ) - let archivedSet = state.archivedWorktreeIDSet - let pruned = state.pinnedWorktreeIDs.filter { - availableIDs.contains($0) - && !mainIDs.contains($0) - && !archivedSet.contains($0) - } - if pruned != state.pinnedWorktreeIDs { - state.pinnedWorktreeIDs = pruned - return true - } - return false -} - -private func pruneRepositoryOrderIDs( - roots: [URL], - state: inout RepositoriesFeature.State -) -> Bool { - let rootIDs = roots.map { $0.standardizedFileURL.path(percentEncoded: false) } - let availableIDs = Set(rootIDs + state.repositories.map(\.id)) - let pruned = state.repositoryOrderIDs.filter { availableIDs.contains($0) } - if pruned != state.repositoryOrderIDs { - state.repositoryOrderIDs = pruned - return true - } - return false -} - -private func pruneWorktreeOrderByRepository( - roots: [URL], - state: inout RepositoriesFeature.State -) -> Bool { - let rootIDs = Set(roots.map { $0.standardizedFileURL.path(percentEncoded: false) }) - let repositoriesByID = Dictionary(uniqueKeysWithValues: state.repositories.map { ($0.id, $0) }) - let pinnedSet = Set(state.pinnedWorktreeIDs) - let archivedSet = state.archivedWorktreeIDSet - var pruned: [Repository.ID: [Worktree.ID]] = [:] - for (repoID, order) in state.worktreeOrderByRepository { - guard let repository = repositoriesByID[repoID] else { - if rootIDs.contains(repoID), !order.isEmpty { - pruned[repoID] = order - } - continue - } - let mainID = repository.worktrees.first(where: { state.isMainWorktree($0) })?.id - let availableIDs = Set(repository.worktrees.map(\.id)) - var seen: Set = [] - var filtered: [Worktree.ID] = [] - for id in order { - if availableIDs.contains(id), - id != mainID, - !pinnedSet.contains(id), - !archivedSet.contains(id), - seen.insert(id).inserted - { - filtered.append(id) - } - } - if !filtered.isEmpty { - pruned[repoID] = filtered - } - } - if pruned != state.worktreeOrderByRepository { - state.worktreeOrderByRepository = pruned - return true - } - return false -} - -private func pruneArchivedWorktrees( - availableWorktreeIDs: Set, - state: inout RepositoriesFeature.State -) -> Bool { - let pruned = state.archivedWorktrees.filter { availableWorktreeIDs.contains($0.id) } - if pruned != state.archivedWorktrees { - state.archivedWorktrees = pruned - return true - } - return false -} - -func firstAvailableWorktreeID( - from repositories: [Repository], - state: RepositoriesFeature.State -) -> Worktree.ID? { - for repository in repositories { - if let first = state.orderedWorktrees(in: repository).first { - return first.id - } - } - return nil -} - -func firstAvailableWorktreeID( - in repositoryID: Repository.ID, - state: RepositoriesFeature.State -) -> Worktree.ID? { - guard let repository = state.repositories[id: repositoryID] else { - return nil - } - return state.orderedWorktrees(in: repository).first?.id -} - -private func findWorktreeAndRepository( - worktreeID: Worktree.ID, - state: RepositoriesFeature.State -) -> (worktree: Worktree, repository: Repository)? { - for repository in state.repositories { - if let worktree = repository.worktrees[id: worktreeID] { - return (worktree, repository) - } - } - return nil -} - -func nextWorktreeID( - afterRemoving worktree: Worktree, - in repository: Repository, - state: RepositoriesFeature.State -) -> Worktree.ID? { - let orderedIDs = state.orderedWorktrees(in: repository).map(\.id) - guard let index = orderedIDs.firstIndex(of: worktree.id) else { return nil } - let nextIndex = index + 1 - if nextIndex < orderedIDs.count { - return orderedIDs[nextIndex] - } - if index > 0 { - return orderedIDs[index - 1] - } - return nil -} -- 2.51.2 From 29433c2f3f12df8cf9246c755253a0a09f811808 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 02:29:47 +0900 Subject: [PATCH 337/528] Split AppFeature lint-heavy helpers --- .../Reducer/AppFeature+CommandPalette.swift | 179 +++++++--- .../Reducer/AppFeature+TerminalEvents.swift | 325 ++++++++++++------ 2 files changed, 356 insertions(+), 148 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 0e6424d1..2523a08d 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -15,7 +15,47 @@ extension AppFeature { guard state.commandPalette.isPresented else { return .none } return restoreCommandPaletteTerminalFocusEffect(repositories: state.repositories) - case .delegate(.selectWorktree(let worktreeID)): + case .delegate(let delegate): + return reduceCommandPaletteDelegate(delegate, state: &state) + + default: + return .none + } + } + + func reduceCommandPaletteDelegate( + _ delegate: CommandPaletteFeature.Delegate, + state: inout State + ) -> Effect { + if let effect = reduceCommandPaletteNavigationDelegate(delegate, state: &state) { + return effect + } + if let effect = reduceCommandPaletteCanvasDelegate(delegate) { + return effect + } + if let effect = reduceCommandPaletteWorktreeFileDelegate(delegate, state: &state) { + return effect + } + if let effect = reduceCommandPaletteWorktreeActionDelegate(delegate, state: &state) { + return effect + } + if let effect = reduceCommandPalettePullRequestDelegate(delegate) { + return effect + } + #if DEBUG + if let effect = reduceCommandPaletteDebugDelegate(delegate) { + return effect + } + #endif + return .none + } + + func reduceCommandPaletteNavigationDelegate( + _ delegate: CommandPaletteFeature.Delegate, + state: inout State + ) -> Effect? { + switch delegate { + case .selectWorktree(let worktreeID): if state.repositories.isShowingCanvas { if state.repositories.worktree(for: worktreeID) == nil, state.repositories.repositories[id: worktreeID]?.kind == .plain @@ -26,10 +66,10 @@ extension AppFeature { } return .send(.repositories(.selectWorktree(worktreeID))) - case .delegate(.checkForUpdates): + case .checkForUpdates: return .send(.updates(.checkForUpdates)) - case .delegate(.openSettings): + case .openSettings: return .merge( .send(.settings(.setSelection(.general))), .run { _ in @@ -37,52 +77,71 @@ extension AppFeature { } ) - case .delegate(.newWorktree): + case .newWorktree: return .send(.repositories(.worktreeCreation(.createRandomWorktree))) - case .delegate(.openRepository): + case .openRepository: return .send(.repositories(.setOpenPanelPresented(true))) - case .delegate(.deleteWorktree(let worktreeID, let repositoryID)): + case .deleteWorktree(let worktreeID, let repositoryID): return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) - case .delegate(.viewArchivedWorktrees): + case .viewArchivedWorktrees: return .send(.repositories(.selectArchivedWorktrees)) - case .delegate(.refreshWorktrees): + case .refreshWorktrees: return .send(.repositories(.refreshWorktrees)) - case .delegate(.jumpToLatestUnread): + case .jumpToLatestUnread: return .send(.jumpToLatestUnread) - case .delegate(.installCLI): + case .installCLI: return .send(.settings(.installCLIButtonTapped(showAlert: false))) - case .delegate(.toggleLeftSidebar): + case .toggleLeftSidebar: return .send(.toggleLeftSidebar) - case .delegate(.toggleActiveAgentsPanel): + case .toggleActiveAgentsPanel: return .send(.repositories(.activeAgents(.togglePanelVisibility))) - case .delegate(.toggleCanvas): + default: + return nil + } + } + + func reduceCommandPaletteCanvasDelegate( + _ delegate: CommandPaletteFeature.Delegate + ) -> Effect? { + switch delegate { + case .toggleCanvas: return .send(.repositories(.toggleCanvas)) - case .delegate(.expandCanvasCard): + case .expandCanvasCard: return .send(.repositories(.requestCanvasCommand(.toggleExpand))) - case .delegate(.arrangeCanvasCards): + case .arrangeCanvasCards: return .send(.repositories(.requestCanvasCommand(.arrange))) - case .delegate(.organizeCanvasCards): + case .organizeCanvasCards: return .send(.repositories(.requestCanvasCommand(.organize))) - case .delegate(.selectAllCanvasCards): + case .selectAllCanvasCards: return .send(.repositories(.requestCanvasCommand(.selectAll))) - case .delegate(.toggleShelf): + case .toggleShelf: return .send(.repositories(.toggleShelf)) - case .delegate(.showDiff): + default: + return nil + } + } + + func reduceCommandPaletteWorktreeFileDelegate( + _ delegate: CommandPaletteFeature.Delegate, + state: inout State + ) -> Effect? { + switch delegate { + case .showDiff: guard let worktreeID = state.repositories.selectedWorktreeID, let worktree = state.repositories.worktree(for: worktreeID) else { @@ -99,10 +158,10 @@ extension AppFeature { } } - case .delegate(.revealInFinder): + case .revealInFinder: return .send(.openWorktree(.finder)) - case .delegate(.copyPath): + case .copyPath: guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } @@ -114,38 +173,48 @@ extension AppFeature { } } - case .delegate(.revealInSidebar): + case .revealInSidebar: guard state.repositories.selectedWorktreeID != nil else { return .none } return .merge( .send(.showLeftSidebar), .send(.repositories(.revealSelectedWorktreeInSidebar)) ) - case .delegate(.runScript): + default: + return nil + } + } + + func reduceCommandPaletteWorktreeActionDelegate( + _ delegate: CommandPaletteFeature.Delegate, + state: inout State + ) -> Effect? { + switch delegate { + case .runScript: return .send(.runScript) - case .delegate(.stopRunScript): + case .stopRunScript: return .send(.stopRunScript) - case .delegate(.renameBranch): + case .renameBranch: guard let worktreeID = state.repositories.selectedWorktreeID else { return .none } return .send(.repositories(.requestRenameBranchPrompt(worktreeID))) - case .delegate(.openRepositorySettings(let repositoryID)): + case .openRepositorySettings(let repositoryID): // Reuse the existing repo-side flow so the repo-existence guard and // settingsWindowClient.show() live in one place. return .send(.repositories(.repositoryManagement(.openRepositorySettings(repositoryID)))) - case .delegate(.togglePinWorktree(let worktreeID, let isCurrentlyPinned)): + case .togglePinWorktree(let worktreeID, let isCurrentlyPinned): if isCurrentlyPinned { return .send(.repositories(.worktreeOrdering(.unpinWorktree(worktreeID)))) } return .send(.repositories(.worktreeOrdering(.pinWorktree(worktreeID)))) - case .delegate(.runCustomCommand(let index)): + case .runCustomCommand(let index): return .send(.runCustomCommand(index)) - case .delegate(.ghosttyCommand(let action)): + case .ghosttyCommand(let action): guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } @@ -153,7 +222,7 @@ extension AppFeature { await terminalClient.send(.performBindingAction(worktree, action: action)) } - case .delegate(.changeFocusedTabIcon(let worktreeID)): + case .changeFocusedTabIcon(let worktreeID): guard let worktree = state.repositories.selectedTerminalWorktree, worktree.id == worktreeID else { @@ -163,43 +232,61 @@ extension AppFeature { await terminalClient.send(.presentTabIconPicker(worktree)) } - case .delegate(.openPullRequest(let worktreeID)): + default: + return nil + } + } + + func reduceCommandPalettePullRequestDelegate( + _ delegate: CommandPaletteFeature.Delegate + ) -> Effect? { + switch delegate { + case .openPullRequest(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openOnCodeHost)))) - case .delegate(.markPullRequestReady(let worktreeID)): + case .markPullRequestReady(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .markReadyForReview)))) - case .delegate(.mergePullRequest(let worktreeID)): + case .mergePullRequest(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .merge)))) - case .delegate(.closePullRequest(let worktreeID)): + case .closePullRequest(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .close)))) - case .delegate(.copyFailingJobURL(let worktreeID)): + case .copyFailingJobURL(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyFailingJobURL)))) - case .delegate(.copyCiFailureLogs(let worktreeID)): + case .copyCiFailureLogs(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .copyCiFailureLogs)))) - case .delegate(.rerunFailedJobs(let worktreeID)): + case .rerunFailedJobs(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .rerunFailedJobs)))) - case .delegate(.openFailingCheckDetails(let worktreeID)): + case .openFailingCheckDetails(let worktreeID): return .send(.repositories(.githubIntegration(.pullRequestAction(worktreeID, .openFailingCheckDetails)))) - #if DEBUG - case .delegate(.debugTestToast(let toast)): + default: + return nil + } + } + + #if DEBUG + func reduceCommandPaletteDebugDelegate( + _ delegate: CommandPaletteFeature.Delegate + ) -> Effect? { + switch delegate { + case .debugTestToast(let toast): return .send(.repositories(.showToast(toast))) - case .delegate(.debugSimulateUpdateFound): + case .debugSimulateUpdateFound: return .send(.updates(.debugSimulateUpdateFound)) - case .delegate(.debugLightDockNotificationDot): + case .debugLightDockNotificationDot: return .run { _ in await dockClient.setNotificationBadge(1) } - #endif - default: - return .none + default: + return nil + } } - } + #endif } diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift index 4fce045b..d486ab35 100644 --- a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -1,42 +1,45 @@ import ComposableArchitecture +import Foundation extension AppFeature { func reduceTerminalEvent( _ event: TerminalClient.Event, state: inout State ) -> Effect { - switch event { - case .customCommandSucceeded(_, let name, let durationMs): - let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" - return .send(.repositories(.showToast(.success(message)))) + if let effect = reduceTerminalNotificationEvent(event, state: &state) { + return effect + } + if let effect = reduceTerminalStatusEvent(event, state: &state) { + return effect + } + if let effect = reduceTerminalCommandPaletteEvent(event, state: state) { + return effect + } + if let effect = reduceTerminalLayoutEvent(event, state: &state) { + return effect + } + if let effect = reduceTerminalTabEvent(event, state: state) { + return effect + } + if let effect = reduceTerminalAgentEvent(event, state: state) { + return effect + } + return .none + } + func reduceTerminalNotificationEvent( + _ event: TerminalClient.Event, + state: inout State + ) -> Effect? { + switch event { case .notificationReceived(let worktreeID, let surfaceID, let title, let body): - var effects: [Effect] = [ - .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) - ] - if state.settings.systemNotificationsEnabled { - effects.append( - .run { _ in - await systemNotificationClient.send(title, body, worktreeID, surfaceID) - } - ) - } - if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { - effects.append( - .run { _ in - await notificationSoundClient.play() - } - ) - } - let bounceMode = state.settings.dockBounceMode - if bounceMode != .off { - effects.append( - .run { _ in - await dockClient.bounce(bounceMode) - } - ) - } - return .merge(effects) + return terminalNotificationReceivedEffect( + worktreeID: worktreeID, + surfaceID: surfaceID, + title: title, + body: body, + state: state + ) case .notificationIndicatorChanged(let count): state.notificationIndicatorCount = count @@ -45,6 +48,20 @@ extension AppFeature { await dockClient.setNotificationBadge(badgeCount) } + default: + return nil + } + } + + func reduceTerminalStatusEvent( + _ event: TerminalClient.Event, + state: inout State + ) -> Effect? { + switch event { + case .customCommandSucceeded(_, let name, let durationMs): + let message = "\(name) succeeded in \(formatCustomCommandDuration(durationMs))" + return .send(.repositories(.showToast(.success(message)))) + case .runScriptStatusChanged(let worktreeID, let isRunning): if isRunning { state.runScriptStatusByWorktreeID[worktreeID] = true @@ -53,35 +70,18 @@ extension AppFeature { } return .none - case .agentEntryChanged(let entry): - return .send( - .repositories( - .activeAgents( - .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) - ) - ) - ) - - case .agentEntryRemoved(let id): - return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) + default: + return nil + } + } + func reduceTerminalCommandPaletteEvent( + _ event: TerminalClient.Event, + state: State + ) -> Effect? { + switch event { case .commandPaletteToggleRequested(let worktreeID): - if state.commandPalette.isPresented { - return .send(.commandPalette(.setPresented(false))) - } - if state.repositories.worktree(for: worktreeID) != nil { - return .merge( - .send(.repositories(.selectWorktree(worktreeID))), - .send(.commandPalette(.setPresented(true))) - ) - } - if state.repositories.repositories[id: worktreeID]?.kind == .plain { - return .merge( - .send(.repositories(.selectRepository(worktreeID))), - .send(.commandPalette(.setPresented(true))) - ) - } - return .send(.commandPalette(.setPresented(true))) + return commandPaletteToggleRequestedEffect(worktreeID: worktreeID, state: state) case .setupScriptConsumed(let worktreeID): return .send(.repositories(.worktreeCreation(.consumeSetupScript(worktreeID)))) @@ -89,24 +89,18 @@ extension AppFeature { case .fontSizeChanged(let fontSize): return .send(.settings(.setTerminalFontSize(fontSize))) + default: + return nil + } + } + + func reduceTerminalLayoutEvent( + _ event: TerminalClient.Event, + state: inout State + ) -> Effect? { + switch event { case .layoutRestored(let selectedWorktreeID): - appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") - // Layout restore has settled: tabs are re-created, selection is set. - // Now apply the default view preference, which was deferred in - // `repositoriesChanged` (via `shouldDeferDefaultView`) to avoid - // stray spines and a selection flash. - var effects: [Effect] = [] - if let selectedWorktreeID { - // Plain folders use .repository selection, not .worktree - if let repo = state.repositories.repositories[id: selectedWorktreeID], - repo.kind == .plain - { - effects.append(.send(.repositories(.selectRepository(selectedWorktreeID)))) - } else { - effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) - } - } - return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) + return layoutRestoredEffect(selectedWorktreeID: selectedWorktreeID, state: &state) case .layoutRestoreFailed(let message): appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") @@ -115,36 +109,21 @@ extension AppFeature { applyDefaultViewMode(into: &state) ) + default: + return nil + } + } + + func reduceTerminalTabEvent( + _ event: TerminalClient.Event, + state: State + ) -> Effect? { + switch event { case .tabCreated(let worktreeID): - // Every tab creation (user +, CLI open, layout restore, ...) - // marks its worktree as Shelf-visible. Layout restore in - // particular only calls `selectWorktree` for the one active - // worktree; other restored worktrees only surface here. - var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) - if state.repositories.worktree(for: worktreeID) != nil { - openedWorktreeIDs.insert(worktreeID) - } - let syncedOpenedWorktreeIDs = openedWorktreeIDs - return .merge( - .send(.repositories(.markWorktreeOpened(worktreeID))), - .run { _ in - await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) - } - ) + return tabCreatedEffect(worktreeID: worktreeID, state: state) case .tabClosed(let worktreeID, let remainingTabs): - // Closing the last tab retires the book from the Shelf. Other - // closes are routine and need no Reducer-side bookkeeping. - guard remainingTabs == 0 else { return .none } - var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) - openedWorktreeIDs.remove(worktreeID) - let syncedOpenedWorktreeIDs = openedWorktreeIDs - return .merge( - .send(.repositories(.markWorktreeClosed(worktreeID))), - .run { _ in - await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) - } - ) + return tabClosedEffect(worktreeID: worktreeID, remainingTabs: remainingTabs, state: state) case .focusChanged(_, let surfaceID): // Keep the Active Agents panel's keyboard-navigation anchor in sync with @@ -152,7 +131,149 @@ extension AppFeature { return .send(.repositories(.activeAgents(.focusedSurfaceChanged(surfaceID)))) default: - return .none + return nil + } + } + + func reduceTerminalAgentEvent( + _ event: TerminalClient.Event, + state: State + ) -> Effect? { + switch event { + case .agentEntryChanged(let entry): + return .send( + .repositories( + .activeAgents( + .agentEntryChanged(entry, autoShowPanel: state.settings.autoShowActiveAgentsPanel) + ) + ) + ) + + case .agentEntryRemoved(let id): + return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) + + default: + return nil + } + } + + func terminalNotificationReceivedEffect( + worktreeID: Worktree.ID, + surfaceID: UUID, + title: String, + body: String, + state: State + ) -> Effect { + var effects: [Effect] = [ + .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) + ] + if state.settings.systemNotificationsEnabled { + effects.append( + .run { _ in + await systemNotificationClient.send(title, body, worktreeID, surfaceID) + } + ) } + if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { + effects.append( + .run { _ in + await notificationSoundClient.play() + } + ) + } + let bounceMode = state.settings.dockBounceMode + if bounceMode != .off { + effects.append( + .run { _ in + await dockClient.bounce(bounceMode) + } + ) + } + return .merge(effects) + } + + func commandPaletteToggleRequestedEffect( + worktreeID: Worktree.ID, + state: State + ) -> Effect { + if state.commandPalette.isPresented { + return .send(.commandPalette(.setPresented(false))) + } + if state.repositories.worktree(for: worktreeID) != nil { + return .merge( + .send(.repositories(.selectWorktree(worktreeID))), + .send(.commandPalette(.setPresented(true))) + ) + } + if state.repositories.repositories[id: worktreeID]?.kind == .plain { + return .merge( + .send(.repositories(.selectRepository(worktreeID))), + .send(.commandPalette(.setPresented(true))) + ) + } + return .send(.commandPalette(.setPresented(true))) + } + + func layoutRestoredEffect( + selectedWorktreeID: Worktree.ID?, + state: inout State + ) -> Effect { + appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") + // Layout restore has settled: tabs are re-created, selection is set. + // Now apply the default view preference, which was deferred in + // `repositoriesChanged` (via `shouldDeferDefaultView`) to avoid + // stray spines and a selection flash. + var effects: [Effect] = [] + if let selectedWorktreeID { + // Plain folders use .repository selection, not .worktree + if let repo = state.repositories.repositories[id: selectedWorktreeID], + repo.kind == .plain + { + effects.append(.send(.repositories(.selectRepository(selectedWorktreeID)))) + } else { + effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) + } + } + return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)]) + } + + func tabCreatedEffect( + worktreeID: Worktree.ID, + state: State + ) -> Effect { + // Every tab creation (user +, CLI open, layout restore, ...) + // marks its worktree as Shelf-visible. Layout restore in + // particular only calls `selectWorktree` for the one active + // worktree; other restored worktrees only surface here. + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + if state.repositories.worktree(for: worktreeID) != nil { + openedWorktreeIDs.insert(worktreeID) + } + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeOpened(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) + } + + func tabClosedEffect( + worktreeID: Worktree.ID, + remainingTabs: Int, + state: State + ) -> Effect { + // Closing the last tab retires the book from the Shelf. Other + // closes are routine and need no Reducer-side bookkeeping. + guard remainingTabs == 0 else { return .none } + var openedWorktreeIDs = openedWorktreeIDsForInfoWatcher(from: state.repositories) + openedWorktreeIDs.remove(worktreeID) + let syncedOpenedWorktreeIDs = openedWorktreeIDs + return .merge( + .send(.repositories(.markWorktreeClosed(worktreeID))), + .run { _ in + await worktreeInfoWatcher.send(.setOpenedWorktreeIDs(syncedOpenedWorktreeIDs)) + } + ) } } -- 2.51.2 From 453243c963bf71b93876613551e604a247b36fa5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 09:53:41 +0900 Subject: [PATCH 338/528] Harden CLI socket access --- supacode/CLIService/CLISocketServer.swift | 61 +++++++++++++++++++++-- supacodeTests/CLISocketServerTests.swift | 29 +++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index b82ab3a1..09500d69 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -33,10 +33,7 @@ final class CLISocketServer { func start() throws { // Ensure parent directory exists (e.g. ~/Library/Application Support/com.onevcat.prowl) let parentDir = (socketPath as NSString).deletingLastPathComponent - try? FileManager.default.createDirectory( - atPath: parentDir, - withIntermediateDirectories: true - ) + try ensureSocketDirectory(at: parentDir) var addr = try Self.socketAddress(for: socketPath) @@ -83,6 +80,13 @@ final class CLISocketServer { releaseSocketLock() throw CLIServiceError.bindFailed } + guard chmod(socketPath, S_IRUSR | S_IWUSR) == 0 else { + close(serverFD) + serverFD = -1 + unlink(socketPath) + releaseSocketLock() + throw CLIServiceError.permissionFailed + } // Listen guard listen(serverFD, 5) == 0 else { @@ -125,6 +129,10 @@ final class CLISocketServer { guard lockFD >= 0 else { throw CLIServiceError.lockFailed } + guard fchmod(lockFD, S_IRUSR | S_IWUSR) == 0 else { + releaseSocketLock() + throw CLIServiceError.lockFailed + } do { try Self.setCloseOnExec(lockFD) } catch { @@ -144,6 +152,23 @@ final class CLISocketServer { lockFD = -1 } + private func ensureSocketDirectory(at parentDir: String) throws { + let fileManager = FileManager.default + let existed = fileManager.fileExists(atPath: parentDir) + try fileManager.createDirectory( + atPath: parentDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + + // Avoid chmod'ing arbitrary existing custom parents such as /tmp or $HOME + // when PROWL_CLI_SOCKET is overridden. + guard !existed || parentDir == Self.defaultSocketDirectory else { return } + guard chmod(parentDir, S_IRWXU) == 0 else { + throw CLIServiceError.permissionFailed + } + } + // MARK: - Accept loop (runs on acceptQueue, NOT in Swift concurrency) private nonisolated static func acceptLoop(serverFD: Int32, server: CLISocketServer?) { @@ -167,6 +192,8 @@ final class CLISocketServer { defer { Darwin.close(clientFD) } do { + guard Self.clientHasCurrentUser(clientFD) else { return } + // Read length-prefixed request let lengthData = try Self.fdRead(fildes: clientFD, count: 4) let length = lengthData.withUnsafeBytes { @@ -196,6 +223,23 @@ final class CLISocketServer { } } + static func isAllowedPeerUID(_ peerUID: uid_t, currentUID: uid_t = geteuid()) -> Bool { + peerUID == currentUID + } + + private static func clientHasCurrentUser(_ clientFD: Int32) -> Bool { + #if canImport(Darwin) + var peerUID = uid_t() + var peerGID = gid_t() + guard getpeereid(clientFD, &peerUID, &peerGID) == 0 else { + return false + } + return isAllowedPeerUID(peerUID) + #else + return true + #endif + } + // MARK: - Low-level I/O using Darwin read/write private static func fdRead(fildes: Int32, count: Int) throws -> Data { @@ -254,6 +298,14 @@ final class CLISocketServer { return result == 0 } + private static var defaultSocketDirectory: String { + FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library", directoryHint: .isDirectory) + .appending(path: "Application Support", directoryHint: .isDirectory) + .appending(path: "com.onevcat.prowl", directoryHint: .isDirectory) + .path(percentEncoded: false) + } + private static func socketAddress(for socketPath: String) throws -> sockaddr_un { var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -286,6 +338,7 @@ enum CLIServiceError: Error, Equatable { case socketAlreadyOwned case lockFailed case closeOnExecFailed + case permissionFailed case bindFailed case listenFailed case readFailed diff --git a/supacodeTests/CLISocketServerTests.swift b/supacodeTests/CLISocketServerTests.swift index 35980555..e58677a4 100644 --- a/supacodeTests/CLISocketServerTests.swift +++ b/supacodeTests/CLISocketServerTests.swift @@ -66,16 +66,45 @@ struct CLISocketServerTests { #expect(isCloseOnExec(descriptors.lock)) } + @Test func socketFilesAreOwnerOnly() throws { + let socketPath = temporarySocketPath(suffix: "permissions") + let socketDirectory = (socketPath as NSString).deletingLastPathComponent + let lockPath = "\(socketPath).lock" + let server = CLISocketServer(router: CLICommandRouter(), socketPath: socketPath) + try server.start() + defer { server.stop() } + + #expect(fileMode(at: socketDirectory) == 0o700) + #expect(fileMode(at: socketPath) == 0o600) + #expect(fileMode(at: lockPath) == 0o600) + } + + @Test func peerUIDMustMatchCurrentUser() { + #expect(CLISocketServer.isAllowedPeerUID(501, currentUID: 501)) + #expect(!CLISocketServer.isAllowedPeerUID(502, currentUID: 501)) + } + private func temporarySocketPath(suffix: String) -> String { URL(fileURLWithPath: "/tmp", isDirectory: true) + .appending(path: "prowl-cli-tests-\(UUID().uuidString.prefix(8))", directoryHint: .isDirectory) .appending( path: "prowl-\(suffix)-\(UUID().uuidString.prefix(8)).sock", directoryHint: .notDirectory ) .path(percentEncoded: false) } + private func fileMode(at path: String) -> mode_t? { + var statValue = stat() + guard stat(path, &statValue) == 0 else { return nil } + return statValue.st_mode & mode_t(0o777) + } + private func createStaleSocket(at socketPath: String) throws { unlink(socketPath) + try FileManager.default.createDirectory( + atPath: (socketPath as NSString).deletingLastPathComponent, + withIntermediateDirectories: true + ) let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) guard socketFD >= 0 else { throw CLIServiceError.socketCreationFailed -- 2.51.2 From f027e894d8ab76e3b50381ce6352310cafab58a4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 10:42:02 +0900 Subject: [PATCH 339/528] Add CLI tab and pane close commands --- ProwlCLI/Commands/PaneCommand.swift | 34 ++++ ProwlCLI/Commands/ProwlCommand.swift | 2 + ProwlCLI/Commands/TabCommand.swift | 77 ++++++++ ProwlCLI/Output/OutputRenderer.swift | 59 ++++++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 99 ++++++++++ skills/prowl-cli/SKILL.md | 16 +- supacode/App/supacodeApp.swift | 66 ++++++- supacode/CLIService/CLICommandRouter.swift | 10 +- supacode/CLIService/PaneCommandHandler.swift | 97 ++++++++++ .../CLIService/Shared/CommandEnvelope.swift | 4 + supacode/CLIService/Shared/ErrorCodes.swift | 6 + supacode/CLIService/Shared/InputModels.swift | 31 +++ .../Shared/PaneCommandPayload.swift | 11 ++ .../CLIService/Shared/TabCommandPayload.swift | 73 +++++++ supacode/CLIService/TabCommandHandler.swift | 181 ++++++++++++++++++ supacodeTests/CLICommandEnvelopeTests.swift | 35 ++++ supacodeTests/CLICommandRouterTests.swift | 24 +++ .../CLIPaneCommandHandlerTests.swift | 84 ++++++++ supacodeTests/CLITabCommandHandlerTests.swift | 153 +++++++++++++++ 19 files changed, 1053 insertions(+), 9 deletions(-) create mode 100644 ProwlCLI/Commands/PaneCommand.swift create mode 100644 ProwlCLI/Commands/TabCommand.swift create mode 100644 supacode/CLIService/PaneCommandHandler.swift create mode 100644 supacode/CLIService/Shared/PaneCommandPayload.swift create mode 100644 supacode/CLIService/Shared/TabCommandPayload.swift create mode 100644 supacode/CLIService/TabCommandHandler.swift create mode 100644 supacodeTests/CLIPaneCommandHandlerTests.swift create mode 100644 supacodeTests/CLITabCommandHandlerTests.swift diff --git a/ProwlCLI/Commands/PaneCommand.swift b/ProwlCLI/Commands/PaneCommand.swift new file mode 100644 index 00000000..97e43640 --- /dev/null +++ b/ProwlCLI/Commands/PaneCommand.swift @@ -0,0 +1,34 @@ +// ProwlCLI/Commands/PaneCommand.swift + +import ArgumentParser +import ProwlCLIShared + +struct PaneCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pane", + abstract: "Manage terminal panes.", + subcommands: [ + PaneCloseCommand.self, + ] + ) +} + +struct PaneCloseCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "close", + abstract: "Close a terminal pane." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + try CLIExecution.run(command: "pane", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .pane(PaneInput(action: .close, selector: try selector.resolve())) + ) + try CLIRunner.execute(envelope) + } + } +} diff --git a/ProwlCLI/Commands/ProwlCommand.swift b/ProwlCLI/Commands/ProwlCommand.swift index 581269e9..3b598ba6 100644 --- a/ProwlCLI/Commands/ProwlCommand.swift +++ b/ProwlCLI/Commands/ProwlCommand.swift @@ -17,6 +17,8 @@ struct ProwlCommand: ParsableCommand { SendCommand.self, KeyCommand.self, ReadCommand.self, + TabCommand.self, + PaneCommand.self, ], defaultSubcommand: OpenCommand.self ) diff --git a/ProwlCLI/Commands/TabCommand.swift b/ProwlCLI/Commands/TabCommand.swift new file mode 100644 index 00000000..070ea79f --- /dev/null +++ b/ProwlCLI/Commands/TabCommand.swift @@ -0,0 +1,77 @@ +// ProwlCLI/Commands/TabCommand.swift + +import ArgumentParser +import Foundation +import ProwlCLIShared + +struct TabCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tab", + abstract: "Create or close terminal tabs.", + subcommands: [ + TabCreateCommand.self, + TabCloseCommand.self, + ] + ) +} + +struct TabCreateCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new terminal tab." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + @Option(name: .long, help: "Working directory for the new tab.") + var path: String? + + mutating func run() throws { + try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .tab(TabInput(action: .create, selector: try selector.resolve(), path: normalizedPath())) + ) + try CLIRunner.execute(envelope) + } + } + + private func normalizedPath() -> String? { + guard let path else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + .standardizedFileURL + .path(percentEncoded: false) + .trimmingTrailingSlash() + } +} + +struct TabCloseCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "close", + abstract: "Close a terminal tab." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .tab(TabInput(action: .close, selector: try selector.resolve())) + ) + try CLIRunner.execute(envelope) + } + } +} + +extension String { + fileprivate func trimmingTrailingSlash() -> String { + var value = self + while value.count > 1, value.hasSuffix("/") { + value.removeLast() + } + return value + } +} diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 3053f679..bdc7d9cd 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -81,6 +81,22 @@ enum OutputRenderer { return } + if response.command == "tab", + let data = response.data, + let payload = try? data.decode(as: TabCommandPayload.self) + { + print(renderTab(payload)) + return + } + + if response.command == "pane", + let data = response.data, + let payload = try? data.decode(as: PaneCommandPayload.self) + { + print(renderPane(payload)) + return + } + if response.command == "open" { return } @@ -179,6 +195,49 @@ enum OutputRenderer { return lines.joined(separator: "\n") } + private static func renderTab(_ payload: TabCommandPayload) -> String { + let wt = payload.target.worktree + let tab = payload.target.tab + let pane = payload.target.pane + let projectName = projectName(from: wt.path) + let verb = + switch payload.action { + case .create: "Created tab" + case .close: "Closed tab" + } + + var lines: [String] = [] + lines.append( + "\(verb) \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(tab.title.yellow)" + + " \(tab.id.dim)" + ) + lines.append(" \("pane:".dim) \(pane.title.green) \(pane.id.dim)") + if let cwd = pane.cwd { + lines.append(" \("cwd:".dim) \(cwd)") + } + return lines.joined(separator: "\n") + } + + private static func renderPane(_ payload: PaneCommandPayload) -> String { + let wt = payload.target.worktree + let pane = payload.target.pane + let projectName = projectName(from: wt.path) + let verb = + switch payload.action { + case .close: "Closed pane" + } + + var lines: [String] = [] + lines.append( + "\(verb) \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" + + " \(pane.id.dim)" + ) + if let cwd = pane.cwd { + lines.append(" \("cwd:".dim) \(cwd)") + } + return lines.joined(separator: "\n") + } + private static func renderSend(_ payload: SendCommandPayload) -> String { let wt = payload.target.worktree let pane = payload.target.pane diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index b221fc3f..8e0537a1 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -155,6 +155,83 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testTabCreateCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "tab-create") + let response = try CommandResponse( + ok: true, + command: "tab", + schemaVersion: "prowl.cli.tab.v1", + data: RawJSON(encoding: makeTabPayload(action: .create)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["tab", "create", "--worktree", "App", "--path", "/Projects/App", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .tab(let input) = envelope.command { + XCTAssertEqual(input.action, .create) + XCTAssertEqual(input.selector, .worktree("App")) + XCTAssertEqual(input.path, "/Projects/App") + } else { + XCTFail("Expected tab command envelope") + } + } + + func testTabCloseCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "tab-close") + let response = try CommandResponse( + ok: true, + command: "tab", + schemaVersion: "prowl.cli.tab.v1", + data: RawJSON(encoding: makeTabPayload(action: .close)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["tab", "close", "--tab", "tab-123", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .tab(let input) = envelope.command { + XCTAssertEqual(input.action, .close) + XCTAssertEqual(input.selector, .tab("tab-123")) + XCTAssertNil(input.path) + } else { + XCTFail("Expected tab command envelope") + } + } + + func testPaneCloseCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "pane-close") + let response = try CommandResponse( + ok: true, + command: "pane", + schemaVersion: "prowl.cli.pane.v1", + data: RawJSON(encoding: makePanePayload(action: .close)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["pane", "close", "--pane", "pane-123", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .pane(let input) = envelope.command { + XCTAssertEqual(input.action, .close) + XCTAssertEqual(input.selector, .pane("pane-123")) + } else { + XCTFail("Expected pane command envelope") + } + } + func testFocusRejectsMultipleSelectorsBeforeTransport() throws { let result = try runProwl(args: ["focus", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) @@ -1240,6 +1317,28 @@ final class ProwlCLIIntegrationTests: XCTestCase { return (requestData, result) } + private func makeTabPayload(action: TabAction) -> TabCommandPayload { + TabCommandPayload(action: action, target: makeTabTarget()) + } + + private func makePanePayload(action: PaneAction) -> PaneCommandPayload { + PaneCommandPayload(action: action, target: makeTabTarget()) + } + + private func makeTabTarget() -> TabTarget { + TabTarget( + worktree: TabTargetWorktree( + id: "App:/Projects/App", + name: "App", + path: "/Projects/App", + rootPath: "/Projects/App", + kind: "git" + ), + tab: TabTargetTab(id: "tab-123", title: "App 1", selected: true), + pane: TabTargetPane(id: "pane-123", title: "zsh", cwd: "/Projects/App", focused: true) + ) + } + private func runProwl( args: [String], environment: [String: String] = [:], diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index fb84f5ec..e0c5aabd 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -35,14 +35,14 @@ prowl key --pane "$pane" enter --json ## Common Recipes -Open a fresh pane for a project, then verify it is not yourself: +Create a fresh tab for the current project, then verify it is not yourself: ```bash -pane="$(prowl open /path/to/project --json | jq -r '.data.target.pane.id')" +pane="$(prowl tab create --worktree /path/to/project --json | jq -r '.data.target.pane.id')" test "$pane" != "$self_pane" ``` -`open` always creates a new tab/pane for a path and brings Prowl forward. To select an already-open pane, use `focus`, not `open`. +`prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl tab create` for deterministic new terminal sessions. Run a command and capture its result: @@ -71,10 +71,11 @@ Send multiline input from stdin: printf '%s\n' 'echo first' 'echo second' | prowl send --pane "$pane" --capture --timeout 30 --json ``` -Close a temporary tab/pane when done. There is no `prowl close` command: +Close a temporary tab/pane when done: ```bash -prowl key --pane "$pane" cmd-w --json +prowl pane close --pane "$pane" --json +prowl tab close --tab "$tab" --json ``` ## Reading Agent Output @@ -169,7 +170,8 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - Never target by tab title alone; use `pane.id` plus path/cwd. - Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. -- `open /path` creates a new tab/pane. It is not a refocus command. +- `open /path` is a project/path navigation command. It may refocus an existing pane and is not a deterministic create command. +- Use `tab create` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. - Focused pane is not stable; `open` and `focus` change it. - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. - `read` returning fewer lines than `--last` requested is normally `truncated: false` — the pane simply has less history and you already have it all, so do not retry for more. `truncated: true` flags a possibly-incomplete result (the full scrollback could not be read). @@ -203,4 +205,4 @@ Always check the exit code before piping output into `jq`; parser-level errors p ## Command Set -Current commands: `list`, `read`, `send`, `key`, `focus`, and `open` (default). There is no CLI `close` or `quit`; use key shortcuts such as `cmd-w` only after explicit target verification. +Current commands: `list`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index b0f868ca..64123ace 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -464,13 +464,77 @@ struct SupacodeApp: App { return KeyDeliveryResult(attempted: repeatCount, delivered: delivered) } ) + let tabHandler = TabCommandHandler( + resolveProvider: { selector in + let resolver = TargetResolver { + TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager + ) + } + return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } + }, + createTab: { target, path in + let repositories = Array(appStore.state.repositories.repositories) + guard let worktree = resolveCLITerminalWorktree(id: target.worktreeID, repositories: repositories) else { + return nil + } + selectCLIWorktreeContext( + worktreeID: target.worktreeID, + appStore: appStore, + terminalManager: terminalManager + ) + let state = terminalManager.state(for: worktree) + let directory = path.map { URL(fileURLWithPath: $0, isDirectory: true) } + guard let tabID = state.createTab(workingDirectoryOverride: directory) else { + return nil + } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + switch resolver.resolve(.tab(tabID.rawValue.uuidString)) { + case .success(let resolved): + return TabResolvedTarget(from: resolved) + case .failure: + return nil + } + }, + closeTab: { target in + guard let tabUUID = UUID(uuidString: target.tabID), + let state = terminalManager.stateIfExists(for: target.worktreeID) + else { + return false + } + return state.closeTab(TerminalTabID(rawValue: tabUUID)) + } + ) + let paneHandler = PaneCommandHandler( + resolveProvider: { selector in + let resolver = TargetResolver { + TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager + ) + } + return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } + }, + closePane: { target in + guard let paneID = UUID(uuidString: target.paneID), + let state = terminalManager.stateIfExists(for: target.worktreeID) + else { + return false + } + guard state.focusSurface(id: paneID) else { return false } + return state.closeFocusedSurface() + } + ) return CLICommandRouter( openHandler: openHandler, listHandler: listHandler, focusHandler: focusHandler, sendHandler: sendHandler, keyHandler: keyHandler, - readHandler: readHandler + readHandler: readHandler, + tabHandler: tabHandler, + paneHandler: paneHandler ) } diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index a74e8cd3..b4183cb8 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -11,6 +11,8 @@ final class CLICommandRouter { private let sendHandler: any CommandHandler private let keyHandler: any CommandHandler private let readHandler: any CommandHandler + private let tabHandler: any CommandHandler + private let paneHandler: any CommandHandler init( openHandler: any CommandHandler = StubCommandHandler(command: "open"), @@ -18,7 +20,9 @@ final class CLICommandRouter { focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), sendHandler: any CommandHandler = StubCommandHandler(command: "send"), keyHandler: any CommandHandler = StubCommandHandler(command: "key"), - readHandler: any CommandHandler = StubCommandHandler(command: "read") + readHandler: any CommandHandler = StubCommandHandler(command: "read"), + tabHandler: any CommandHandler = StubCommandHandler(command: "tab"), + paneHandler: any CommandHandler = StubCommandHandler(command: "pane") ) { self.openHandler = openHandler self.listHandler = listHandler @@ -26,6 +30,8 @@ final class CLICommandRouter { self.sendHandler = sendHandler self.keyHandler = keyHandler self.readHandler = readHandler + self.tabHandler = tabHandler + self.paneHandler = paneHandler } func route(_ envelope: CommandEnvelope) async -> CommandResponse { @@ -37,6 +43,8 @@ final class CLICommandRouter { case .send: handler = sendHandler case .key: handler = keyHandler case .read: handler = readHandler + case .tab: handler = tabHandler + case .pane: handler = paneHandler } return await handler.handle(envelope: envelope) } diff --git a/supacode/CLIService/PaneCommandHandler.swift b/supacode/CLIService/PaneCommandHandler.swift new file mode 100644 index 00000000..f6d2619e --- /dev/null +++ b/supacode/CLIService/PaneCommandHandler.swift @@ -0,0 +1,97 @@ +// supacode/CLIService/PaneCommandHandler.swift + +import Foundation + +@MainActor +final class PaneCommandHandler: CommandHandler { + typealias ResolveProvider = @MainActor (TargetSelector) -> Result + typealias ClosePaneProvider = @MainActor (TabResolvedTarget) -> Bool + + private let resolveProvider: ResolveProvider + private let closePane: ClosePaneProvider + + init( + resolveProvider: @escaping ResolveProvider, + closePane: @escaping ClosePaneProvider + ) { + self.resolveProvider = resolveProvider + self.closePane = closePane + } + + // swiftlint:disable:next async_without_await + func handle(envelope: CommandEnvelope) async -> CommandResponse { + guard case .pane(let input) = envelope.command else { + return errorResponse(code: CLIErrorCode.paneFailed, message: "Invalid command.") + } + + let target: TabResolvedTarget + switch resolveProvider(input.selector) { + case .success(let resolved): + target = resolved + case .failure(let error): + return mapResolverError(error) + } + + switch input.action { + case .close: + guard closePane(target) else { + return errorResponse(code: CLIErrorCode.paneFailed, message: "Failed to close pane.") + } + return success(action: .close, target: target) + } + } + + private func success(action: PaneAction, target: TabResolvedTarget) -> CommandResponse { + do { + return try CommandResponse( + ok: true, + command: "pane", + schemaVersion: "prowl.cli.pane.v1", + data: RawJSON(encoding: PaneCommandPayload(action: action, target: makePayloadTarget(from: target))) + ) + } catch { + return errorResponse(code: CLIErrorCode.paneFailed, message: "Failed to encode response.") + } + } + + private func makePayloadTarget(from target: TabResolvedTarget) -> TabTarget { + TabTarget( + worktree: TabTargetWorktree( + id: target.worktreeID, + name: target.worktreeName, + path: target.worktreePath, + rootPath: target.worktreeRootPath, + kind: target.worktreeKind + ), + tab: TabTargetTab( + id: target.tabID, + title: target.tabTitle, + selected: target.tabSelected + ), + pane: TabTargetPane( + id: target.paneID, + title: target.paneTitle, + cwd: target.paneCWD, + focused: target.paneFocused + ) + ) + } + + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { + switch error { + case .notFound(let message): + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) + case .notUnique(let message): + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) + } + } + + private func errorResponse(code: String, message: String) -> CommandResponse { + CommandResponse( + ok: false, + command: "pane", + schemaVersion: "prowl.cli.pane.v1", + error: CommandError(code: code, message: message) + ) + } +} diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index 073f346f..bd75de6c 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -20,6 +20,8 @@ public enum Command: Codable, Sendable { case send(SendInput) case key(KeyInput) case read(ReadInput) + case tab(TabInput) + case pane(PaneInput) public var name: String { switch self { @@ -29,6 +31,8 @@ public enum Command: Codable, Sendable { case .send: "send" case .key: "key" case .read: "read" + case .tab: "tab" + case .pane: "pane" } } } diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index c6f4fb43..8c588b75 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -38,6 +38,12 @@ public enum CLIErrorCode { // Read public static let readFailed = "READ_FAILED" + // Tab + public static let tabFailed = "TAB_FAILED" + + // Pane + public static let paneFailed = "PANE_FAILED" + // Transport public static let transportFailed = "TRANSPORT_FAILED" public static let timeout = "TIMEOUT" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index e2d8b16c..25e8e97f 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -133,3 +133,34 @@ public struct ReadInput: Codable, Sendable { self.waitTimeoutSeconds = waitTimeoutSeconds } } + +public enum TabAction: String, Codable, Sendable { + case create + case close +} + +public struct TabInput: Codable, Sendable { + public let action: TabAction + public let selector: TargetSelector + public let path: String? + + public init(action: TabAction, selector: TargetSelector = .none, path: String? = nil) { + self.action = action + self.selector = selector + self.path = path + } +} + +public enum PaneAction: String, Codable, Sendable { + case close +} + +public struct PaneInput: Codable, Sendable { + public let action: PaneAction + public let selector: TargetSelector + + public init(action: PaneAction, selector: TargetSelector = .none) { + self.action = action + self.selector = selector + } +} diff --git a/supacode/CLIService/Shared/PaneCommandPayload.swift b/supacode/CLIService/Shared/PaneCommandPayload.swift new file mode 100644 index 00000000..70af62ca --- /dev/null +++ b/supacode/CLIService/Shared/PaneCommandPayload.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PaneCommandPayload: Codable, Sendable, Equatable { + public let action: PaneAction + public let target: TabTarget + + public init(action: PaneAction, target: TabTarget) { + self.action = action + self.target = target + } +} diff --git a/supacode/CLIService/Shared/TabCommandPayload.swift b/supacode/CLIService/Shared/TabCommandPayload.swift new file mode 100644 index 00000000..fdfd8380 --- /dev/null +++ b/supacode/CLIService/Shared/TabCommandPayload.swift @@ -0,0 +1,73 @@ +import Foundation + +public struct TabCommandPayload: Codable, Sendable, Equatable { + public let action: TabAction + public let target: TabTarget + + public init(action: TabAction, target: TabTarget) { + self.action = action + self.target = target + } +} + +public struct TabTarget: Codable, Sendable, Equatable { + public let worktree: TabTargetWorktree + public let tab: TabTargetTab + public let pane: TabTargetPane + + public init(worktree: TabTargetWorktree, tab: TabTargetTab, pane: TabTargetPane) { + self.worktree = worktree + self.tab = tab + self.pane = pane + } +} + +public struct TabTargetWorktree: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let path: String + public let rootPath: String + public let kind: String + + enum CodingKeys: String, CodingKey { + case id + case name + case path + case rootPath = "root_path" + case kind + } + + public init(id: String, name: String, path: String, rootPath: String, kind: String) { + self.id = id + self.name = name + self.path = path + self.rootPath = rootPath + self.kind = kind + } +} + +public struct TabTargetTab: Codable, Sendable, Equatable { + public let id: String + public let title: String + public let selected: Bool + + public init(id: String, title: String, selected: Bool) { + self.id = id + self.title = title + self.selected = selected + } +} + +public struct TabTargetPane: Codable, Sendable, Equatable { + public let id: String + public let title: String + public let cwd: String? + public let focused: Bool + + public init(id: String, title: String, cwd: String?, focused: Bool) { + self.id = id + self.title = title + self.cwd = cwd + self.focused = focused + } +} diff --git a/supacode/CLIService/TabCommandHandler.swift b/supacode/CLIService/TabCommandHandler.swift new file mode 100644 index 00000000..4bf705c8 --- /dev/null +++ b/supacode/CLIService/TabCommandHandler.swift @@ -0,0 +1,181 @@ +// supacode/CLIService/TabCommandHandler.swift + +import Foundation + +struct TabResolvedTarget: Sendable, Equatable { + let worktreeID: String + let worktreeName: String + let worktreePath: String + let worktreeRootPath: String + let worktreeKind: String + let tabID: String + let tabTitle: String + let tabSelected: Bool + let paneID: String + let paneTitle: String + let paneCWD: String? + let paneFocused: Bool +} + +extension TabResolvedTarget { + init(from resolved: ResolvedTarget) { + self.worktreeID = resolved.worktreeID + self.worktreeName = resolved.worktreeName + self.worktreePath = resolved.worktreePath + self.worktreeRootPath = resolved.worktreeRootPath + self.worktreeKind = resolved.worktreeKind.rawValue + self.tabID = resolved.tabID.uuidString + self.tabTitle = resolved.tabTitle + self.tabSelected = resolved.tabSelected + self.paneID = resolved.paneID.uuidString + self.paneTitle = resolved.paneTitle + self.paneCWD = resolved.paneCWD + self.paneFocused = resolved.paneFocused + } +} + +@MainActor +final class TabCommandHandler: CommandHandler { + typealias ResolveProvider = @MainActor (TargetSelector) -> Result + typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? + typealias CloseTabProvider = @MainActor (TabResolvedTarget) -> Bool + + private let resolveProvider: ResolveProvider + private let createTab: CreateTabProvider + private let closeTab: CloseTabProvider + + init( + resolveProvider: @escaping ResolveProvider, + createTab: @escaping CreateTabProvider, + closeTab: @escaping CloseTabProvider + ) { + self.resolveProvider = resolveProvider + self.createTab = createTab + self.closeTab = closeTab + } + + // swiftlint:disable:next async_without_await + func handle(envelope: CommandEnvelope) async -> CommandResponse { + guard case .tab(let input) = envelope.command else { + return errorResponse(code: CLIErrorCode.tabFailed, message: "Invalid command.") + } + + let target: TabResolvedTarget + switch resolveProvider(input.selector) { + case .success(let resolved): + target = resolved + case .failure(let error): + return mapResolverError(error) + } + + switch input.action { + case .create: + return handleCreate(input: input, target: target) + case .close: + return handleClose(target: target) + } + } + + private func handleCreate(input: TabInput, target: TabResolvedTarget) -> CommandResponse { + let path = normalizedAllowedPath(input.path, worktreePath: target.worktreePath) + guard input.path == nil || path != nil else { + return errorResponse( + code: CLIErrorCode.pathNotAllowed, + message: "Tab path must be inside the resolved worktree." + ) + } + + guard let createdTarget = createTab(target, path) else { + return errorResponse(code: CLIErrorCode.tabFailed, message: "Failed to create tab.") + } + return success(action: .create, target: createdTarget) + } + + private func handleClose(target: TabResolvedTarget) -> CommandResponse { + guard closeTab(target) else { + return errorResponse(code: CLIErrorCode.tabFailed, message: "Failed to close tab.") + } + return success(action: .close, target: target) + } + + private func normalizedAllowedPath(_ path: String?, worktreePath: String) -> String? { + guard let path else { return nil } + let normalizedPath = normalize(path) + let normalizedWorktree = normalize(worktreePath) + guard normalizedPath == normalizedWorktree || normalizedPath.hasPrefix(normalizedWorktree + "/") else { + return nil + } + return normalizedPath + } + + private func normalize(_ path: String) -> String { + URL(fileURLWithPath: path, isDirectory: true) + .standardizedFileURL + .path(percentEncoded: false) + .trimmingTrailingSlash() + } + + private func success(action: TabAction, target: TabResolvedTarget) -> CommandResponse { + do { + return try CommandResponse( + ok: true, + command: "tab", + schemaVersion: "prowl.cli.tab.v1", + data: RawJSON(encoding: TabCommandPayload(action: action, target: makePayloadTarget(from: target))) + ) + } catch { + return errorResponse(code: CLIErrorCode.tabFailed, message: "Failed to encode response.") + } + } + + private func makePayloadTarget(from target: TabResolvedTarget) -> TabTarget { + TabTarget( + worktree: TabTargetWorktree( + id: target.worktreeID, + name: target.worktreeName, + path: target.worktreePath, + rootPath: target.worktreeRootPath, + kind: target.worktreeKind + ), + tab: TabTargetTab( + id: target.tabID, + title: target.tabTitle, + selected: target.tabSelected + ), + pane: TabTargetPane( + id: target.paneID, + title: target.paneTitle, + cwd: target.paneCWD, + focused: target.paneFocused + ) + ) + } + + private func mapResolverError(_ error: TargetResolverError) -> CommandResponse { + switch error { + case .notFound(let message): + return errorResponse(code: CLIErrorCode.targetNotFound, message: message) + case .notUnique(let message): + return errorResponse(code: CLIErrorCode.targetNotUnique, message: message) + } + } + + private func errorResponse(code: String, message: String) -> CommandResponse { + CommandResponse( + ok: false, + command: "tab", + schemaVersion: "prowl.cli.tab.v1", + error: CommandError(code: code, message: message) + ) + } +} + +extension String { + fileprivate func trimmingTrailingSlash() -> String { + var value = self + while value.count > 1, value.hasSuffix("/") { + value.removeLast() + } + return value + } +} diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index ae1acab8..2859e175 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -128,6 +128,37 @@ struct CLICommandEnvelopeTests { } } + @Test func envelopeTabCreateRoundTrips() throws { + let envelope = CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .create, selector: .worktree("wt-main"), path: "/Projects/App")) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .tab(let input) = decoded.command { + #expect(input.action == .create) + #expect(input.selector == .worktree("wt-main")) + #expect(input.path == "/Projects/App") + } else { + Issue.record("Expected .tab command") + } + } + + @Test func envelopePaneCloseRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .pane(PaneInput(action: .close, selector: .pane("pane-1"))) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .pane(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .pane("pane-1")) + } else { + Issue.record("Expected .pane command") + } + } + // MARK: - Command name @Test func commandNameReturnsCorrectStrings() { @@ -138,6 +169,8 @@ struct CLICommandEnvelopeTests { (.send(SendInput(text: "x")), "send"), (.key(KeyInput(rawToken: "tab", token: "tab")), "key"), (.read(ReadInput()), "read"), + (.tab(TabInput(action: .create)), "tab"), + (.pane(PaneInput(action: .close)), "pane"), ] for (command, expected) in commands { #expect(command.name == expected) @@ -154,6 +187,8 @@ struct CLICommandEnvelopeTests { .send(SendInput(text: "test")), .key(KeyInput(rawToken: "enter", token: "enter")), .read(ReadInput()), + .tab(TabInput(action: .create)), + .pane(PaneInput(action: .close)), ] for cmd in commands { let envelope = CommandEnvelope(output: .json, command: cmd) diff --git a/supacodeTests/CLICommandRouterTests.swift b/supacodeTests/CLICommandRouterTests.swift index f388cb0c..a22c8683 100644 --- a/supacodeTests/CLICommandRouterTests.swift +++ b/supacodeTests/CLICommandRouterTests.swift @@ -77,6 +77,28 @@ struct CLICommandRouterTests { #expect(response.command == "read") } + @MainActor + @Test func routerDispatchesTabToTabHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .create)) + ) + let response = await router.route(envelope) + #expect(response.command == "tab") + } + + @MainActor + @Test func routerDispatchesPaneToPaneHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .pane(PaneInput(action: .close)) + ) + let response = await router.route(envelope) + #expect(response.command == "pane") + } + // MARK: - Custom handler injection @MainActor @@ -106,6 +128,8 @@ struct CLICommandRouterTests { .send(SendInput(text: "x")), .key(KeyInput(rawToken: "tab", token: "tab")), .read(ReadInput()), + .tab(TabInput(action: .create)), + .pane(PaneInput(action: .close)), ] let router = CLICommandRouter() for cmd in commands { diff --git a/supacodeTests/CLIPaneCommandHandlerTests.swift b/supacodeTests/CLIPaneCommandHandlerTests.swift new file mode 100644 index 00000000..de6b595d --- /dev/null +++ b/supacodeTests/CLIPaneCommandHandlerTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CLIPaneCommandHandlerTests { + @Test func closeResolvesTargetAndClosesPane() async throws { + let target = makeTarget(tabID: "tab-1", paneID: "pane-to-close") + var closedTarget: TabResolvedTarget? + + let handler = PaneCommandHandler( + resolveProvider: { selector in + #expect(selector == .pane("pane-to-close")) + return .success(target) + }, + closePane: { target in + closedTarget = target + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .pane(PaneInput(action: .close, selector: .pane("pane-to-close"))) + ) + ) + + #expect(response.ok == true) + #expect(closedTarget == target) + let data = try #require(response.data) + let payload = try data.decode(as: PaneCommandPayload.self) + #expect(payload.action == .close) + #expect(payload.target.pane.id == "pane-to-close") + } + + @Test func closeReturnsFailureWhenCloseActionFails() async { + let handler = PaneCommandHandler( + resolveProvider: { _ in .success(makeTarget()) }, + closePane: { _ in false } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .pane(PaneInput(action: .close, selector: .none)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.paneFailed) + } + + private func makeTarget( + worktreeID: String = "App:/Projects/App", + worktreeName: String = "App", + worktreePath: String = "/Projects/App", + worktreeRootPath: String = "/Projects/App", + worktreeKind: String = "git", + tabID: String = "tab-1", + tabTitle: String = "App 1", + tabSelected: Bool = true, + paneID: String = "pane-1", + paneTitle: String = "zsh", + paneCWD: String? = "/Projects/App", + paneFocused: Bool = true + ) -> TabResolvedTarget { + TabResolvedTarget( + worktreeID: worktreeID, + worktreeName: worktreeName, + worktreePath: worktreePath, + worktreeRootPath: worktreeRootPath, + worktreeKind: worktreeKind, + tabID: tabID, + tabTitle: tabTitle, + tabSelected: tabSelected, + paneID: paneID, + paneTitle: paneTitle, + paneCWD: paneCWD, + paneFocused: paneFocused + ) + } +} diff --git a/supacodeTests/CLITabCommandHandlerTests.swift b/supacodeTests/CLITabCommandHandlerTests.swift new file mode 100644 index 00000000..255e8e72 --- /dev/null +++ b/supacodeTests/CLITabCommandHandlerTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CLITabCommandHandlerTests { + @Test func createResolvesWorktreeCreatesTabAndReturnsNewTarget() async throws { + let base = makeTarget(tabID: "base-tab", paneID: "base-pane") + let created = makeTarget(tabID: "new-tab", tabTitle: "App 2", paneID: "new-pane", paneTitle: "zsh") + var resolvedSelector: TargetSelector? + var createBase: TabResolvedTarget? + var createPath: String? + + let handler = TabCommandHandler( + resolveProvider: { selector in + resolvedSelector = selector + return .success(base) + }, + createTab: { target, path in + createBase = target + createPath = path + return created + }, + closeTab: { _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .create, selector: .worktree("App"), path: "/Projects/App")) + ) + ) + + #expect(response.ok == true) + #expect(response.command == "tab") + #expect(resolvedSelector == .worktree("App")) + #expect(createBase == base) + #expect(createPath == "/Projects/App") + + let data = try #require(response.data) + let payload = try data.decode(as: TabCommandPayload.self) + #expect(payload.action == .create) + #expect(payload.target.tab.id == "new-tab") + #expect(payload.target.pane.id == "new-pane") + } + + @Test func createRejectsPathOutsideResolvedWorktree() async throws { + var didCreate = false + let handler = TabCommandHandler( + resolveProvider: { _ in .success(makeTarget(worktreePath: "/Projects/App")) }, + createTab: { _, _ in + didCreate = true + return nil + }, + closeTab: { _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .create, selector: .worktree("App"), path: "/Projects/Other")) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.pathNotAllowed) + #expect(didCreate == false) + } + + @Test func createAllowsOmittedPath() async throws { + var createPath: String? + let handler = TabCommandHandler( + resolveProvider: { _ in .success(makeTarget(worktreePath: "/Projects/App")) }, + createTab: { _, path in + createPath = path + return makeTarget(tabID: "created-tab", paneID: "created-pane") + }, + closeTab: { _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .create, selector: .none)) + ) + ) + + #expect(response.ok == true) + #expect(createPath == nil) + } + + @Test func closeResolvesTargetAndClosesTab() async throws { + let target = makeTarget(tabID: "tab-to-close", paneID: "pane-in-tab") + var closedTarget: TabResolvedTarget? + + let handler = TabCommandHandler( + resolveProvider: { selector in + #expect(selector == .tab("tab-to-close")) + return .success(target) + }, + createTab: { _, _ in nil }, + closeTab: { target in + closedTarget = target + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .close, selector: .tab("tab-to-close"))) + ) + ) + + #expect(response.ok == true) + #expect(closedTarget == target) + let data = try #require(response.data) + let payload = try data.decode(as: TabCommandPayload.self) + #expect(payload.action == .close) + #expect(payload.target.tab.id == "tab-to-close") + } + + private func makeTarget( + worktreeID: String = "App:/Projects/App", + worktreeName: String = "App", + worktreePath: String = "/Projects/App", + worktreeRootPath: String = "/Projects/App", + worktreeKind: String = "git", + tabID: String = "tab-1", + tabTitle: String = "App 1", + tabSelected: Bool = true, + paneID: String = "pane-1", + paneTitle: String = "zsh", + paneCWD: String? = "/Projects/App", + paneFocused: Bool = true + ) -> TabResolvedTarget { + TabResolvedTarget( + worktreeID: worktreeID, + worktreeName: worktreeName, + worktreePath: worktreePath, + worktreeRootPath: worktreeRootPath, + worktreeKind: worktreeKind, + tabID: tabID, + tabTitle: tabTitle, + tabSelected: tabSelected, + paneID: paneID, + paneTitle: paneTitle, + paneCWD: paneCWD, + paneFocused: paneFocused + ) + } +} -- 2.51.2 From de7bf36f04eca8b86663efa8ef17f124bbe65ff6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:09:07 +0900 Subject: [PATCH 340/528] Harden CLI close commands --- ProwlCLI/Commands/PaneCommand.swift | 12 +++- ProwlCLI/Commands/TabCommand.swift | 12 +++- ProwlCLITests/ProwlCLIIntegrationTests.swift | 28 +++++++- skills/prowl-cli/SKILL.md | 6 ++ supacode/App/supacodeApp.swift | 15 ++-- supacode/CLIService/PaneCommandHandler.swift | 11 ++- supacode/CLIService/Shared/InputModels.swift | 36 +++++++++- .../CLIService/Shared/TargetSelector.swift | 5 ++ supacode/CLIService/TabCommandHandler.swift | 15 ++-- .../WorktreeTerminalState+Surfaces.swift | 28 +++++--- .../Models/WorktreeTerminalState.swift | 2 +- supacodeTests/CLICommandEnvelopeTests.swift | 68 ++++++++++++++++++- .../CLIPaneCommandHandlerTests.swift | 38 +++++++++-- supacodeTests/CLITabCommandHandlerTests.swift | 41 +++++++++-- .../WorktreeTerminalManagerTests.swift | 14 ++++ 15 files changed, 295 insertions(+), 36 deletions(-) diff --git a/ProwlCLI/Commands/PaneCommand.swift b/ProwlCLI/Commands/PaneCommand.swift index 97e43640..4fe0a065 100644 --- a/ProwlCLI/Commands/PaneCommand.swift +++ b/ProwlCLI/Commands/PaneCommand.swift @@ -22,11 +22,21 @@ struct PaneCloseCommand: ParsableCommand { @OptionGroup var selector: SelectorOptions @OptionGroup var options: GlobalOptions + @Flag(name: .long, help: "Close without prompting for protected panes.") + var force = false + mutating func run() throws { try CLIExecution.run(command: "pane", output: options.outputMode, colorEnabled: options.colorEnabled) { + let resolvedSelector = try selector.resolve() + guard !resolvedSelector.isNone else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "pane close requires an explicit target selector." + ) + } let envelope = CommandEnvelope( output: options.outputMode, - command: .pane(PaneInput(action: .close, selector: try selector.resolve())) + command: .pane(PaneInput(action: .close, selector: resolvedSelector, force: force)) ) try CLIRunner.execute(envelope) } diff --git a/ProwlCLI/Commands/TabCommand.swift b/ProwlCLI/Commands/TabCommand.swift index 070ea79f..01344502 100644 --- a/ProwlCLI/Commands/TabCommand.swift +++ b/ProwlCLI/Commands/TabCommand.swift @@ -55,11 +55,21 @@ struct TabCloseCommand: ParsableCommand { @OptionGroup var selector: SelectorOptions @OptionGroup var options: GlobalOptions + @Flag(name: .long, help: "Close without prompting for protected panes.") + var force = false + mutating func run() throws { try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { + let resolvedSelector = try selector.resolve() + guard !resolvedSelector.isNone else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "tab close requires an explicit target selector." + ) + } let envelope = CommandEnvelope( output: options.outputMode, - command: .tab(TabInput(action: .close, selector: try selector.resolve())) + command: .tab(TabInput(action: .close, selector: resolvedSelector, force: force)) ) try CLIRunner.execute(envelope) } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 8e0537a1..095c8726 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -193,7 +193,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let (requestData, result) = try runWithMockServer( socketPath: socketPath, response: response, - args: ["tab", "close", "--tab", "tab-123", "--json"] + args: ["tab", "close", "--tab", "tab-123", "--force", "--json"] ) XCTAssertEqual(result.exitCode, 0) @@ -202,11 +202,23 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(input.action, .close) XCTAssertEqual(input.selector, .tab("tab-123")) XCTAssertNil(input.path) + XCTAssertTrue(input.force) } else { XCTFail("Expected tab command envelope") } } + func testTabCloseRejectsMissingTargetBeforeTransport() throws { + let result = try runProwl(args: ["tab", "close", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "tab") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + func testPaneCloseCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "pane-close") let response = try CommandResponse( @@ -219,7 +231,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let (requestData, result) = try runWithMockServer( socketPath: socketPath, response: response, - args: ["pane", "close", "--pane", "pane-123", "--json"] + args: ["pane", "close", "--pane", "pane-123", "--force", "--json"] ) XCTAssertEqual(result.exitCode, 0) @@ -227,11 +239,23 @@ final class ProwlCLIIntegrationTests: XCTestCase { if case .pane(let input) = envelope.command { XCTAssertEqual(input.action, .close) XCTAssertEqual(input.selector, .pane("pane-123")) + XCTAssertTrue(input.force) } else { XCTFail("Expected pane command envelope") } } + func testPaneCloseRejectsMissingTargetBeforeTransport() throws { + let result = try runProwl(args: ["pane", "close", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "pane") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + func testFocusRejectsMultipleSelectorsBeforeTransport() throws { let result = try runProwl(args: ["focus", "--worktree", "Prowl", "--pane", "pane-123", "--json"]) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index e0c5aabd..09ccb1a5 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -78,6 +78,12 @@ prowl pane close --pane "$pane" --json prowl tab close --tab "$tab" --json ``` +`tab close` and `pane close` require an explicit `--tab`, `--pane`, `--worktree`, or `--target`; they intentionally do not default to the currently focused pane. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: + +```bash +prowl pane close --pane "$pane" --force --json +``` + ## Reading Agent Output `task.status` is useful for coordination but is not enough to prove the screen finished rendering. `idle` can arrive before a TUI has painted its final response. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 64123ace..5394eb16 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -497,13 +497,16 @@ struct SupacodeApp: App { return nil } }, - closeTab: { target in + closeTab: { target, force in guard let tabUUID = UUID(uuidString: target.tabID), let state = terminalManager.stateIfExists(for: target.worktreeID) else { return false } - return state.closeTab(TerminalTabID(rawValue: tabUUID)) + return state.closeTab( + TerminalTabID(rawValue: tabUUID), + confirmation: force ? .skip : .prompt(.tab) + ) } ) let paneHandler = PaneCommandHandler( @@ -516,14 +519,16 @@ struct SupacodeApp: App { } return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } }, - closePane: { target in + closePane: { target, force in guard let paneID = UUID(uuidString: target.paneID), let state = terminalManager.stateIfExists(for: target.worktreeID) else { return false } - guard state.focusSurface(id: paneID) else { return false } - return state.closeFocusedSurface() + return state.closeSurface( + id: paneID, + confirmation: force ? .skip : .prompt(.pane) + ) } ) return CLICommandRouter( diff --git a/supacode/CLIService/PaneCommandHandler.swift b/supacode/CLIService/PaneCommandHandler.swift index f6d2619e..3742c86a 100644 --- a/supacode/CLIService/PaneCommandHandler.swift +++ b/supacode/CLIService/PaneCommandHandler.swift @@ -5,7 +5,7 @@ import Foundation @MainActor final class PaneCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector) -> Result - typealias ClosePaneProvider = @MainActor (TabResolvedTarget) -> Bool + typealias ClosePaneProvider = @MainActor (TabResolvedTarget, Bool) -> Bool private let resolveProvider: ResolveProvider private let closePane: ClosePaneProvider @@ -24,6 +24,13 @@ final class PaneCommandHandler: CommandHandler { return errorResponse(code: CLIErrorCode.paneFailed, message: "Invalid command.") } + if input.action == .close, input.selector.isNone { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "pane close requires an explicit target selector." + ) + } + let target: TabResolvedTarget switch resolveProvider(input.selector) { case .success(let resolved): @@ -34,7 +41,7 @@ final class PaneCommandHandler: CommandHandler { switch input.action { case .close: - guard closePane(target) else { + guard closePane(target, input.force) else { return errorResponse(code: CLIErrorCode.paneFailed, message: "Failed to close pane.") } return success(action: .close, target: target) diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 25e8e97f..c67926ca 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -143,11 +143,28 @@ public struct TabInput: Codable, Sendable { public let action: TabAction public let selector: TargetSelector public let path: String? + public let force: Bool - public init(action: TabAction, selector: TargetSelector = .none, path: String? = nil) { + enum CodingKeys: String, CodingKey { + case action + case selector + case path + case force + } + + public init(action: TabAction, selector: TargetSelector = .none, path: String? = nil, force: Bool = false) { self.action = action self.selector = selector self.path = path + self.force = force + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.action = try container.decode(TabAction.self, forKey: .action) + self.selector = try container.decode(TargetSelector.self, forKey: .selector) + self.path = try container.decodeIfPresent(String.self, forKey: .path) + self.force = try container.decodeIfPresent(Bool.self, forKey: .force) ?? false } } @@ -158,9 +175,24 @@ public enum PaneAction: String, Codable, Sendable { public struct PaneInput: Codable, Sendable { public let action: PaneAction public let selector: TargetSelector + public let force: Bool - public init(action: PaneAction, selector: TargetSelector = .none) { + enum CodingKeys: String, CodingKey { + case action + case selector + case force + } + + public init(action: PaneAction, selector: TargetSelector = .none, force: Bool = false) { self.action = action self.selector = selector + self.force = force + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.action = try container.decode(PaneAction.self, forKey: .action) + self.selector = try container.decode(TargetSelector.self, forKey: .selector) + self.force = try container.decodeIfPresent(Bool.self, forKey: .force) ?? false } } diff --git a/supacode/CLIService/Shared/TargetSelector.swift b/supacode/CLIService/Shared/TargetSelector.swift index 0cf9f43d..c6312b60 100644 --- a/supacode/CLIService/Shared/TargetSelector.swift +++ b/supacode/CLIService/Shared/TargetSelector.swift @@ -11,4 +11,9 @@ public enum TargetSelector: Codable, Sendable, Equatable { case tab(String) case pane(String) case auto(String) + + public var isNone: Bool { + if case .none = self { return true } + return false + } } diff --git a/supacode/CLIService/TabCommandHandler.swift b/supacode/CLIService/TabCommandHandler.swift index 4bf705c8..a53cb0b6 100644 --- a/supacode/CLIService/TabCommandHandler.swift +++ b/supacode/CLIService/TabCommandHandler.swift @@ -38,7 +38,7 @@ extension TabResolvedTarget { final class TabCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector) -> Result typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? - typealias CloseTabProvider = @MainActor (TabResolvedTarget) -> Bool + typealias CloseTabProvider = @MainActor (TabResolvedTarget, Bool) -> Bool private let resolveProvider: ResolveProvider private let createTab: CreateTabProvider @@ -60,6 +60,13 @@ final class TabCommandHandler: CommandHandler { return errorResponse(code: CLIErrorCode.tabFailed, message: "Invalid command.") } + if input.action == .close, input.selector.isNone { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "tab close requires an explicit target selector." + ) + } + let target: TabResolvedTarget switch resolveProvider(input.selector) { case .success(let resolved): @@ -72,7 +79,7 @@ final class TabCommandHandler: CommandHandler { case .create: return handleCreate(input: input, target: target) case .close: - return handleClose(target: target) + return handleClose(input: input, target: target) } } @@ -91,8 +98,8 @@ final class TabCommandHandler: CommandHandler { return success(action: .create, target: createdTarget) } - private func handleClose(target: TabResolvedTarget) -> CommandResponse { - guard closeTab(target) else { + private func handleClose(input: TabInput, target: TabResolvedTarget) -> CommandResponse { + guard closeTab(target, input.force) else { return errorResponse(code: CLIErrorCode.tabFailed, message: "Failed to close tab.") } return success(action: .close, target: target) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 0763c1bd..7644ac2a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -763,20 +763,31 @@ extension WorktreeTerminalState { } } - func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { - guard surfaces[view.id] != nil else { return } - if processAlive { - guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: .prompt(.pane)) else { return } - } + @discardableResult + func closeSurface(id surfaceID: UUID, confirmation: TerminalCloseConfirmationMode = .prompt(.pane)) -> Bool { + guard let view = surfaces[surfaceID] else { return false } + return closeSurface(view, confirmation: confirmation) + } + + @discardableResult + func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) -> Bool { + let confirmation: TerminalCloseConfirmationMode = processAlive ? .prompt(.pane) : .skip + return closeSurface(view, confirmation: confirmation) + } + + @discardableResult + private func closeSurface(_ view: GhosttySurfaceView, confirmation: TerminalCloseConfirmationMode) -> Bool { + guard surfaces[view.id] != nil else { return false } + guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: confirmation) else { return false } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() forgetSurface(view.id) - return + return true } guard let node = tree.find(id: view.id) else { view.closeSurface() forgetSurface(view.id) - return + return true } let nextSurface = focusedSurfaceIdByTab[tabId] == view.id @@ -798,7 +809,7 @@ extension WorktreeTerminalState { // Shelf's "retire the book when its last tab closes" logic // never saw this very common path. onTabClosed?() - return + return true } updateTree(newTree, for: tabId) updateRunningState(for: tabId) @@ -809,6 +820,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeValue(forKey: tabId) } } + return true } func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index f0b68355..16bd116a 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -543,7 +543,7 @@ final class WorktreeTerminalState { } @discardableResult - private func closeTab(_ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode) -> Bool { + func closeTab(_ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode) -> Bool { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index 2859e175..aa6ca09d 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -139,6 +139,47 @@ struct CLICommandEnvelopeTests { #expect(input.action == .create) #expect(input.selector == .worktree("wt-main")) #expect(input.path == "/Projects/App") + #expect(input.force == false) + } else { + Issue.record("Expected .tab command") + } + } + + @Test func envelopeTabCloseForceRoundTrips() throws { + let envelope = CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .close, selector: .tab("tab-1"), force: true)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .tab(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .tab("tab-1")) + #expect(input.force == true) + } else { + Issue.record("Expected .tab command") + } + } + + @Test func envelopeTabInputDecodesMissingForceAsFalse() throws { + let json = """ + { + "output": "json", + "command": { + "tab": { + "_0": { + "action": "close", + "selector": { "tab": { "_0": "tab-1" } } + } + } + } + } + """ + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: Data(json.utf8)) + if case .tab(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .tab("tab-1")) + #expect(input.force == false) } else { Issue.record("Expected .tab command") } @@ -147,13 +188,38 @@ struct CLICommandEnvelopeTests { @Test func envelopePaneCloseRoundTrips() throws { let envelope = CommandEnvelope( output: .text, - command: .pane(PaneInput(action: .close, selector: .pane("pane-1"))) + command: .pane(PaneInput(action: .close, selector: .pane("pane-1"), force: true)) ) let data = try JSONEncoder().encode(envelope) let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) if case .pane(let input) = decoded.command { #expect(input.action == .close) #expect(input.selector == .pane("pane-1")) + #expect(input.force == true) + } else { + Issue.record("Expected .pane command") + } + } + + @Test func envelopePaneInputDecodesMissingForceAsFalse() throws { + let json = """ + { + "output": "json", + "command": { + "pane": { + "_0": { + "action": "close", + "selector": { "pane": { "_0": "pane-1" } } + } + } + } + } + """ + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: Data(json.utf8)) + if case .pane(let input) = decoded.command { + #expect(input.action == .close) + #expect(input.selector == .pane("pane-1")) + #expect(input.force == false) } else { Issue.record("Expected .pane command") } diff --git a/supacodeTests/CLIPaneCommandHandlerTests.swift b/supacodeTests/CLIPaneCommandHandlerTests.swift index de6b595d..ea5ec3de 100644 --- a/supacodeTests/CLIPaneCommandHandlerTests.swift +++ b/supacodeTests/CLIPaneCommandHandlerTests.swift @@ -8,14 +8,16 @@ struct CLIPaneCommandHandlerTests { @Test func closeResolvesTargetAndClosesPane() async throws { let target = makeTarget(tabID: "tab-1", paneID: "pane-to-close") var closedTarget: TabResolvedTarget? + var closeForce: Bool? let handler = PaneCommandHandler( resolveProvider: { selector in #expect(selector == .pane("pane-to-close")) return .success(target) }, - closePane: { target in + closePane: { target, force in closedTarget = target + closeForce = force return true } ) @@ -23,12 +25,13 @@ struct CLIPaneCommandHandlerTests { let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .pane(PaneInput(action: .close, selector: .pane("pane-to-close"))) + command: .pane(PaneInput(action: .close, selector: .pane("pane-to-close"), force: true)) ) ) #expect(response.ok == true) #expect(closedTarget == target) + #expect(closeForce == true) let data = try #require(response.data) let payload = try data.decode(as: PaneCommandPayload.self) #expect(payload.action == .close) @@ -38,13 +41,13 @@ struct CLIPaneCommandHandlerTests { @Test func closeReturnsFailureWhenCloseActionFails() async { let handler = PaneCommandHandler( resolveProvider: { _ in .success(makeTarget()) }, - closePane: { _ in false } + closePane: { _, _ in false } ) let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .pane(PaneInput(action: .close, selector: .none)) + command: .pane(PaneInput(action: .close, selector: .pane("pane-1"))) ) ) @@ -52,6 +55,33 @@ struct CLIPaneCommandHandlerTests { #expect(response.error?.code == CLIErrorCode.paneFailed) } + @Test func closeRejectsMissingExplicitTarget() async { + var didResolve = false + var didClose = false + let handler = PaneCommandHandler( + resolveProvider: { _ in + didResolve = true + return .success(makeTarget()) + }, + closePane: { _, _ in + didClose = true + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .pane(PaneInput(action: .close, selector: .none)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(didResolve == false) + #expect(didClose == false) + } + private func makeTarget( worktreeID: String = "App:/Projects/App", worktreeName: String = "App", diff --git a/supacodeTests/CLITabCommandHandlerTests.swift b/supacodeTests/CLITabCommandHandlerTests.swift index 255e8e72..64662df0 100644 --- a/supacodeTests/CLITabCommandHandlerTests.swift +++ b/supacodeTests/CLITabCommandHandlerTests.swift @@ -22,7 +22,7 @@ struct CLITabCommandHandlerTests { createPath = path return created }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -53,7 +53,7 @@ struct CLITabCommandHandlerTests { didCreate = true return nil }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -76,7 +76,7 @@ struct CLITabCommandHandlerTests { createPath = path return makeTarget(tabID: "created-tab", paneID: "created-pane") }, - closeTab: { _ in true } + closeTab: { _, _ in true } ) let response = await handler.handle( @@ -93,6 +93,7 @@ struct CLITabCommandHandlerTests { @Test func closeResolvesTargetAndClosesTab() async throws { let target = makeTarget(tabID: "tab-to-close", paneID: "pane-in-tab") var closedTarget: TabResolvedTarget? + var closeForce: Bool? let handler = TabCommandHandler( resolveProvider: { selector in @@ -100,8 +101,9 @@ struct CLITabCommandHandlerTests { return .success(target) }, createTab: { _, _ in nil }, - closeTab: { target in + closeTab: { target, force in closedTarget = target + closeForce = force return true } ) @@ -109,18 +111,47 @@ struct CLITabCommandHandlerTests { let response = await handler.handle( envelope: CommandEnvelope( output: .json, - command: .tab(TabInput(action: .close, selector: .tab("tab-to-close"))) + command: .tab(TabInput(action: .close, selector: .tab("tab-to-close"), force: true)) ) ) #expect(response.ok == true) #expect(closedTarget == target) + #expect(closeForce == true) let data = try #require(response.data) let payload = try data.decode(as: TabCommandPayload.self) #expect(payload.action == .close) #expect(payload.target.tab.id == "tab-to-close") } + @Test func closeRejectsMissingExplicitTarget() async throws { + var didResolve = false + var didClose = false + let handler = TabCommandHandler( + resolveProvider: { _ in + didResolve = true + return .success(makeTarget()) + }, + createTab: { _, _ in nil }, + closeTab: { _, _ in + didClose = true + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .tab(TabInput(action: .close, selector: .none)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(didResolve == false) + #expect(didClose == false) + } + private func makeTarget( worktreeID: String = "App:/Projects/App", worktreeName: String = "App", diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 72dfa940..c481789a 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -135,6 +135,20 @@ struct WorktreeTerminalManagerTests { #expect(state.surfaceView(for: tabId) == nil) } + @Test func closeSurfaceReturnsActualRemovalResult() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == true) + #expect(state.surfaceView(for: surfaceId) == nil) + #expect(state.tabManager.tabs.isEmpty) + #expect(state.closeSurface(id: surfaceId, confirmation: .skip) == false) + } + @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From 08e1c950d8e79331ef1509ce50be82756302bc67 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:25:13 +0900 Subject: [PATCH 341/528] Fix deletion of externally-created worktrees under symlinked roots Worktrees created outside Prowl under symlinked roots like /tmp could not be deleted: the row briefly vanished then reappeared after the next refresh. `worktrees(for:)` stores paths via `standardizedFileURL`, which resolves the /private symlink (e.g. /private/tmp/foo -> /tmp/foo), while the removal guard compared against the raw paths reported by `git worktree list --porcelain` (/private/tmp/foo). The mismatch failed the guard, so `removeWorktree` silently returned without relocating or pruning anything, and the optimistic UI removal was undone by the next worktree refresh. Canonicalize both sides through the same `standardizedFileURL` transform so externally-created worktrees match and actually get removed. --- supacode/Clients/Git/GitClient.swift | 15 +++++- .../GitClientRemoveWorktreeTests.swift | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 5bfc132f..af8c90ac 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -526,7 +526,7 @@ struct GitClient { nonisolated func removeWorktree(_ worktree: Worktree, deleteBranch: Bool) async throws -> URL { let rootPath = worktree.repositoryRootURL.path(percentEncoded: false) let worktreeURL = worktree.workingDirectory.standardizedFileURL - let worktreePath = worktreeURL.path(percentEncoded: false) + let worktreePath = Self.canonicalWorktreePath(worktreeURL.path(percentEncoded: false)) let registeredWorktreePaths = try await registeredWorktreePaths(rootPath: rootPath) guard registeredWorktreePaths.contains(worktreePath) else { return worktree.workingDirectory @@ -572,7 +572,11 @@ struct GitClient { operation: .worktreeList, arguments: ["-C", rootPath, "worktree", "list", "--porcelain"] ) - return Self.parseGitWorktreePorcelainPaths(output) + // `git worktree list --porcelain` reports the raw on-disk path (e.g. `/private/tmp/foo`), + // while `worktrees(for:)` stores `standardizedFileURL` paths (which resolve `/private` + // symlinks to `/tmp`). Canonicalize both sides identically so the removal guard matches + // externally-created worktrees living under symlinked roots like /tmp or /var. + return Set(Self.parseGitWorktreePorcelainPaths(output).map(Self.canonicalWorktreePath)) } nonisolated func deleteLocalBranch( @@ -874,6 +878,13 @@ struct GitClient { return FileManager.default.fileExists(atPath: gitMetadataURL.path(percentEncoded: false)) } + /// Normalizes a worktree path to the same canonical form `worktrees(for:)` stores, so paths + /// reported by git (which keep `/private` symlink prefixes) compare equal to the standardized + /// URLs Prowl tracks internally. + nonisolated static func canonicalWorktreePath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path(percentEncoded: false) + } + nonisolated static func parseGitWorktreePorcelainPaths(_ output: String) -> Set { Set( output diff --git a/supacodeTests/GitClientRemoveWorktreeTests.swift b/supacodeTests/GitClientRemoveWorktreeTests.swift index 94b78365..6c3bb581 100644 --- a/supacodeTests/GitClientRemoveWorktreeTests.swift +++ b/supacodeTests/GitClientRemoveWorktreeTests.swift @@ -131,6 +131,55 @@ struct GitClientRemoveWorktreeTests { #expect(!calls.contains { $0.suffix(3) == ["branch", "-d", "feat"] }) } + @Test func removeWorktreeMatchesWhenGitReportsPrivateSymlinkPath() async throws { + // Regression: externally-created worktrees under /tmp are reported by git as `/private/tmp/...`, + // but Prowl tracks them as standardized `/tmp/...` URLs. The removal guard must treat them as + // the same worktree, otherwise removal silently no-ops and the row reappears after a refresh. + let fileManager = FileManager.default + let name = "prowl-private-symlink-wt-\(UUID().uuidString)" + let standardizedURL = URL(fileURLWithPath: "/tmp/\(name)", isDirectory: true) + try fileManager.createDirectory(at: standardizedURL, withIntermediateDirectories: true) + try Data("gitdir: /tmp/repo/.git/worktrees/\(name)\n".utf8) + .write(to: standardizedURL.appending(path: ".git")) + defer { + try? fileManager.removeItem(at: standardizedURL) + } + + let store = ShellCallStore() + let shell = ShellClient( + run: { _, arguments, _ in + await store.record(arguments) + if arguments.contains("--porcelain") { + // Git reports the raw, non-standardized path with the /private prefix. + return ShellOutput( + stdout: "worktree /private/tmp/\(name)\nHEAD abc\ndetached\n", + stderr: "", + exitCode: 0 + ) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let worktree = Worktree( + id: standardizedURL.path(percentEncoded: false), + name: name, + detail: "../\(name)", + workingDirectory: standardizedURL, + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + + _ = try await client.removeWorktree(worktree, deleteBranch: false) + + // The guard matched, so removal proceeded: the directory was relocated off its original path + // and git was asked to prune the now-missing worktree. + let stillExists = fileManager.fileExists(atPath: standardizedURL.path(percentEncoded: false)) + #expect(!stillExists) + let calls = await store.calls + #expect(calls.contains { $0.contains("prune") }) + } + @Test func forceDeleteLocalBranchUsesForceFlag() async throws { let store = ShellCallStore() let shell = ShellClient( -- 2.51.2 From 880e1f3db7c677220170722a5dc18a1e5110d309 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:44:32 +0900 Subject: [PATCH 342/528] Clarify prowl CLI skill targeting --- skills/prowl-cli/SKILL.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 09ccb1a5..47d20918 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -10,13 +10,13 @@ Use `prowl` only when the task is to inspect or control the running Prowl GUI ap ## Safe Default Workflow -Always resolve a concrete pane UUID before `read`, `send`, `key`, or `focus`. +Always resolve a concrete pane UUID before `read`, `send`, `key`, `focus`, or destructive close commands. ```bash prowl list --json ``` -Pick the target by `pane.id`, `worktree.path`, `pane.cwd`, and `pane.focused`. Do not trust tab titles: they are free-form and can lag or lie. +Pick the target by `pane.id`, `tab.id`, `worktree.id`, `worktree.path`, `pane.cwd`, and `pane.focused`. Do not trust tab titles: they are free-form and can lag or lie. If your session was launched from a Prowl pane, the focused pane is often you. Treat focused pane IDs as something to identify and avoid unless you intentionally want to operate on yourself. @@ -35,13 +35,21 @@ prowl key --pane "$pane" enter --json ## Common Recipes -Create a fresh tab for the current project, then verify it is not yourself: +Create a fresh tab for a listed worktree, then verify it is not yourself: ```bash -pane="$(prowl tab create --worktree /path/to/project --json | jq -r '.data.target.pane.id')" +project="/path/to/project" +worktree="$(prowl list --json | jq -r --arg project "$project" ' + .data.items[] + | select((.worktree.path | rtrimstr("/")) == ($project | rtrimstr("/"))) + | .worktree.id +' | head -n 1)" +pane="$(prowl tab create --worktree "$worktree" --json | jq -r '.data.target.pane.id')" test "$pane" != "$self_pane" ``` +Prefer a `worktree.id` or `worktree.name` returned by `prowl list` over a hand-typed path; list preserves normalization such as trailing slashes. Use `--path` only for the new tab's working directory inside the selected worktree. + `prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl tab create` for deterministic new terminal sessions. Run a command and capture its result: @@ -78,7 +86,7 @@ prowl pane close --pane "$pane" --json prowl tab close --tab "$tab" --json ``` -`tab close` and `pane close` require an explicit `--tab`, `--pane`, `--worktree`, or `--target`; they intentionally do not default to the currently focused pane. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: +`tab close` and `pane close` require an explicit `--tab`, `--pane`, `--worktree`, or `--target`; they intentionally do not default to the currently focused pane. For automation-created tabs, prefer the `tab.id` or `pane.id` returned by `tab create`. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: ```bash prowl pane close --pane "$pane" --force --json -- 2.51.2 From f845a8a7faee3537c0bc899722d6007817e7b3d2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 11:47:42 +0900 Subject: [PATCH 343/528] Move CLI install plan into doc-onevcat/plans, clear docs dir --- {docs => doc-onevcat}/plans/2026-04-04-cli-install-command.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {docs => doc-onevcat}/plans/2026-04-04-cli-install-command.md (100%) diff --git a/docs/plans/2026-04-04-cli-install-command.md b/doc-onevcat/plans/2026-04-04-cli-install-command.md similarity index 100% rename from docs/plans/2026-04-04-cli-install-command.md rename to doc-onevcat/plans/2026-04-04-cli-install-command.md -- 2.51.2 From 4923580042fffa019426950a824bac2a8ef892e5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 12:23:53 +0900 Subject: [PATCH 344/528] Add agent-facing Prowl documentation set under docs/ Index + overview + concepts, 15 component manuals, and 2 references (keyboard shortcuts, settings fields). Written for AI agents to read and answer human questions about Prowl. Shortcut/CLI/settings facts verified against AppShortcuts.swift, GlobalSettings/RepositorySettings, and the live prowl CLI. --- docs/README.md | 93 +++++++ docs/components/active-agents.md | 76 ++++++ docs/components/agent-detection.md | 86 +++++++ docs/components/canvas.md | 107 ++++++++ docs/components/cli.md | 234 ++++++++++++++++++ docs/components/command-palette.md | 62 +++++ docs/components/custom-actions.md | 95 +++++++ docs/components/diff-view.md | 51 ++++ docs/components/github-pull-requests.md | 62 +++++ docs/components/notifications.md | 76 ++++++ docs/components/repositories-and-worktrees.md | 173 +++++++++++++ docs/components/settings.md | 48 ++++ docs/components/shelf.md | 89 +++++++ docs/components/terminal.md | 146 +++++++++++ docs/components/updates.md | 41 +++ docs/components/view-modes.md | 49 ++++ docs/concepts.md | 111 +++++++++ docs/overview.md | 97 ++++++++ docs/reference/keyboard-shortcuts.md | 147 +++++++++++ docs/reference/settings-fields.md | 97 ++++++++ 20 files changed, 1940 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/components/active-agents.md create mode 100644 docs/components/agent-detection.md create mode 100644 docs/components/canvas.md create mode 100644 docs/components/cli.md create mode 100644 docs/components/command-palette.md create mode 100644 docs/components/custom-actions.md create mode 100644 docs/components/diff-view.md create mode 100644 docs/components/github-pull-requests.md create mode 100644 docs/components/notifications.md create mode 100644 docs/components/repositories-and-worktrees.md create mode 100644 docs/components/settings.md create mode 100644 docs/components/shelf.md create mode 100644 docs/components/terminal.md create mode 100644 docs/components/updates.md create mode 100644 docs/components/view-modes.md create mode 100644 docs/concepts.md create mode 100644 docs/overview.md create mode 100644 docs/reference/keyboard-shortcuts.md create mode 100644 docs/reference/settings-fields.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..70f26b1e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,93 @@ +# Prowl Documentation — Index + +> **Prowl** is a native macOS command center for running multiple AI coding agents +> (Claude Code, Codex, Gemini, Cursor, and friends) in parallel — each in its own +> terminal, tab, split, and git worktree. This documentation set is the operator's +> manual for the app. + +## Who this is for + +**The primary reader of these docs is an AI agent, not a human.** A human will +usually point their agent at this folder and then ask questions like _"how do I +broadcast a command to every agent at once?"_ or _"what does the Shelf view do?"_. +The agent reads the relevant file(s) here and answers precisely, then the human +drills into whatever interests them. + +Everything here is plain Markdown — no images required. It is written to be +**searched by filename and keyword**, read in full quickly, and quoted accurately. + +## How to use this documentation (for agents) + +1. **Start here.** This index maps every feature to a file. +2. **For a pitch / first impression**, read [`overview.md`](overview.md) — the + "most exciting" features, suitable for introducing Prowl to a human. +3. **For the mental model**, read [`concepts.md`](concepts.md) — the + repository → worktree → tab → pane hierarchy, view modes, and glossary. Read + this before answering anything structural. +4. **For a specific feature**, open the matching file in [`components/`](components/). + Each is a self-contained manual for one part of the app. +5. **For exact lookups** (every keyboard shortcut, every setting field), use + [`reference/`](reference/). +6. **To actually drive the app programmatically**, read + [`components/cli.md`](components/cli.md) — the `prowl` command-line interface. + +Filenames are descriptive on purpose. A keyword search such as +`grep -ri "broadcast" docs/` or `grep -ri "worktree" docs/` will land on the +right file. + +## Component manuals + +Each file under `components/` documents one part: what it is, how to open it, +its keyboard shortcuts, detailed behavior, settings, and gotchas. + +| File | What it covers | +|------|----------------| +| [`components/repositories-and-worktrees.md`](components/repositories-and-worktrees.md) | The sidebar: adding repositories, creating / opening / archiving / deleting git worktrees, plain (non-git) folders, pinning, ordering, repository icons & colors. | +| [`components/terminal.md`](components/terminal.md) | Terminal tabs, splits/panes, surfaces, the Ghostty engine, tab titles & icons, shell integration, scrollback, font size. | +| [`components/canvas.md`](components/canvas.md) | Canvas view — a zoomable board of live terminal cards, multi-select, and **broadcast a command to every agent at once**. | +| [`components/shelf.md`](components/shelf.md) | Shelf view — worktrees as vertical "book spines" you flip through from the keyboard. | +| [`components/view-modes.md`](components/view-modes.md) | Switching between Normal / Canvas / Shelf layouts and what each is good for. | +| [`components/command-palette.md`](components/command-palette.md) | `⌘P` searchable command launcher; every action category it exposes. | +| [`components/active-agents.md`](components/active-agents.md) | The Active Agents panel: a live list of every running agent and its status, with one-click jump-to-agent. | +| [`components/agent-detection.md`](components/agent-detection.md) | How Prowl knows an agent is Working / Blocked / Idle / Done, which agents it recognizes, and how the status indicator works. | +| [`components/notifications.md`](components/notifications.md) | Agent-finished reminders, command-finished notifications, the bell/unread indicators, and Dock badge/bounce. | +| [`components/diff-view.md`](components/diff-view.md) | The Diff window (`⌘⇧Y`): working-tree changes vs HEAD, split/unified modes, line-change badges. | +| [`components/github-pull-requests.md`](components/github-pull-requests.md) | GitHub PR integration via `gh`: PR status, CI checks, merge/close/re-run actions from the command palette. | +| [`components/custom-actions.md`](components/custom-actions.md) | Run Script (`⌘R`/`⌘.`), Setup & Archive scripts, and per-repo Custom Commands with their own buttons & hotkeys. Injected env vars. | +| [`components/settings.md`](components/settings.md) | The Settings window (`⌘,`): every tab and what it controls. | +| [`components/updates.md`](components/updates.md) | Sparkle auto-updates: channels, auto-check, `⌘⇧U`. | +| [`components/cli.md`](components/cli.md) | The `prowl` CLI — let an agent inspect and drive panes (`list`, `read`, `send`, `key`, `focus`, `tab`, `pane`, `open`). | + +## Reference (exact lookups) + +| File | What it covers | +|------|----------------| +| [`reference/keyboard-shortcuts.md`](reference/keyboard-shortcuts.md) | The complete, authoritative keyboard-shortcut table, grouped by category, with command IDs and remappability. | +| [`reference/settings-fields.md`](reference/settings-fields.md) | Every `GlobalSettings` and per-repository setting field: name, type, default, effect, and on-disk location. | + +## Find by task (reverse lookup) + +| I want to… | Read | +|------------|------| +| Run many agents side by side and see them all | [`components/canvas.md`](components/canvas.md), [`components/shelf.md`](components/shelf.md) | +| Send one command to every agent simultaneously | [`components/canvas.md`](components/canvas.md) (broadcast) | +| Spin up a new branch/worktree for a new agent | [`components/repositories-and-worktrees.md`](components/repositories-and-worktrees.md) | +| Find / run any action by name | [`components/command-palette.md`](components/command-palette.md) | +| See which agents are working vs waiting on me | [`components/active-agents.md`](components/active-agents.md), [`components/agent-detection.md`](components/agent-detection.md) | +| Get notified when an agent finishes | [`components/notifications.md`](components/notifications.md) | +| Bind `swift build` / `npm test` to a button + hotkey | [`components/custom-actions.md`](components/custom-actions.md) | +| Review what an agent changed | [`components/diff-view.md`](components/diff-view.md) | +| Open / merge / re-run CI on a pull request | [`components/github-pull-requests.md`](components/github-pull-requests.md) | +| Drive a pane from a script or another agent | [`components/cli.md`](components/cli.md) | +| Look up a keyboard shortcut | [`reference/keyboard-shortcuts.md`](reference/keyboard-shortcuts.md) | +| Change app behavior / a setting | [`components/settings.md`](components/settings.md), [`reference/settings-fields.md`](reference/settings-fields.md) | + +## Conventions used in these docs + +- Modifier symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control · **↩** Return · **⌫** Delete (Backspace). +- Default shortcuts are shown. Most app shortcuts are **user-remappable** in + Settings → Shortcuts, so a human's actual keys may differ — when in doubt, + the [Command Palette](components/command-palette.md) shows the live binding + next to each action, and the `prowl` CLI never depends on keybindings. +- Requirements: **macOS 26.0+**. +- Website: · Source: diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md new file mode 100644 index 00000000..ba75ecf8 --- /dev/null +++ b/docs/components/active-agents.md @@ -0,0 +1,76 @@ +# Active Agents Panel + +> A live list of every running agent across all worktrees, with status and +> one-click jump-to-agent. Your mission-control roster. + +**Keywords:** active agents, agents panel, running agents, status list, working, blocked, done, idle, jump to agent, roster + +**Related:** [agent-detection](agent-detection.md) · [notifications](notifications.md) · [command-palette](command-palette.md) · [canvas](canvas.md) + +## What it is + +A collapsible panel at the bottom of the left sidebar that lists **every agent +currently detected across all worktrees/tabs/panes**, in real time. Each row shows +the agent (Claude, Codex, …), its repository/branch context, and a **status pill**. +Click a row to jump straight to that agent's pane. + +**Toggle:** `⌘⌥P` (`toggle_active_agents_panel`), the sidebar footer button, or +Command Palette → "Toggle Active Agents Panel". + +## What each row shows + +``` +[icon] AgentName · RepositoryName [status pill] + tab title or branch (secondary) +``` + +- **Icon** — the detected agent's icon (falls back to a sparkle). +- **Title** — agent name + repository (repo color-coded). +- **Subtitle** — the tab title (if `showActiveAgentTabTitles`) or branch name. +- **Status pill** — one of: + +| Status | Meaning | Look | +|--------|---------|------| +| **Working** | actively processing | orange, animated indicator | +| **Blocked** | waiting for you (a prompt) | red | +| **Done** | finished, not yet seen | blue | +| **Idle** | nothing running / seen | grey | + +The list is sorted by urgency: **Blocked → Working → Done → Idle**, then by most +recently changed. (See [agent-detection](agent-detection.md) for how these states +are determined.) + +## Interactions + +- **Click a row** → focuses that worktree + tab + pane and brings Prowl forward. A + **Done** row downgrades to **Idle** once focused. +- **Keyboard navigation:** `⌃⌥↓` next agent, `⌃⌥↑` previous agent (wraps). +- **Resize** the panel by dragging its top edge (height is remembered). +- **Auto-show:** if `autoShowActiveAgentsPanel` is on and the panel is hidden, a + newly detected agent opens it automatically. + +## Empty state + +When nothing is running: "New agents will appear here." + +## Settings + +- `autoShowActiveAgentsPanel` — pop the panel open when an agent appears. +- `showActiveAgentTabTitles` — show each agent's tab title instead of its branch. +- Panel height and hidden/shown state are persisted automatically. + +## Relationship to other features + +- **Agent detection** ([agent-detection](agent-detection.md)) feeds this panel. +- **Notifications** ([notifications](notifications.md)) fire on the same + transitions (e.g. an agent finishing → Done). +- **Canvas** ([canvas](canvas.md)) is the spatial counterpart — cards light up on + the same completion signal. + +## Gotchas for agents + +- "Blocked" is the actionable state — it means an agent is **waiting on a human** + (a permission/confirmation prompt). Surface these first. +- This panel reflects **detected** agents; detection is best-effort (see + [agent-detection](agent-detection.md)). For programmatic certainty about a + pane's task status, use [`prowl list`](cli.md) → `task.status`. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md new file mode 100644 index 00000000..0bb7d95a --- /dev/null +++ b/docs/components/agent-detection.md @@ -0,0 +1,86 @@ +# Agent Detection + +> How Prowl knows there's an agent in a pane and whether it's Working, Blocked, +> Idle, or Done — and which agents it recognizes. + +**Keywords:** agent detection, claude, codex, gemini, cursor, working, blocked, idle, done, status, process probe, screen heuristics, indicator, spinner + +**Related:** [active-agents](active-agents.md) · [notifications](notifications.md) · [terminal](terminal.md) + +## What it is + +Prowl continuously inspects each terminal pane to decide whether a coding agent is +running and what state it's in. That signal drives the +[Active Agents panel](active-agents.md), the per-tab activity indicator, +[Canvas](canvas.md) cards lighting up, and [notifications](notifications.md). + +## Agents it recognizes + +Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, +Kimi, Droid, Amp, and Pi. Detection covers common wrappers (node, python, bun, +bash, etc.) so agents launched indirectly are still found. + +## How detection works (two stages) + +1. **Process probe.** Prowl reads the pane's foreground process group and matches + process names / argv against known agent executables, scoring argv[0] highest, + then process name, then command-line tokens. +2. **Screen heuristics.** It scans the last ~24 non-blank lines of the pane for + agent-specific UI cues — e.g. "Esc to interrupt" (working), confirmation/ + permission prompts (blocked), idle prompts. Each agent has its own patterns + (including spinner glyphs: braille frames, symbol cycles, Cursor's hexagons, + Kimi's moon phases, etc.). + +To avoid flicker, detection **stabilizes**: it tolerates several consecutive +misses before declaring an agent gone, and Claude specifically gets a short +(~1.2s) hold so brief pauses between thinking and output don't drop it out of +"working". + +## The state machine + +**Raw states:** `working`, `blocked`, `idle`, `unknown`. + +**Display states** (what you see): + +| Display | Derived from | Meaning | +|---------|--------------|---------| +| **Working** | raw `working` | actively processing | +| **Blocked** | raw `blocked` | waiting for the user (a prompt) | +| **Done** | raw `idle` + **unseen** | just finished; you haven't looked yet | +| **Idle** | raw `idle` + **seen** | nothing running | + +A **Done** pane becomes **Idle** the moment you focus it. + +## How often it runs + +- ~**300 ms** when an agent is present or you're typing (active). +- ~**2 s** when idle. + +The heavier process probe is throttled (≈ every few seconds unless something +changes) so many panes don't add up to high CPU. Status indicators redraw on a +coarse tick rather than every frame for the same reason. + +## The indicator + +In tabs and the Active Agents panel, a **Working** agent shows an animated spinner +(the per-agent style detected on screen); **Blocked** is a distinct +attention color; **Idle/Done** are static. The "working" animation style is also +configurable in spirit — Prowl uses a bagua/trigram-style spinner in the agents +list. + +## Settings + +Agent detection is on by default. Related toggles live in the Active Agents and +Notifications settings (e.g. `autoShowActiveAgentsPanel`, +`showActiveAgentTabTitles`). + +## Gotchas for agents + +- Detection is **heuristic and best-effort**. A short-lived command between polls + can be missed; an unusual prompt might read as the wrong state. +- **"Blocked"** is the one that means *a human is needed* — it's typically a + permission/confirmation prompt the agent is waiting on. +- For deterministic automation, don't rely on the visual status; use + [`prowl list`](cli.md) (`task.status`) and confirm a screen is finished with + `prowl read --wait-stable` — `task.status` can flip to idle before a TUI has + painted its last frame. diff --git a/docs/components/canvas.md b/docs/components/canvas.md new file mode 100644 index 00000000..37014b29 --- /dev/null +++ b/docs/components/canvas.md @@ -0,0 +1,107 @@ +# Canvas View + +> A zoomable board where every agent is a live, interactive terminal card — +> watch them all at once, and broadcast one command to every selected agent. + +**Keywords:** canvas, board, cards, grid, zoom, pan, broadcast, multi-select, select all, arrange, organize, expand card, bird's-eye, overview of agents + +**Related:** [view-modes](view-modes.md) · [shelf](shelf.md) · [terminal](terminal.md) · [active-agents](active-agents.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) + +## What it is + +Canvas turns every open terminal tab into a **card** floating on an infinite, +zoomable board. Each card is a **live, fully interactive terminal** — not a +screenshot — so you can watch several agents work simultaneously, then click into +any card to type. A card whose task just finished **lights up** so you can see at +a glance who's done. + +**Toggle Canvas:** `⌘⌥↩` (`toggle_canvas`). Toggling has no effect if no worktrees +are open. + +## The signature feature: broadcast to every agent + +Canvas lets you **type a command once and send it to many agents at once**: + +1. **Select multiple cards.** `⌘`-click cards to add them to the selection, or + press **`⌘⌥A` (Select All Cards)**. With two or more selected, Canvas enters + **broadcasting** mode (the toolbar shows "Broadcasting to N cards"). +2. **Type into the primary (focused) card.** What you type — text and submitted + commands — is **mirrored to every selected card**. +3. Great for "run the tests", "commit", "`/clear`", "continue", etc. across a + whole fleet of agents in one keystroke. + +Selection controls: +- `⌘`-click a card body → toggle it in/out of the selection. +- `⌘`-click an already-selected card → make it the **primary** (the one you type + into) without deselecting the rest. +- Click empty canvas, or press **Esc** → clear the selection. + +## Working with cards + +| Operation | How | +|-----------|-----| +| Focus (enter) a card | Single-click its body | +| Move a card | Drag its title bar | +| Resize a card | Drag its edges/corners | +| Expand / restore a card | `⌘⌥E`, double-click the title bar, or the title-bar expand button — fills the viewport with that card; click the dimmed background or `⌘⌥E` again to restore | +| Close a card (its tab) | The **×** in the card title bar | +| Pan the board | Drag empty space (or middle-drag) | +| Zoom | `⌘` + scroll, or pinch (≈0.25×–2×) | + +## Arranging the board + +- **`⌘⌥A` Select All Cards** — select every card (entering broadcast). +- **`⌘⌥R` Arrange Cards** — bin-pack the existing cards to fit the viewport's + aspect ratio, preserving each card's size, then fit-to-view. +- **`⌘⌥G` Organize Cards** — reset every card to a uniform grid (≈√N columns) at a + default size and center them. + +These also appear as toolbar buttons. There's a `?` help popover (bottom-left) +explaining pan/zoom/expand. + +## Visual cues + +- **Focused card:** bright accent border. **Selected (multi-select):** medium + accent border. **Unselected:** faint border. +- **Finished/unread task:** the card title bar turns orange. +- During selection/broadcast, non-focused cards get a subtle shield overlay so you + don't accidentally type into the wrong one. +- The left nav band is tinted with the focused card's repository color + (`windowTintMode`). + +## Layout & sizing + +- Default card size adapts to screen width (roughly 800×550 on a 14", larger on a + 27"). Cards have generous min/max bounds and snap-animate on resize. +- On first entry, cards auto-arrange into a balanced grid. Positions, sizes, and + z-order are remembered for the session and restored when you return to Canvas. +- Closing a card's last tab prunes it; focus advances to a neighbor. + +## Many agents at once + +Canvas is built for scale: the grid grows with the number of cards, Arrange packs +them to your viewport, and you can zoom out to see everything then zoom into a +region. Cards are real terminals, so the ones off-screen are occlusion-managed for +performance. + +## Settings that affect Canvas + +- `defaultViewMode` — launch directly into Canvas. +- `windowTintMode` / repository colors — card and nav tinting. +- `showRunButtonInToolbar` — whether the Run button appears in the Canvas toolbar. + +## When to recommend Canvas vs Shelf + +- **Canvas** = spatial, see-everything, broadcast to many. Best when you're + supervising a fleet or fanning the same command out. +- **Shelf** = linear, keyboard-fast triage of many worktrees one at a time. + +See [view-modes](view-modes.md) for the comparison. + +## Gotchas for agents + +- Broadcasting requires **≥2 selected cards**; it mirrors what's typed into the + **primary** card. +- Toggling Canvas with nothing open is a no-op. +- A "card" maps to a **tab**, identified by `tab.id` — the same ID the + [`prowl` CLI](cli.md) uses. diff --git a/docs/components/cli.md b/docs/components/cli.md new file mode 100644 index 00000000..36427bdd --- /dev/null +++ b/docs/components/cli.md @@ -0,0 +1,234 @@ +# The `prowl` CLI + +> A command-line interface to inspect and drive the running Prowl app — so you (or +> an agent) can list panes, read their screens, run commands and capture output, +> send keystrokes, focus, and open/close tabs and panes programmatically. + +**Keywords:** prowl cli, command line, prowl list, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, pane id, automation, json, capture, socket + +**Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) + +> This is the reference for the `prowl` binary. For an opinionated, safety-first +> *workflow* guide (recipes, pitfalls, quoting), the repository also ships the +> `prowl-cli` skill at `skills/prowl-cli/SKILL.md` — same tool, task-oriented. + +## What it is & when to use it + +`prowl` talks to a running Prowl GUI app over a Unix socket. Reach for it whenever +the task is to act on a pane **other than the current one** — check a sibling +agent, run something in another tab and grab the output, focus a worktree, open a +project, or close a scratch tab. It is **not** for ordinary editing/building +inside a repo, and not for how-to questions about Prowl's settings. + +## Install + +From the app: **Settings → Advanced → Install Command Line Tool**, or Command +Palette → "Install Command Line Tool". This symlinks `prowl` into +`/usr/local/bin` (prompting for admin if needed). + +## Global options + +- `--json` — emit structured JSON (recommended for automation). Each command's + JSON has a `schema_version` like `prowl.cli.list.v1`. +- `--no-color` — disable colored text output (implied by `--json`). + +Success envelope: `{ "ok": true, "command": "...", "schema_version": "...", "data": {...} }`. +Error envelope: `{ "ok": false, "command": "...", "error": { "code": "...", "message": "..." } }`. +Exit code is 0 on success, non-zero on failure. **Parser errors print plain text** +(not JSON) even with `--json`, because parsing happens before execution — always +check the exit code before piping to `jq`. + +## Targeting model + +Most commands accept one selector (mutually exclusive): + +- `--pane ` — a specific pane (safest for automation). +- `--tab ` — a specific tab (its focused/first pane). +- `--worktree ` — a worktree (its selected/first tab → focused/first + pane). +- `-t, --target ` — auto-resolve: tries pane UUID, then tab UUID, then + worktree id/name/path. +- **No selector** → the *current* focus (focused worktree → selected tab → focused + pane). Some commands (close) refuse this for safety. + +**Rules:** at most one selector (else `INVALID_ARGUMENT`); prefer explicit +`--pane`. The focused pane is not stable — `open` and `focus` change it. + +> **Never target by tab title.** Titles are free-form and can lie. Resolve a +> concrete `pane.id` from `prowl list --json` first. + +## Commands + +### `prowl list` +Snapshot of all worktrees → tabs → panes. No selectors. + +```bash +prowl list --json +``` +Each item contains: +- `worktree`: `id`, `name`, `path`, `root_path`, `kind` (`git`|`plain`) +- `tab`: `id`, `title`, `selected` +- `pane`: `id`, `title`, `cwd`, `focused` +- `task`: `status` (`running` | `idle` | null) + +`task.status` is the same agent activity signal described in +[agent-detection](agent-detection.md). It's good for coordination but can flip to +idle **before** a TUI finishes painting — confirm with `read --wait-stable`. + +Find your own pane (to avoid operating on yourself): +```bash +self_pane="$(prowl list --json | jq -r '.data.items[] | select(.pane.focused==true) | .pane.id')" +``` + +### `prowl read [target]` +Read a pane's content. + +- `--last ` — last N lines (scrollback + screen); omit for a full snapshot. +- `--wait-stable` — re-read until the screen stops changing (best for live TUIs). +- `--stable-interval <50–5000ms>` (default 200), `--stable-period <100–60000ms>` + (default 800), `--wait-timeout <1–300s>` (default 10) — tune the stable wait. + +```bash +prowl read --pane "$pane" --last 200 --wait-stable --json +``` +Response includes `mode` (snapshot|last), `source` (screen|scrollback|mixed), +`truncated`, `line_count`, `text`, and (when waiting) `stabilized`, `waited_ms`, +`samples`. **`truncated: false` with fewer lines than `--last` just means the pane +has less history — don't retry.** `truncated: true` flags a possibly-incomplete +read. + +### `prowl send [target] [text]` +Type into a pane, optionally wait for completion and capture output. + +- Text source: argv, or stdin if no argv (don't provide both → `EMPTY_INPUT`). +- `--capture` — wait and capture the command's output (screen diff). **Requires + OSC 133 shell integration** on the target; sends a trailing Enter; **cannot** + combine with `--no-wait` or `--no-enter`. +- `--no-wait` — fire and forget. +- `--no-enter` — pre-fill text without submitting (submit later with `key enter`). +- `--timeout <1–300s>` — wait budget (default 30). + +```bash +prowl send --pane "$pane" 'npm test' --capture --timeout 60 --json # run & capture +prowl send --pane "$pane" 'long-task' --no-wait --json # don't wait +printf '%s\n' 'echo a' 'echo b' | prowl send --pane "$pane" --capture # stdin +``` +Response: `input` (source/characters/bytes/trailing_enter_sent), `wait` +(`exit_code`, `duration_ms`) when waiting, and `capture` (`text`, `line_count`, +`truncated`) when capturing. If the pane lacks shell integration you get +`CAPTURE_UNSUPPORTED` — drop `--capture` and use `read --wait-stable`, or redirect +the command's output to a file and `cat` it. + +### `prowl key [target] [token]` +Send a keystroke. + +- `--repeat <1–100>` — repeat the key. +- Tokens: named keys (`enter`/`return`, `esc`, `tab`, `backspace`, `delete`, + `space`, arrows `up`/`down`/`left`/`right`, `pageup`/`pagedown`, `home`/`end`, + `f1`–`f12`, punctuation), single characters (`a`–`z`, `0`–`9`, etc.), and + modifier combos joined with `-`: `cmd`/`command`, `shift`, `opt`/`option`/`alt`, + `ctrl`/`control` — e.g. `ctrl-c`, `cmd-k`, `shift-tab`, `cmd-shift-p`. + +```bash +prowl key --pane "$pane" enter --json +prowl key --pane "$pane" down --repeat 10 --json +``` + +### `prowl focus [target]` +Focus a worktree/tab/pane and bring Prowl to the front. + +```bash +prowl focus --pane "$pane" --json +prowl focus --worktree MyApp --json +``` + +### `prowl tab create` +Create a new terminal tab (deterministic — unlike `open`). + +- `--path

` — working directory (must be inside the worktree root). +- Selectors choose the worktree (defaults to current). + +```bash +pane="$(prowl tab create --worktree "$wt" --json | jq -r '.data.target.pane.id')" +``` + +### `prowl tab close` / `prowl pane close` +Close a tab or a pane. **Require an explicit selector** (`--tab`/`--pane`/ +`--worktree`/`--target`) — they intentionally do **not** default to the focused +pane. If the target has protected agent work or a long-running command, Prowl may +ask for GUI confirmation; `--force` skips it (use only after positively +identifying the target). + +```bash +prowl pane close --pane "$pane" --json +prowl tab close --tab "$tab" --force --json +``` + +### `prowl open [path]` (the default command) +Navigate Prowl to a path (or bring it to front with no argument). It may focus an +existing pane or create a tab — it is **not** a deterministic "new pane" command. +For a guaranteed fresh shell, use `tab create`. + +```bash +prowl open ~/projects/app # open/focus that project +prowl open # just bring Prowl forward +``` +Supports `~` and `file://`. Reports `resolution` (no-argument / exact-root / +inside-root / new-root), `app_launched`, `brought_to_front`, `created_tab`, and a +`target`. + +## Transport & app launch + +- Socket: `~/Library/Application Support/com.onevcat.prowl/cli.sock` (override with + `PROWL_CLI_SOCKET`). If that primary path would exceed the AF_UNIX 104-byte + limit (e.g. a very long home-directory path), it falls back to + `$TMPDIR/prowl-cli.sock`. +- If the app isn't running, the CLI launches it (`open -a Prowl`) and waits up to + ~15s for the socket — except when `PROWL_CLI_SOCKET` is set. +- Framed protocol: 4-byte length prefix + JSON, both directions. + +## Error codes + +| Code | Meaning / recovery | +|------|--------------------| +| `APP_NOT_RUNNING` | Can't reach Prowl. Ask before restarting it. | +| `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID. | +| `TARGET_NOT_UNIQUE` | Selector matched several — be more specific (use `--pane`). | +| `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | +| `EMPTY_INPUT` | `send` got neither argv nor stdin (or both). | +| `INVALID_ARGUMENT` | Bad flag/combo (e.g. `--capture --no-wait`) or out-of-range value. | +| `CAPTURE_UNSUPPORTED` | Target lacks OSC 133 — drop `--capture`, use `read --wait-stable`. | +| `WAIT_TIMEOUT` | Command didn't finish in time — raise `--timeout` or use `--no-wait`. | +| `UNSUPPORTED_KEY` / `INVALID_REPEAT` | Check `prowl key --help`. | +| `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`tab create` path. | +| `LAUNCH_FAILED` | App launch or socket wait failed. | +| `*_FAILED` (`LIST_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`) | The action itself failed. | + +## Safety & self-targeting + +- If your shell runs **inside a Prowl pane**, the focused pane is probably *you*. + Identify and avoid it (the `self_pane` snippet above) so you don't `key enter` + into your own session. +- Close commands require explicit targets and may prompt for GUI confirmation on + protected work; `--force` bypasses the prompt. + +## A complete loop (run, read, clean up) + +```bash +self_pane="$(prowl list --json | jq -r '.data.items[]|select(.pane.focused==true)|.pane.id')" +pane="$(prowl tab create --worktree MyApp --json | jq -r '.data.target.pane.id')" +test "$pane" != "$self_pane" +prowl send --pane "$pane" 'swift build' --capture --timeout 300 --json +prowl read --pane "$pane" --last 100 --wait-stable --json +prowl pane close --pane "$pane" --json +``` + +## Gotchas for agents (quick list) + +- Resolve a `pane.id` before `read`/`send`/`key`/`focus`/close — never trust tab + titles. +- `--capture` needs shell integration; otherwise `read --wait-stable` or file + redirection. +- `open` is navigation, not a guaranteed new pane — use `tab create`. +- In zsh, don't name a variable `status` (it's readonly). +- Pass shell values into `jq` with `--arg`. diff --git a/docs/components/command-palette.md b/docs/components/command-palette.md new file mode 100644 index 00000000..f8d70a18 --- /dev/null +++ b/docs/components/command-palette.md @@ -0,0 +1,62 @@ +# Command Palette + +> The `⌘P` searchable launcher for (almost) every action in Prowl. Type a name, +> hit Return. + +**Keywords:** command palette, ⌘P, cmd+p, search actions, launcher, fuzzy search, run command, quick action + +**Related:** [keyboard-shortcuts](../reference/keyboard-shortcuts.md) · [github-pull-requests](github-pull-requests.md) · [custom-actions](custom-actions.md) · [active-agents](active-agents.md) + +## What it is + +A fuzzy-searchable overlay that surfaces dozens of actions — view toggles, +worktree operations, pull-request actions, terminal commands, app commands, and +your custom commands. It's the fastest way to reach anything whose shortcut you +don't remember, and it shows each action's **live keyboard binding** next to it. + +**Open:** `⌘P` (`command_palette`), or the toolbar button. **Close:** `Esc`. + +## Using it + +- **Type** to fuzzy-match against action titles and hidden keyword aliases. +- **↑ / ↓** (or `⌃P` / `⌃N`) move the selection; **Return** runs it; **Esc** closes. +- **Empty query** shows a **Recent** section (your recently used actions, by + recency) above a **Suggested** section (curated defaults) — up to 8 rows. +- **With a query** it's a flat list ranked by match quality and recency. + +## What it can do (categories) + +The available actions depend on context (e.g. PR actions appear only when the +selected worktree has a pull request). + +- **View / layout:** Toggle Sidebar, Toggle Active Agents Panel, Toggle Canvas, + Toggle Shelf, Show Diff, and Canvas actions (Expand/Arrange/Organize/Select-All + cards). +- **Navigation:** Reveal in Finder, Copy Path, Reveal in Sidebar, Jump to Latest + Unread, and select-a-specific-worktree entries. +- **Worktree:** New Worktree, Refresh Worktrees, View Archived Worktrees, Run + Script, Stop Script, Rename Branch, Pin/Unpin, Delete Worktree, Change Tab Icon. +- **Pull request** (when a PR exists): Open PR, Mark Ready for Review, Copy failing + job URL, Copy CI Failure Logs, Re-run Failed Jobs, Open Failing Check Details, + Merge PR, Close PR. See [github-pull-requests](github-pull-requests.md). +- **Terminal:** font size, find, tab/pane selection, new/close terminal (mirrors + the Ghostty-bridged commands; search-only). +- **App:** Check for Updates, Open Settings, Open Repository, **Install Command + Line Tool**, Repo Settings. +- **Custom commands:** your per-repo Custom Commands appear here too. +- **Debug** (Debug builds only): toast/update/dock simulations. + +## Behavior notes + +- Recency is remembered, so frequently used actions float up in the empty-query + suggestions. +- PR and Canvas entries appear/disappear as state changes (PR present, Canvas + active, etc.). +- There are no user settings for the palette; ranking is automatic. + +## Gotchas for agents + +- If a human can't find an action, the palette is the universal answer: "press + `⌘P` and type its name." +- The palette shows the **live** (possibly remapped) binding for each action — a + reliable way to discover a human's actual keys. diff --git a/docs/components/custom-actions.md b/docs/components/custom-actions.md new file mode 100644 index 00000000..271464ed --- /dev/null +++ b/docs/components/custom-actions.md @@ -0,0 +1,95 @@ +# Custom Actions, Scripts & Run Commands + +> Turn repeated commands into buttons and hotkeys: the Run Script, the automatic +> Setup/Archive scripts, and per-repo Custom Commands. + +**Keywords:** custom command, custom action, run script, setup script, archive script, button, hotkey, PROWL_WORKTREE_PATH, PROWL_ROOT_PATH, close on success, split, terminal input, swift build, npm test, claude -p + +**Related:** [repositories-and-worktrees](repositories-and-worktrees.md) · [terminal](terminal.md) · [settings](settings.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) + +## Overview + +Prowl has four distinct mechanisms for "run my command": + +| Mechanism | Scope | When it runs | Configured in | +|-----------|-------|--------------|---------------| +| **Run Script** | one per repo | on demand (`⌘R`) | Repo Settings → Run Script | +| **Setup Script** | one per repo | automatically after a worktree is created | Repo Settings → Setup Script | +| **Archive Script** | one per repo | automatically before a worktree is archived | Repo Settings → Archive Script | +| **Custom Commands** | many per repo | on demand (button / hotkey / palette) | Repo Settings → Custom Commands | + +All scripts run with these **environment variables** injected: +- `PROWL_WORKTREE_PATH` — the active worktree's directory. +- `PROWL_ROOT_PATH` — the repository root. + +## Run Script (`⌘R` / `⌘.`) + +A single per-repo command you launch on demand. + +- **Run:** `⌘R` (`run_script`) — runs the repo's Run Script in the focused + worktree. If no Run Script is set, you're prompted for one. +- **Stop:** `⌘.` (`stop_script`). +- A toolbar **Run** button is shown when `showRunButtonInToolbar` is on. +- While running, the worktree shows a running status; the tab is title-locked to + the command until it finishes. + +## Setup Script (automatic on create) + +Set a per-repo **Setup Script** to bootstrap every new worktree — install deps, +copy env files, warm caches, etc. It runs automatically right after worktree +creation, in the new worktree, with `PROWL_WORKTREE_PATH` / `PROWL_ROOT_PATH` set. + +## Archive Script (automatic on archive) + +A per-repo **Archive Script** runs **before** a worktree is archived — tear down +servers, clean artifacts, etc. If it exits non-zero, archiving stops and the +worktree stays active, with the error shown. + +## Custom Commands (buttons + hotkeys) + +The most flexible option: define **multiple** named actions per repository, each +with its own SF Symbol icon, shell command, execution mode, optional **close on +success**, and optional **keyboard shortcut**. + +**Execution modes:** + +| Mode | What it does | Supports "close on success" | +|------|--------------|-----------------------------| +| **Shell script** | runs in a new terminal tab | ✅ | +| **Terminal input** | types the command into the focused pane | ❌ | +| **Split** | runs in a new split of the focused pane (direction: right/left/down/top) | ✅ | + +**Close on success** auto-closes the tab/split shortly after the command exits 0 +(a brief delay lets you see the final output). + +**Hotkeys:** each Custom Command can carry a `⌘`/`⇧`/`⌥`/`⌃` shortcut. Within the +focused repository, a Custom Command's hotkey takes **precedence over app +shortcuts**; conflicts (with reserved app actions or other custom commands) are +detected when you record the key, and you choose Replace / Cancel. + +**Where they appear:** as buttons in the UI, in the Worktrees menu, and in the +[Command Palette](command-palette.md). Custom Commands are also stored per repo, +in `~/.prowl/repo//prowl.onevcat.json`. + +## Example uses + +- `swift build` on `⌘B` (shell script). +- `npm run dev` as a split that stays open (split mode, no close-on-success). +- `claude -p "review the current diff and summarize risks"` (shell script) — a + one-keystroke AI assistant. +- `git push && gh pr create --fill` on a hotkey. + +## Settings recap + +- Per repo (Repo Settings): `runScript`, `setupScript`, `archiveScript`, and the + Custom Commands list. +- Global: `showRunButtonInToolbar`, `showDefaultEditorInToolbar`. + +## Gotchas for agents + +- The three named scripts (`runScript`/`setupScript`/`archiveScript`) are + **one each per repo**; Custom Commands are the "many" option. +- Scripts always have `PROWL_WORKTREE_PATH` and `PROWL_ROOT_PATH` available — use + them instead of assuming a working directory. +- "Terminal input" mode types into whatever pane is focused — be sure of the + target (the same caution as [`prowl send`](cli.md)). diff --git a/docs/components/diff-view.md b/docs/components/diff-view.md new file mode 100644 index 00000000..758458f8 --- /dev/null +++ b/docs/components/diff-view.md @@ -0,0 +1,51 @@ +# Diff View + +> A dedicated window showing what changed in a worktree vs HEAD — review an +> agent's work before you commit. + +**Keywords:** diff, diff view, changes, review, working tree, HEAD, split, unified, line changes, ⌘⇧Y, show diff + +**Related:** [repositories-and-worktrees](repositories-and-worktrees.md) · [github-pull-requests](github-pull-requests.md) · [command-palette](command-palette.md) + +## What it is + +The Diff window shows all changes in the selected worktree's working directory +compared against **HEAD** — exactly what an agent has modified. It's a fast way to +review before committing or merging. + +**Open:** `⌘⇧Y` (`show_diff`), or Command Palette → "Show Diff". The window is a +persistent singleton (remembers size/position) and auto-refreshes when it regains +focus. `⌘W` closes it. + +## What it shows + +- A **file list** sidebar of changed files, each with a colored status badge: + - **M** Modified (orange) · **A** Added/untracked (green) · **D** Deleted (red) · + **R** Renamed / **C** Copied (blue) · **?** Unknown (grey). +- The selected file's diff, comparing the **HEAD** version (`git show HEAD:path`) + against the **on-disk** version. +- Both tracked changes and **untracked new files** are included. + +## Modes & interactions + +- **Split** (side-by-side, default) or **Unified** view — toggle via the toolbar + picker. +- Click a file in the list to view its diff. +- Auto-refresh on focus keeps it current as the agent keeps working. + +## Line-change badges elsewhere + +Repositories can show **line-change badges** (additions/deletions) on worktree +rows, controlled per repo by `observeLineDiffsAutomatically` (on by default). +Disable it for very large repos if it's expensive. + +## Availability + +Diff is a **git-only** feature — it's unavailable for plain (non-git) folders. + +## Gotchas for agents + +- The diff is **working-tree vs HEAD**, not vs the base branch — it reflects + uncommitted changes in that worktree. +- For changes already in a pull request (vs the base branch, with CI), see + [github-pull-requests](github-pull-requests.md). diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md new file mode 100644 index 00000000..c20d2148 --- /dev/null +++ b/docs/components/github-pull-requests.md @@ -0,0 +1,62 @@ +# GitHub / Pull Request Integration + +> See a worktree's PR status and CI, and act on it — merge, mark-ready, re-run +> failed jobs, copy failure logs — without leaving Prowl. + +**Keywords:** github, pull request, PR, CI, checks, merge, mark ready, re-run, failing jobs, code host, gh cli + +**Related:** [command-palette](command-palette.md) · [repositories-and-worktrees](repositories-and-worktrees.md) · [diff-view](diff-view.md) · [settings](settings.md) + +## What it is + +For git repositories hosted on GitHub, Prowl fetches the pull request associated +with a worktree's branch and exposes its status and actions. It works through the +**`gh` CLI**, so it uses your existing `gh auth` — Prowl never handles tokens +itself. + +## What it shows + +- PR number, title, state (open/closed/merged), draft status. +- Additions/deletions, author, base/head branches. +- Review decision (approved / changes requested / pending). +- **CI status:** a rollup of all checks (success / failure / in-progress / + expected / skipped) with failing/success counts and per-check detail URLs. +- **Merge readiness:** Prowl evaluates blockers in order — merge conflicts, + changes requested, failed checks, other non-mergeable states. + +PR status can surface as a badge on the worktree and as a summary in the command +palette. + +## Actions (via Command Palette, when a PR exists) + +Open the [Command Palette](command-palette.md) (`⌘P`) on a worktree that has a PR: + +- **Open Pull Request on GitHub** — open it in the browser. (`⌘⌃G` "Open on Code + Host" also opens the PR/repo page.) +- **Mark PR Ready for Review** — convert a draft to ready (only when it's a draft). +- **Copy failing job URL** — copy the first failing check's URL. +- **Copy CI Failure Logs** — extract and copy the failed run's logs (great to hand + back to an agent to fix). +- **Re-run Failed Jobs** — re-trigger the latest failed workflow. +- **Open Failing Check Details** — open a failing check in the browser. +- **Merge PR** — merge when mergeable (not draft, checks pass, no conflicts, no + changes requested). Merge strategy comes from `pullRequestMergeStrategy` + (global) or the per-repo override (`merge` / `squash` / `rebase`). +- **Close PR** — close an open PR. + +## Requirements & settings + +- The **`gh` CLI** must be installed and authenticated (`gh auth login`). +- `githubIntegrationEnabled` (global) gates all GitHub features. +- Per repo: `fetchPullRequestState` (auto-fetch PR state; on by default — turn off + for big/expensive repos), `pullRequestMergeStrategy` override. +- Settings → **GitHub** tab holds the integration options. + +## Gotchas for agents + +- No `gh` / not authenticated → no PR features. If a human expects PR actions and + they're missing, check `gh auth status`. +- PR actions appear in the palette **only when the selected worktree's branch has a + PR**. No PR → no actions. +- "Copy CI Failure Logs" is the high-value loop for agents: copy logs → feed to the + agent → it fixes → "Re-run Failed Jobs". diff --git a/docs/components/notifications.md b/docs/components/notifications.md new file mode 100644 index 00000000..3747daee --- /dev/null +++ b/docs/components/notifications.md @@ -0,0 +1,76 @@ +# Notifications & Agent Reminders + +> How Prowl tells you an agent finished or needs you — system notifications, the +> bell/unread indicators, and Dock badge/bounce. + +**Keywords:** notification, agent reminder, bell, unread, command finished, dock badge, dock bounce, sound, alert, finished + +**Related:** [agent-detection](agent-detection.md) · [active-agents](active-agents.md) · [terminal](terminal.md) · [settings](settings.md) + +## What it is + +Prowl watches your panes and surfaces three kinds of alerts so you can leave the +screen and come back exactly when you're needed: + +1. **Agent reminders** — when an agent in an unfocused worktree produces output or + finishes, Prowl flags that worktree (bell/unread) and can notify. +2. **Command-finished notifications** — when a long-running command completes. +3. **Terminal bell / desktop notifications** — bell (BEL) and explicit terminal + notifications increment unread indicators. + +## When a notification fires + +**Command finished** posts a notification only when **all** hold: +- `commandFinishedNotificationEnabled` is on, +- the command ran longer than `commandFinishedNotificationThreshold` (default + **10 s**), +- it wasn't a user-initiated exit (Ctrl-C → exit 130, SIGTERM → 143), +- you didn't type in that pane within the last ~3 seconds. + +Custom commands also post a success toast when they exit 0. + +Whether these become **macOS system banners** depends on +`systemNotificationsEnabled`; in-app alerts depend on `inAppNotificationsEnabled`; +sound depends on `notificationSoundEnabled`. + +## Where unread shows up + +- **Sidebar:** an orange bell next to a worktree with unseen notifications. +- **Toolbar:** a bell button with a badge count; opening its popover marks items + read. +- **Shelf:** orange highlight on the relevant tab slot + a dot on the spine. +- **Canvas:** the card's title bar turns orange. +- **Dock:** an optional numeric badge (count of worktrees with unseen + notifications). + +Unread clears when you **focus** the relevant worktree/surface or view the +notification. `moveNotifiedWorktreeToTop` floats a freshly notified worktree to the +top of its section. **Jump to Latest Unread** (`⌘⌥U`) takes you straight to it. + +## Dock behavior + +- **Badge:** `showNotificationDotOnDock` shows an unread count on the Dock icon. + Requires macOS notification permission + "Badge app icon" enabled; Prowl + disables the toggle if the system doesn't allow it. +- **Bounce:** `dockBounceMode` — `off`, `once` (single bounce), or `continuously` + (bounces until you bring Prowl forward). + +## Settings (Settings → Notifications) + +- `inAppNotificationsEnabled` — in-app alerts / bell indicators. +- `systemNotificationsEnabled` — macOS system banners. +- `notificationSoundEnabled` — play a sound. +- `moveNotifiedWorktreeToTop` — float notified worktree to top. +- `commandFinishedNotificationEnabled` + `commandFinishedNotificationThreshold` — + long-command notifications and their minimum duration. +- `showNotificationDotOnDock`, `dockBounceMode` — Dock badge & bounce. + +Full field detail: [`reference/settings-fields.md`](../reference/settings-fields.md). + +## Gotchas for agents + +- The notification that matters most for human attention pairs with the **Blocked** + agent state (a prompt waiting on the human) — see + [agent-detection](agent-detection.md). +- Unread state is **transient** — focusing a worktree clears it; it isn't a + durable inbox. diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md new file mode 100644 index 00000000..34f4f7a8 --- /dev/null +++ b/docs/components/repositories-and-worktrees.md @@ -0,0 +1,173 @@ +# Repositories & Git Worktrees + +> The sidebar and everything in it: adding projects, and creating, opening, +> archiving, and deleting git worktrees — the unit of work you hand to an agent. + +**Keywords:** repository, repo, worktree, branch, sidebar, add repository, new worktree, archive, delete, pin, plain folder, non-git, base branch, git-wt, wt + +**Related:** [concepts](../concepts.md) · [terminal](terminal.md) · [custom-actions](custom-actions.md) · [github-pull-requests](github-pull-requests.md) · [settings-fields](../reference/settings-fields.md) + +## What it is + +The left **sidebar** lists your **repositories**, each expandable into its +**worktrees**. A repository is a git project (or a plain non-git folder) you've +added. A worktree is one branch checked out into its own directory, so multiple +branches are live on disk at once — ideal for giving each agent its own branch. + +Within a repository, worktrees are grouped: **Main** (the repo root), **Pinned**, +**Pending** (being created), and the rest. Each row shows the name, branch detail, +an unread-notification bell, and run/agent status. + +## Repository kinds + +| Kind | Worktrees | Branches | Diff | PRs | Run scripts | +|------|-----------|----------|------|-----|-------------| +| **git** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **plain** (non-git folder) | ❌ | ❌ | ❌ | ❌ | ✅ | + +A plain folder can't expand; clicking it just opens a terminal there. Prowl +auto-detects which kind a path is when you add it (it runs `git` to find the repo +root; "not a git repository" → plain folder). + +## Adding a repository + +- **Shortcut:** `⌘⇧O` (`open_repository`) +- **Toolbar:** the **Add Repository** button (folder-with-plus icon) at the top of + the sidebar. +- **Command Palette:** "Open Repository". + +Pick one or more directories. Prowl detects git vs plain, de-duplicates, and +persists the list. Paths that don't exist or can't be read are reported in an +alert after the load. + +## Creating a worktree + +- **Shortcut:** `⌘N` (`new_worktree`) +- **Button:** the **+** on a repository's header (only for git repos that support + worktrees). +- **Command Palette:** "New Worktree". + +By default a **creation prompt** appears (controlled by +`promptForWorktreeCreation`) where you: +- enter a **branch name** (leave blank to auto-generate, e.g. `bold-cat-523`), +- choose the **base ref** (branch/tag) to branch from, +- optionally **fetch the remote first**. + +Press **↩** to create, **Esc** to cancel. Branch names are validated live +(`git check-ref-format`). + +**Creation runs through the bundled `wt` CLI** (`Resources/git-wt`) and streams +progress through stages: reading local branches → choosing a name → checking repo +mode → resolving the base ref → fetching → creating. A **Pending** row shows the +live status until it's ready. + +**Copying uncommitted files:** new worktrees can optionally copy `.gitignore`'d +files (`copyIgnoredOnWorktreeCreate`) and/or untracked files +(`copyUntrackedOnWorktreeCreate`) from the source — set globally or per repo. + +**Setup script:** if the repo defines a setup script, it runs automatically in the +new worktree (see [custom-actions](custom-actions.md)). + +## Selecting & switching worktrees + +- **Click** a row to select it (focuses its terminal). +- **`⌃1`–`⌃9`** jump to worktree 1–9. +- **`⌘⌃↓` / `⌘⌃↑`** select next / previous worktree. +- **`⌘⌥[` / `⌘⌥]`** go back / forward through worktree selection history. +- **`⌘⇧L`** reveals the currently focused worktree in the sidebar. + +## Pinning & ordering + +- **Pin / Unpin:** hover a worktree → pin button, or right-click → "Pin to top" / + "Unpin". Pinned worktrees sit in a section above the rest. (Not available for + the main worktree.) +- **Reorder:** drag repositories or worktrees to rearrange; a thin accent line + shows the drop target. Order is persisted. +- **Expand / Collapse:** click the chevron on a repo header, or use the sidebar's + **Expand All / Collapse All** buttons. Collapsed state is remembered. + +## Archiving a worktree + +Archiving hides a worktree from the main list without deleting it. + +- **Right-click** the row → "Archive Worktree" (or "Archive Selected Worktrees" + with a multi-selection). No default keyboard shortcut. +- If the branch is already **merged**, Prowl archives immediately without asking. +- If the repo defines an **archive script**, it runs first (live progress); if it + fails, archiving stops and the worktree stays active. +- **View archived:** `⌘⌃A` opens the Archived Worktrees panel, grouped by repo, + with **Unarchive** and **Delete Selected** (`⌘⇧⌫`) buttons. +- **Auto-delete:** if `archivedAutoDeletePeriod` is set, archived worktrees older + than the period are deleted automatically. + +The **main worktree cannot be archived.** + +## Deleting a worktree + +Deleting removes the worktree directory (and optionally its branch). + +- **Right-click** the row → "Delete Worktree", or **`⌘⇧⌫`**. +- A confirmation dialog offers **"Delete the local branch (`git branch -d`)"**. + Default behavior comes from `deleteBranchOnDeleteWorktree`. +- Prowl removes the worktree (relocating + `git worktree prune` if needed). If + branch deletion is rejected because the branch isn't merged, it offers a + **force delete** (`git branch -D`). +- Protected branches (`main`, `master`, and the detected default) are guarded. + +The **main worktree cannot be deleted.** + +## Removing a repository + +Right-click a repo header → "Remove Repository" (or the **⋯** menu). This removes +it from Prowl (closing its open terminals); it does **not** delete files on disk. + +## Opening a worktree in another app + +`⌘O` opens the worktree (Finder by default). Right-click → submenu, or per-repo +default (`openActionID`) / global default (`defaultEditorID`), pick from any +installed app Prowl detects: Finder, Terminal, `$EDITOR`, VS Code (+ Insiders), +Cursor, Zed, Windsurf, Xcode, JetBrains IDEs, GitHub Desktop / Fork / GitKraken / +Sourcetree / Sublime Merge / SmartGit / GitUp, and terminals (Alacritty, Ghostty, +Kitty, Warp, WezTerm). If the chosen app isn't installed, Prowl shows an alert. + +Other per-row context-menu items: **Copy Path**, **Reveal in Finder**, +**Repo Settings**. + +## Repository appearance (icon & color) + +In **Repo Settings** you can give each repository an **icon** (any SF Symbol from +a curated set, a bundled asset, or your own image) and a **color** (10 presets or +a custom color). The color tints the icon, the name, the Shelf spine (if +`shelfSpineTintFollowsRepositoryColor`), and the window chrome (if +`windowTintMode = repositoryColor`). You can also set a **custom display title** +(`customTitle`) that overrides the folder name. + +## Lifecycle states (what the row can show) + +- **Pending** — worktree is being created (grey row, live stage text). +- **Creating / Archiving / Removing** — transient loading states with progress. +- **Running** — a Run Script or agent task is active in that worktree. +- **Unread bell** — the worktree has unseen notifications. + +## Settings that affect this area + +Global (Settings → Worktree / General) and per-repository (Repo Settings) — see +[`reference/settings-fields.md`](../reference/settings-fields.md) for the full +list. Highlights: + +- `promptForWorktreeCreation`, `fetchOriginBeforeWorktreeCreation` +- `defaultWorktreeBaseDirectoryPath` / per-repo `worktreeBaseDirectoryPath` +- per-repo `worktreeBaseRef` (default base branch) +- `copyIgnoredOnWorktreeCreate`, `copyUntrackedOnWorktreeCreate` +- `deleteBranchOnDeleteWorktree`, `mergedWorktreeAction`, `archivedAutoDeletePeriod` +- per-repo `setupScript`, `archiveScript`, `openActionID`, `customTitle` + +## Gotchas for agents + +- The **main worktree** (`isMain`) is special: no archive/delete/rename. +- Worktree **names are auto-generated** (`adjective-animal-number`) unless the user + named them — don't assume the name reflects the branch's purpose. +- Removing a repository does **not** delete files; deleting a worktree **does** + remove its directory (and optionally its branch). +- A worktree's `id` is its **path** (with trailing-slash normalization) — the same + identifier the [`prowl` CLI](cli.md) uses. diff --git a/docs/components/settings.md b/docs/components/settings.md new file mode 100644 index 00000000..cb307dbb --- /dev/null +++ b/docs/components/settings.md @@ -0,0 +1,48 @@ +# Settings + +> The Settings window (`⌘,`): what each tab controls. For the exhaustive +> field-by-field list, see [`reference/settings-fields.md`](../reference/settings-fields.md). + +**Keywords:** settings, preferences, ⌘comma, general, notifications, shortcuts, worktree, updates, advanced, github, repo settings, appearance + +**Related:** [reference/settings-fields](../reference/settings-fields.md) · [custom-actions](custom-actions.md) · [updates](updates.md) · [notifications](notifications.md) + +## Opening + +`⌘,` (`open_settings`), the app menu, or Command Palette → "Open Settings". The +window is a sidebar of tabs plus a detail pane. + +## Tabs + +| Tab | Controls | +|-----|----------| +| **General** | Appearance (system/light/dark), default app for opening worktrees, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, restore layout on launch. | +| **Notifications** | In-app alerts, sound, macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | +| **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | +| **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | +| **Updates** | Update channel (Stable/Tip), auto-check toggle, "Check for Updates Now". → [updates](updates.md) | +| **Advanced** | Analytics, crash reports, and the **Install Command Line Tool** (`prowl` CLI) action. | +| **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | +| **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | + +## Where settings live on disk + +- **Global:** `~/.prowl/settings.json` +- **Per-repo:** `~/.prowl/repo//prowl.json` +- **Per-repo custom commands:** `~/.prowl/repo//prowl.onevcat.json` + +Legacy `~/.supacode` is migrated to `~/.prowl` on first launch. + +## Install the CLI from here + +**Advanced → Install Command Line Tool** symlinks `prowl` into `/usr/local/bin` +(prompting for admin rights if needed). Also available via Command Palette → +"Install Command Line Tool". See [cli](cli.md). + +## Gotchas for agents + +- Many behaviors are **global with a per-repo override** (copy-files, base + directory, PR merge strategy, open-with app). A per-repo value of "default"/nil + means "use the global setting." +- For exact field names, types, and defaults (useful when reading/writing the JSON + directly), use [`reference/settings-fields.md`](../reference/settings-fields.md). diff --git a/docs/components/shelf.md b/docs/components/shelf.md new file mode 100644 index 00000000..5dc320e9 --- /dev/null +++ b/docs/components/shelf.md @@ -0,0 +1,89 @@ +# Shelf View + +> Worktrees as vertical "book spines" stacked on the side — flip through your +> whole stack from the keyboard, never losing your place. + +**Keywords:** shelf, book, spine, vertical tabs, flip, cycle books, cycle tabs, triage, proximity, stack + +**Related:** [view-modes](view-modes.md) · [canvas](canvas.md) · [terminal](terminal.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) + +## What it is + +Shelf lays your worktrees out as thin vertical **spines** ("books") on the sides, +with the currently open book's terminal filling the center. Each spine shows the +project icon/name (rotated) and a column of its **tabs** as small icon slots. +Books near the open one are brighter; distant ones fade — so you always know where +you are in the stack. + +**Toggle Shelf:** `⌘⇧↩` (`toggle_shelf`). Requires at least one open worktree; +otherwise it's a no-op. + +## Keyboard navigation (the whole point) + +| Action | Key | +|--------|-----| +| Flip to **next book** (worktree) | `⌘⌃→` | +| Flip to **previous book** | `⌘⌃←` | +| Jump to **book 1–9** | `⌃⌥1` … `⌃⌥9` | +| Cycle the open book's **tabs** | `⌘⌃↓` (next) / `⌘⌃↑` (previous) | +| Select the open book's **tab 1–9** | `⌘1` … `⌘9` | + +So `⌘⌃←/→` moves **between agents**, and `⌘⌃↑/↓` moves **between that agent's +tabs** — triage six in-flight agents one keystroke at a time. + +## Interacting with spines + +- **Open a book:** click anywhere on a closed spine (it activates and shows its + terminal). +- **Select a tab:** click a tab slot on the open book's spine. +- **New tab:** the **+** at the bottom of a spine. On a closed book, **+** opens + the book and creates the tab. +- **Split:** the open book's spine has **split-vertical** and **split-horizontal** + buttons (Ghostty `new_split:right` / `new_split:down`). +- **Close a tab:** hover a tab slot → its **×**. +- **Tab context menu:** right-click a tab slot → Rename, Change Icon, Close, Close + Others, Close to Right, Close All. +- **Spine header context menu:** right-click → **Repo Settings**, **Close + Worktree/Folder** (closes all its tabs, removing the book). + +While holding `⌘`, the open book overlays its tab slots with `1…9` hints for quick +selection. + +## Visual cues + +- **Open book:** full-strength color. **Neighbors:** ~50%. Farther books fade + progressively — a proximity ladder that keeps the stack readable. +- **Unread notification:** a tab slot highlights orange; the spine header shows an + orange dot. +- Spine tint follows the **repository color** when set (toggle + `shelfSpineTintFollowsRepositoryColor`); otherwise a fallback + (`shelfSpineTintFallback`: neutral or system tint) is used. +- In Shelf, the toolbar and leading band are tinted with the open book's repo + color (`windowTintMode`). + +## Empty state + +If no book is open, the center shows "No worktree selected" with a books icon; +spines stay visible so you can pick one. + +## Settings that affect Shelf + +- `defaultViewMode` — launch directly into Shelf. +- `shelfSpineTintFollowsRepositoryColor` — color spines by repo color. +- `shelfSpineTintFallback` — `neutral` or `systemTint` when a repo has no color. +- `windowTintMode` / repository colors — chrome tinting. + +## When to recommend Shelf vs Canvas + +- **Shelf** = linear, keyboard-driven, see-one-at-a-time triage of many + worktrees. Fastest way to cycle through agents and check each. +- **Canvas** = spatial, all-at-once supervision and broadcast. + +See [view-modes](view-modes.md). + +## Gotchas for agents + +- A "book" is a **worktree**; a tab slot is a **tab** within it. +- Closing the last tab of a book removes the book from the shelf. +- The book-cycling keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only by + arrow direction — be precise when describing them. diff --git a/docs/components/terminal.md b/docs/components/terminal.md new file mode 100644 index 00000000..bb0accaa --- /dev/null +++ b/docs/components/terminal.md @@ -0,0 +1,146 @@ +# Terminal — Tabs, Splits & Panes + +> The terminal layer: tabs within a worktree, splitting a tab into panes, the +> Ghostty engine underneath, tab titles & icons, shell integration, and fonts. + +**Keywords:** terminal, tab, split, pane, surface, ghostty, font size, shell integration, OSC 133, scrollback, find, search, close tab, new tab, tab title, tab icon, CJK + +**Related:** [concepts](../concepts.md) · [canvas](canvas.md) · [shelf](shelf.md) · [agent-detection](agent-detection.md) · [cli](cli.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) + +## What it is + +Inside each worktree you get terminal **tabs**. Each tab is a layout of one or +more **panes** (a.k.a. surfaces) — split horizontally/vertically — rendered by the +embedded **Ghostty** engine. One worktree → many tabs → each tab → one or more +panes. + +``` +Worktree +└─ Tab (has a title + icon) + └─ Pane / surface (a live Ghostty terminal; tabs can hold several, split) +``` + +A single Ghostty app instance hosts every pane as an independent surface, which is +why Prowl is fully native and CJK-correct. + +## Tabs + +| Operation | How | +|-----------|-----| +| New tab | Terminal menu → **New Terminal** (Ghostty `new_tab`, typically `⌘T`); the **+** on a Shelf spine; the [`prowl tab create`](cli.md) CLI | +| Select tab 1–9 | `⌘1`–`⌘9` | +| Previous / Next tab | `⌘⇧[` / `⌘⇧]` | +| Close focused tab | Terminal menu → **Close Terminal Tab** (Ghostty `close_tab`) | +| Close (right-click a tab) | Close Tab · Close Other Tabs · Close Tabs to the Right · Close All | +| Rename tab | Right-click → **Rename Tab** (sets a custom title) | +| Change tab icon | Right-click → **Change Tab Icon** (pick an SF Symbol) | +| Reorder tabs | Drag tabs in the tab bar | + +In **Shelf** view, tabs of the open book are also cycled with `⌘⌃↑` / `⌘⌃↓`. + +## Splits (panes) + +Splitting is handled by Ghostty actions (bind/keys in your Ghostty config): + +| Operation | Ghostty action | +|-----------|----------------| +| New split (vertical / right) | `new_split:right` | +| New split (horizontal / down) | `new_split:down` | +| Focus adjacent pane | `goto_split:left/right/up/down`, or the app shortcuts below | +| Resize split | `resize_split::` | +| Equalize splits | `equalize_splits` | +| Zoom / maximize a pane | `toggle_split_zoom` | +| Close a pane | `close_surface` (Terminal menu → **Close Terminal**) | + +App-level pane navigation (works inside the terminal too): `⌘[` / `⌘]` previous / +next pane, `⌘⌥↑/↓/←/→` for directional pane focus. Shelf spines also expose +**split vertical / split horizontal** buttons on the open book. + +## Tab titles — important caveat + +A tab's displayed title is, in order of precedence: +1. a **custom title** you set via Rename Tab, else +2. the **live shell title** the running program emits (OSC 2), else +3. an auto-generated default like `project 1`, `project 2`. + +Tabs created for a Run Script show the command and are **title-locked** until it +finishes. Prowl also "learns" your shell's idle prompt so it doesn't mistake it +for a meaningful title. + +> **Titles are free-form and can lie or lag.** Any program can set any title. +> When automating, never target a pane by its title — use the stable `pane.id` +> from [`prowl list`](cli.md). The bundled [`prowl-cli` skill](cli.md) repeats +> this for good reason. + +## Tab icons + +Tabs get an auto-detected icon based on what's running (e.g. an agent's icon), +which you can override (Change Tab Icon) or which a script can set. Icon +precedence is `auto < script < user`. + +## Shell integration & status (OSC sequences) + +Ghostty's shell integration drives several Prowl features via terminal escape +sequences: + +- **OSC 133** (prompt/command marks) — lets Prowl know when a command **starts and + finishes** and its **exit code**. This powers command-finished notifications, + auto-close-on-success, and the `prowl send --capture` output capture. +- **OSC 2** (title) — feeds tab titles and agent/icon detection. +- **Progress reports** — agents/commands report busy/idle, feeding the tab's + activity indicator and task status. +- **Bell / desktop notification** — increments unread indicators. + +`--capture` in the CLI **requires OSC 133** on the target pane; without it you get +`CAPTURE_UNSUPPORTED` (read the screen with `read --wait-stable` instead). + +## Command-finished behavior + +When a command finishes (via OSC 133), Prowl can: +- fire a **notification** if it ran longer than the threshold (default 10s) — see + [notifications](notifications.md); +- **auto-close** the tab/pane if it was launched with "close on success" and the + exit code is 0; +- skip the notification for user-initiated exits (Ctrl-C → 130, SIGTERM → 143) or + if you typed in that pane within the last ~3 seconds. + +## Font size + +- **Reset / Increase / Decrease** via the Terminal menu (Ghostty + `reset_font_size` / `increase_font_size:1` / `decrease_font_size:1`; usually + `⌘0` / `⌘+` / `⌘-`). +- The chosen size is remembered (`terminalFontSize`) and applied across worktrees; + new tabs and splits inherit the focused pane's size. + +## In-terminal search + +Terminal menu: **Find…** (`start_search`), **Find Next/Previous** +(`search:next`/`search:previous`), **Hide Find Bar** (`end_search`), **Use +Selection for Find** (`search_selection`). These are Ghostty-managed. + +## Scrollback, CJK, copy/paste + +Scrollback, wide/CJK character rendering, and copy/paste are handled by Ghostty +with its defaults — Prowl doesn't override them. Customize terminal behavior in +your Ghostty config at `~/.config/ghostty/config`. + +## Color scheme + +The terminal theme automatically follows the macOS light/dark appearance. There's +no in-app Ghostty theme picker; change Ghostty colors via its config file. + +## Layout persistence + +If `restoreTerminalLayoutOnLaunch` is enabled, Prowl saves the tab/split layout +(which worktrees, tabs, split trees, titles, icons) and restores it on next +launch. Notification bodies are not persisted. + +## Gotchas for agents + +- **Tab selection ≠ pane focus.** A tab can have several panes; selecting a tab + doesn't pin which split has keyboard focus. The CLI's `pane.focused` is the + truth. +- Closing the **last** tab leaves the worktree with no visible terminal (Shelf + removes the book; Canvas drops the card). +- `--capture` and stable reads depend on the pane's shell integration; agents + running full-screen TUIs may need `read --wait-stable` rather than `--capture`. diff --git a/docs/components/updates.md b/docs/components/updates.md new file mode 100644 index 00000000..4af5fc01 --- /dev/null +++ b/docs/components/updates.md @@ -0,0 +1,41 @@ +# Updates + +> Prowl keeps itself current via Sparkle. Channels, auto-check, and manual checks. + +**Keywords:** updates, sparkle, auto-update, check for updates, channel, stable, tip, version, ⌘⇧U + +**Related:** [settings](settings.md) + +## What it is + +Prowl uses the **Sparkle** framework for auto-updates. Releases are notarized. + +- **Check now:** `⌘⇧U` (`check_for_updates`), or Command Palette → "Check for + Updates", or Settings → Updates → "Check for Updates Now". +- **Background checks:** if `updatesAutomaticallyCheckForUpdates` is on, Prowl + checks periodically (roughly hourly). When a background check finds an update, a + badge appears on the notification bell; clicking it opens the standard Sparkle + dialog. +- **On quit with a downloaded update:** Prowl offers to install or defer. + +## Channels + +`updateChannel` selects the release track: **Stable** (default) or **Tip**. Choose +in Settings → Updates. (Day-to-day, Stable is the published track.) + +## Settings + +- `updateChannel` — `stable` or `tip`. +- `updatesAutomaticallyCheckForUpdates` — background checks (default on). +- `updatesAutomaticallyDownloadUpdates` — auto-download in the background. + +## Install via Homebrew + +Prowl is also distributed as a cask: `brew install --cask onevcat/tap/prowl`. The +in-app Sparkle updater and Homebrew are separate channels. + +## Gotchas for agents + +- This updates **the Prowl app itself**, not your projects or agents. +- A bell badge that isn't a notification may be an **available update** — check + Settings → Updates. diff --git a/docs/components/view-modes.md b/docs/components/view-modes.md new file mode 100644 index 00000000..f943a53d --- /dev/null +++ b/docs/components/view-modes.md @@ -0,0 +1,49 @@ +# View Modes — Normal / Canvas / Shelf + +> The same open worktrees, three layouts. Switching modes rearranges the view; it +> never changes your running sessions. + +**Keywords:** view mode, layout, normal, canvas, shelf, switch view, toggle canvas, toggle shelf, default view + +**Related:** [canvas](canvas.md) · [shelf](shelf.md) · [concepts](../concepts.md) · [repositories-and-worktrees](repositories-and-worktrees.md) + +## The three modes + +| Mode | Layout | Strength | Toggle | +|------|--------|----------|--------| +| **Normal** | Sidebar of worktrees + the focused worktree's tabs/panes | Deep, focused work on one branch | default (exit Canvas/Shelf) | +| **Canvas** | Zoomable board of live terminal cards | See many agents at once; **broadcast** | `⌘⌥↩` | +| **Shelf** | Vertical "book spines" you flip through | Fast keyboard triage of many worktrees | `⌘⇧↩` | + +## How to switch + +- **Canvas:** `⌘⌥↩` (`toggle_canvas`), the sidebar Canvas button, or Command + Palette → "Canvas". +- **Shelf:** `⌘⇧↩` (`toggle_shelf`), the sidebar Shelf button, or Command Palette → + "Shelf". +- **Back to Normal:** toggle the active mode off, or select a worktree in the + sidebar. + +Toggling Canvas or Shelf with **no open worktrees** does nothing. + +## What's preserved across switches + +- Your running terminals, tabs, and panes are untouched. +- Canvas remembers card positions/sizes/z-order for the session. +- Entering Shelf keeps your current worktree open (or jumps to the first available + one if you were on Canvas / archived / nothing). +- Exiting Canvas returns you to the focused card's worktree, the worktree you had + before Canvas, your last focused worktree, or the first available — in that + order. + +## Launch behavior + +`defaultViewMode` (Settings) chooses which mode Prowl opens in: +`normal`, `shelf`, or `canvas`. + +## Which to recommend + +- Supervising a fleet / sending the same command everywhere → **Canvas** + ([details](canvas.md)). +- Cycling agents one-by-one with the keyboard → **Shelf** ([details](shelf.md)). +- Heads-down on a single branch → **Normal**. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 00000000..5503ab59 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,111 @@ +# Core Concepts & Glossary + +> The mental model behind Prowl. Read this before answering any structural +> question ("what's the difference between a tab and a pane?", "what's a book?"). + +**Keywords:** concepts, mental model, hierarchy, glossary, worktree, tab, pane, surface, split, book, spine, view mode, terminology + +## The object hierarchy + +Prowl nests four levels. Everything in the app refers back to these. + +``` +Repository a git repo (or a plain folder) you've added to Prowl +└─ Worktree a git worktree = one branch checked out in its own directory + └─ Tab a terminal tab inside that worktree + └─ Pane one terminal surface; a tab can be split into several panes +``` + +- **Repository** — a project you added to the sidebar. Two kinds: + - **`git`** — a real git repository; supports worktrees, branches, diff, PRs. + - **`plain`** — a non-git folder; you can open a terminal and run scripts in it, + but there are no worktrees, branches, diff, or PR features. +- **Worktree** — a git *worktree*: a branch checked out into its own working + directory, so several branches are live on disk at once. This is the unit you + hand to an agent. The repository's root directory is the **main worktree** + (`isMain`); it can't be archived, deleted, or renamed. Worktrees you create get + an auto-generated name like `bold-cat-523` unless you name them. +- **Tab** — a terminal tab within a worktree. A worktree can hold many tabs. +- **Pane / surface** — a single terminal session rendered by Ghostty. A tab + starts with one pane and can be **split** horizontally/vertically into more. + "Pane" and "surface" mean the same thing; the CLI and UI both use "pane". + +> **For the `prowl` CLI:** every pane has a stable **UUID** (`pane.id`), every tab +> has a `tab.id`, and every worktree has a `worktree.id` (its path). Target work +> by these IDs — never by tab title, which is free-form and can lie. See +> [`components/cli.md`](components/cli.md). + +## View modes — three ways to see the same worktrees + +The same set of open worktrees/tabs can be displayed three ways. Switching modes +doesn't change your sessions, only how they're laid out. + +| Mode | What it looks like | Best for | Toggle | +|------|--------------------|----------|--------| +| **Normal** | Sidebar of worktrees + one focused worktree's tabs/panes | Focused work on one branch | (default; exit Canvas/Shelf) | +| **Canvas** | A zoomable board of live terminal cards | Watching many agents; broadcasting | `⌘⌥↩` | +| **Shelf** | Vertical "book spines" you flip through | Fast keyboard triage of many worktrees | `⌘⇧↩` | + +See [`components/view-modes.md`](components/view-modes.md), and the deep dives in +[`components/canvas.md`](components/canvas.md) and +[`components/shelf.md`](components/shelf.md). + +## Agent status + +Prowl watches each pane and infers what the agent in it is doing. Four +user-visible states: + +- **Working** — actively processing (animated indicator). +- **Blocked** — waiting for you (a confirmation/permission prompt). This is the + one that needs your attention. +- **Done** — finished and you haven't looked yet (an unseen completion). Becomes + **Idle** once you focus it. +- **Idle** — nothing running / seen. + +How this is detected (process inspection + on-screen heuristics) and the full +list of recognized agents are in +[`components/agent-detection.md`](components/agent-detection.md). The live list of +all agents and their statuses is the +[Active Agents panel](components/active-agents.md). + +## Where Prowl stores things + +- **Global settings:** `~/.prowl/settings.json` +- **Per-repository settings:** `~/.prowl/repo//prowl.json` +- **Per-repository user custom commands:** `~/.prowl/repo//prowl.onevcat.json` +- **CLI socket:** `~/Library/Application Support/com.onevcat.prowl/cli.sock` + (overridable with `PROWL_CLI_SOCKET`) +- Legacy `~/.supacode` is migrated to `~/.prowl` on first launch. (Prowl is a fork + of Supacode; some internal identifiers still read `supacode`.) + +Full field-by-field detail: [`reference/settings-fields.md`](reference/settings-fields.md). + +## The terminal engine + +Prowl embeds **GhosttyKit / libghostty** (built from the Ghostty terminal +emulator). A single Ghostty app instance hosts every pane as an independent +surface. This is why Prowl is fully native (no web views), fast, and +correct with CJK/wide characters. Terminal-level features — splits, font size, +in-terminal search, copy/paste — are handled by Ghostty; app-level features — +tabs, worktrees, views — are Prowl's. See +[`components/terminal.md`](components/terminal.md) for where that line sits. + +## Glossary (quick definitions) + +- **Worktree** — a git branch checked out in its own directory; the unit you give + an agent. +- **Book / spine** — Shelf-view name for a worktree shown as a vertical strip. +- **Card** — Canvas-view name for one terminal tab shown as a floating tile. +- **Broadcast** — typing into one Canvas card and mirroring it to all selected + cards at once. +- **Pane / surface** — one terminal session; tabs split into panes. +- **Main worktree** — the repository root; cannot be archived/deleted/renamed. +- **Pinned worktree** — a worktree floated to the top of its repository section. +- **Archived worktree** — hidden from the main list but not deleted; restorable. +- **Run Script** — a per-repo on-demand command (`⌘R` to run, `⌘.` to stop). +- **Setup / Archive script** — per-repo scripts that run automatically on worktree + create / archive. +- **Custom Command** — a per-repo user-defined action with its own button, icon, + and hotkey. +- **Agent Reminder** — a notification fired when an agent finishes or needs you. +- **Command Palette** — the `⌘P` searchable launcher for every action. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000..9f05d5ab --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,97 @@ +# Prowl — Overview & Highlights + +> Read this first to understand _why Prowl exists_ and what makes it exciting. +> An agent can read this file and give a human a confident, accurate first +> introduction; the human then asks for detail on whatever they like, and the +> agent dives into the matching file under [`components/`](components/). + +**Keywords:** overview, introduction, pitch, highlights, what is prowl, why prowl, parallel agents, orchestrator + +## The one-line version + +**Your terminal wasn't built for agents. Prowl is.** It's a native macOS command +center for running many AI coding agents in parallel — each in its own terminal, +its own tab/split, and its own git worktree — and keeping all of them in view and +under control at once. + +## The problem it solves + +When you work with coding agents, you stop typing single commands and start +_orchestrating_: Claude Code refactoring in one branch, Codex writing tests in +another, a third agent chewing through a migration. A normal terminal — even a +tab-heavy one — buries that. You lose track of who's working, who finished, and +who is blocked waiting for your approval. Prowl is built around that exact +workflow: **many agents, many branches, many ideas, all legible at a glance.** + +## The most exciting parts + +### 🖼 Canvas — every agent, at a glance +A bird's-eye, zoomable board where **every card is a live, interactive terminal** +— not a screenshot. Three agents running and one just finished? You see _where_ +instantly, because finished cards light up the moment their task completes. And +you can **multi-select cards and broadcast a single command to every agent at +once** — "run the tests", "commit", "/clear" — typed once, delivered everywhere. +→ [`components/canvas.md`](components/canvas.md) + +### 📚 Shelf — your worktrees, lined up like books +Every worktree becomes a vertical **spine** stacked on the side, tabs nested +underneath, nearby books brighter and distant ones faded so you always know +where you are. Flip through the whole stack from the keyboard — **`⌘⌃←`/`⌘⌃→` +cycles books, `⌘⌃↑`/`⌘⌃↓` cycles tabs** — so six agents in flight become a +one-keystroke triage instead of a hunt. +→ [`components/shelf.md`](components/shelf.md) + +### 🤖 The `prowl` CLI — let your agents drive the terminal +Prowl ships a `prowl` command-line tool so **both you and your agents** can +inspect and control the app programmatically. An agent can discover sibling +panes, run a command in another pane and capture the output, read a screen, or +send keystrokes — turning Prowl into a surface that agents coordinate _through_. +```bash +prowl list # discover panes & their status +prowl send "npm test" --capture # run it & capture the output in one shot +prowl read --wait-stable # read a pane once its screen settles +prowl key enter # send keystrokes programmatically +``` +→ [`components/cli.md`](components/cli.md) + +### ⚡ Custom Actions — one keystroke, any workflow +Pin `swift build`, `npm test`, or `claude -p "review this diff"` to a button and +bind it to a hotkey, per repository. Set it up once and stop retyping the same +command every day. Paired with `claude -p` / `codex exec`, your terminal becomes +a daily AI-powered assistant on a keystroke. +→ [`components/custom-actions.md`](components/custom-actions.md) + +### 🔔 Active Agents panel & Agent Reminders +A live list of **every running agent across every worktree**, each with a +status — Working, Blocked (needs you), Done, or Idle — and one-click jump-to-agent. +The moment an agent finishes, Prowl can fire a macOS notification, badge the +Dock, and float the worktree to the top, so you can leave the screen and come +back exactly when you're needed. +→ [`components/active-agents.md`](components/active-agents.md) · +[`components/agent-detection.md`](components/agent-detection.md) · +[`components/notifications.md`](components/notifications.md) + +## And the fundamentals, done right + +- **Fully native.** Powered by **libghostty** (the Ghostty terminal engine) — no + Electron, no web views. Fast, and **CJK-safe** out of the box. +- **Git worktrees are first-class.** Spin up a parallel branch for a new agent in + one click; archive or delete it (and its branch) when you're done. +- **Vertical tabs sidebar.** Repos, branches, and worktrees down the side; never + lose context. +- **Command Palette (`⌘P`).** Everything is reachable by typing its name. +- **Diff view (`⌘⇧Y`).** Review exactly what an agent changed against HEAD. +- **GitHub PRs built in.** See CI status; merge, mark-ready, re-run failed jobs, + or copy failure logs straight from the palette. +- **Auto-updates** via Sparkle, notarized releases. + +## How a human typically uses Prowl with this doc set + +1. The human asks their agent something about Prowl. +2. The agent reads this `overview.md` (for breadth) and/or the specific + `components/*.md` file (for depth), then answers. +3. The human picks a thread — "tell me more about broadcasting" — and the agent + opens [`components/canvas.md`](components/canvas.md) and gets specific. + +Continue to [`concepts.md`](concepts.md) for the mental model, or jump straight to +any [component manual](README.md#component-manuals). diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md new file mode 100644 index 00000000..fe3a433b --- /dev/null +++ b/docs/reference/keyboard-shortcuts.md @@ -0,0 +1,147 @@ +# Reference: Keyboard Shortcuts + +> The complete, authoritative shortcut table. Source of truth: +> `supacode/App/AppShortcuts.swift` (app actions) and `supacode/Commands/*.swift` +> (menu wiring). Terminal-level keys come from the Ghostty engine. + +**Keywords:** keyboard, shortcuts, hotkeys, keybindings, key bindings, remap, ⌘, command, shortcut list + +Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control · **↩** Return · **⌫** Delete · arrows ↑ ↓ ← → + +> **Defaults shown.** Most rows are user-remappable (see +> [Remapping](#remapping--customization)). To see a human's _live_ binding, the +> [Command Palette](../components/command-palette.md) displays the resolved key +> next to each action. Automation should use the [`prowl` CLI](../components/cli.md), +> which never depends on keybindings. + +## Worktrees & repositories + +| Action | Default | Command ID | Remappable | +|--------|---------|------------|------------| +| New Worktree | ⌘N | `new_worktree` | yes | +| Open Worktree (opens in Finder by default) | ⌘O | `open_worktree` | yes | +| Open Repository… | ⌘⇧O | `open_repository` | yes | +| Open on Code Host (e.g. GitHub) | ⌘⌃G | `open_pull_request` | yes | +| Refresh Worktrees | ⌘⇧R | `refresh_worktrees` | yes | +| Archived Worktrees (panel) | ⌘⌃A | `archived_worktrees` | yes | +| Select Next Worktree _(cycles tabs in Shelf view)_ | ⌘⌃↓ | `select_next_worktree` | yes | +| Select Previous Worktree _(cycles tabs in Shelf view)_ | ⌘⌃↑ | `select_previous_worktree` | yes | +| Back in Worktree History | ⌘⌥[ | `worktree_history_back` | yes | +| Forward in Worktree History | ⌘⌥] | `worktree_history_forward` | yes | +| Select Worktree 1–9 | ⌃1 … ⌃9 | `select_worktree_1` … `_9` | yes | +| Reveal in Sidebar | ⌘⇧L | `reveal_in_sidebar` | yes | +| Rename Branch | ⌘⇧M | `rename_branch` | yes (local) | +| Archive Worktree | _(menu only, no default key)_ | — | — | +| Delete Worktree | ⌘⇧⌫ | _(menu-bound, fixed)_ | no | +| Confirm Worktree Action (in dialogs) | ⌘↩ | _(menu-bound, fixed)_ | no | + +## View & layout + +| Action | Default | Command ID | Remappable | +|--------|---------|------------|------------| +| Toggle Left Sidebar | ⌘⌃S | `toggle_left_sidebar` | yes | +| Toggle Active Agents Panel | ⌘⌥P | `toggle_active_agents_panel` | yes | +| Select Next Agent (in panel) | ⌃⌥↓ | `select_next_active_agent` | yes | +| Select Previous Agent (in panel) | ⌃⌥↑ | `select_previous_active_agent` | yes | +| Jump to Latest Unread | ⌘⌥U | `jump_to_latest_unread` | yes | +| Show Diff | ⌘⇧Y | `show_diff` | yes | +| Toggle Canvas | ⌘⌥↩ | `toggle_canvas` | yes | +| Toggle Shelf | ⌘⇧↩ | `toggle_shelf` | yes | + +## Shelf view + +| Action | Default | Command ID | Remappable | +|--------|---------|------------|------------| +| Select Next Book | ⌘⌃→ | `select_next_shelf_book` | yes | +| Select Previous Book | ⌘⌃← | `select_previous_shelf_book` | yes | +| Select Book 1–9 | ⌃⌥1 … ⌃⌥9 | `select_shelf_book_1` … `_9` | yes | + +> In Shelf view, **⌘⌃←/→ flips between books (worktrees)** and **⌘⌃↑/↓ cycles the +> tabs of the open book** (those are the `select_previous/next_worktree` actions). + +## Canvas view + +| Action | Default | Command ID | Remappable | +|--------|---------|------------|------------| +| Select All Canvas Cards | ⌘⌥A | `select_all_canvas_cards` | yes (local) | +| Arrange Canvas Cards (pack to fit) | ⌘⌥R | `arrange_canvas_cards` | yes (local) | +| Organize Canvas Cards (uniform grid) | ⌘⌥G | `organize_canvas_cards` | yes (local) | +| Expand / Restore Canvas Card | ⌘⌥E | `expand_canvas_card` | yes (local) | +| Clear selection | Esc | — | — | +| Zoom | ⌘ + scroll, or pinch | — | — | + +## Terminal tabs & panes + +These app actions are also registered with Ghostty, so they work while a terminal +pane has focus. The Ghostty action each maps to is shown for reference. + +| Action | Default | Command ID | Ghostty action | +|--------|---------|------------|----------------| +| Select Terminal Tab 1–9 | ⌘1 … ⌘9 | `select_terminal_tab_1` … `_9` | `goto_tab:N` | +| Select Previous Tab | ⌘⇧[ | `select_previous_terminal_tab` | `previous_tab` | +| Select Next Tab | ⌘⇧] | `select_next_terminal_tab` | `next_tab` | +| Select Previous Pane | ⌘[ | `select_previous_terminal_pane` | `goto_split:previous` | +| Select Next Pane | ⌘] | `select_next_terminal_pane` | `goto_split:next` | +| Select Pane Up | ⌘⌥↑ | `select_terminal_pane_up` | `goto_split:up` | +| Select Pane Down | ⌘⌥↓ | `select_terminal_pane_down` | `goto_split:down` | +| Select Pane Left | ⌘⌥← | `select_terminal_pane_left` | `goto_split:left` | +| Select Pane Right | ⌘⌥→ | `select_terminal_pane_right` | `goto_split:right` | + +## Terminal engine (Ghostty-managed) + +These are **not** Prowl app shortcuts; they are Ghostty terminal bindings, shown +in Prowl's Terminal menu with whatever key Ghostty resolves. Customize them in +your Ghostty config (`~/.config/ghostty/config`). Typical defaults in parentheses. + +| Action | Ghostty action | Typical default | +|--------|----------------|-----------------| +| New Terminal (tab) | `new_tab` | ⌘T | +| Close Terminal (pane/surface) | `close_surface` | ⌘W | +| Close Terminal Tab | `close_tab` | ⌘⇧W | +| New Split (vertical / horizontal) | `new_split:right` / `new_split:down` | (Ghostty default) | +| Reset Font Size | `reset_font_size` | ⌘0 | +| Increase Font Size | `increase_font_size:1` | ⌘+ | +| Decrease Font Size | `decrease_font_size:1` | ⌘- | +| Find… | `start_search` | ⌘F | +| Find Next / Previous | `search:next` / `search:previous` | ⌘G / ⌘⇧G | +| Hide Find Bar | `end_search` | Esc | +| Use Selection for Find | `search_selection` | ⌘E | + +## App & global + +| Action | Default | Command ID | Remappable | +|--------|---------|------------|------------| +| Command Palette | ⌘P | `command_palette` | yes | +| Open Settings | ⌘, | `open_settings` | yes | +| Run Script | ⌘R | `run_script` | yes | +| Stop Script | ⌘. | `stop_script` | yes | +| Check for Updates | ⌘⇧U | `check_for_updates` | yes | +| Quit Application | ⌘Q | `quit_application` | **no** (fixed) | + +Plus any per-repository **Custom Commands**, which can each carry their own +hotkey — see [`components/custom-actions.md`](../components/custom-actions.md). + +## Remapping & customization + +- **App actions** (scope `configurableAppAction`) are remappable in + **Settings → Shortcuts**. Overrides are stored in + `~/.prowl/settings.json` under `keybindingUserOverrides`. +- **`quit_application`** is fixed (`systemFixedAppAction`) — it can't be remapped. +- **Canvas/local actions** (`localInteraction`, e.g. Arrange/Organize/Expand, + Rename Branch) are remappable but only checked against each other for conflicts. +- **Custom Command** hotkeys are repo-scoped and take precedence over app + shortcuts within the focused repository; conflicts are surfaced when recording. +- **Terminal engine keys** are owned by Ghostty. Prowl automatically *unbinds* + any Ghostty key that collides with an app shortcut, and re-binds the + tab/pane-navigation actions into Ghostty so they work inside the terminal. You + customize the rest in `~/.config/ghostty/config`. + +## Notes for agents + +- These are **defaults**. If a human says "my `⌘P` does X", trust them — they may + have remapped it. The CLI is binding-independent; prefer it for automation. +- When two rows look like they share keys (e.g. `⌃⌥↑/↓` for agent navigation vs + `⌘⌥↑/↓` for pane navigation), check the **modifiers carefully** — Control vs + Command distinguishes them. +- `select_previous/next_worktree` (⌘⌃↑/↓) is overloaded by design: in Shelf view + it cycles the open book's **tabs**; elsewhere it changes the selected worktree. diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md new file mode 100644 index 00000000..beae60b5 --- /dev/null +++ b/docs/reference/settings-fields.md @@ -0,0 +1,97 @@ +# Reference: Settings Fields + +> Every settings field with its exact name, type, default, and effect — useful for +> reading or writing the JSON directly. Source of truth: +> `supacode/Features/Settings/Models/GlobalSettings.swift` and `RepositorySettings.swift`. + +**Keywords:** settings fields, global settings, repository settings, defaults, settings.json, prowl.json, config, json schema + +For the UI grouping of these into tabs, see [`components/settings.md`](../components/settings.md). + +## On-disk locations + +| Scope | Path | +|-------|------| +| Global settings | `~/.prowl/settings.json` | +| Per-repository settings | `~/.prowl/repo//prowl.json` | +| Per-repository custom commands | `~/.prowl/repo//prowl.onevcat.json` | + +JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to +`~/.prowl` on first launch. + +## Global settings (`GlobalSettings`) + +| Field | Type | Default | Effect | +|-------|------|---------|--------| +| `appearanceMode` | enum (`system`/`light`/`dark`) | `dark` | App appearance. | +| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo). | +| `confirmBeforeQuit` | Bool | `true` | Confirm before quitting Prowl. | +| `updateChannel` | enum (`stable`/`tip`) | `stable` | Sparkle release channel. | +| `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | +| `updatesAutomaticallyDownloadUpdates` | Bool | `false` | Auto-download updates. | +| `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | +| `notificationSoundEnabled` | Bool | `true` | Play a notification sound. | +| `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | +| `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | +| `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | +| `commandFinishedNotificationThreshold` | Int (seconds) | `10` | Minimum duration before that notification fires. | +| `analyticsEnabled` | Bool | `true` | Send usage analytics (PostHog; off in Debug). | +| `crashReportsEnabled` | Bool | `true` | Send crash reports (Sentry). | +| `githubIntegrationEnabled` | Bool | `true` | Enable GitHub/PR features (via `gh`). | +| `deleteBranchOnDeleteWorktree` | Bool | `false` | Default "delete branch" when deleting a worktree. | +| `mergedWorktreeAction` | enum? | `nil` | What to do with a merged worktree (e.g. auto-archive); `nil` = ask. | +| `promptForWorktreeCreation` | Bool | `true` | Show the creation dialog vs. auto-create. | +| `fetchOriginBeforeWorktreeCreation` | Bool | `true` | `git fetch` before creating a worktree. | +| `defaultWorktreeBaseDirectoryPath` | String? | `nil` | Default parent directory for new worktrees. | +| `copyIgnoredOnWorktreeCreate` | Bool | `false` | Copy `.gitignore`'d files into new worktrees. | +| `copyUntrackedOnWorktreeCreate` | Bool | `false` | Copy untracked files into new worktrees. | +| `pullRequestMergeStrategy` | enum (`merge`/`squash`/`rebase`) | `merge` | Default PR merge strategy. | +| `restoreTerminalLayoutOnLaunch` | Bool | `false` | Restore tabs/splits on launch. | +| `terminalFontSize` | Float32? | `nil` | Remembered terminal font size. | +| `archivedAutoDeletePeriod` | enum? (days) | `nil` | Auto-delete archived worktrees after N days; `nil` = never. | +| `keybindingUserOverrides` | object | empty | User keyboard-shortcut remappings. | +| `defaultViewMode` | enum (`normal`/`shelf`/`canvas`) | `normal` | View mode on launch. | +| `dimUnfocusedSplits` | Bool | `true` | Dim panes that aren't focused. | +| `autoShowActiveAgentsPanel` | Bool | `false` | Auto-open the Active Agents panel on a new agent. | +| `showActiveAgentTabTitles` | Bool | `false` | Show tab titles (vs. branch) in the agents panel. | +| `windowTintMode` | enum (`none`/`repositoryColor`/`custom`) | `repositoryColor` | How the window chrome is tinted. | +| `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | +| `showRunButtonInToolbar` | Bool | `true` | Show the Run Script button in the toolbar. | +| `showDefaultEditorInToolbar` | Bool | `true` | Show the open-in-editor button in the toolbar. | +| `dockBounceMode` | enum (`off`/`once`/`continuously`) | `off` | Dock bounce on notification. | +| `showNotificationDotOnDock` | Bool | `false` | Numeric unread badge on the Dock icon. | +| `shelfSpineTintFallback` | enum (`neutral`/`systemTint`) | `neutral` | Shelf spine color when a repo has no color. | +| `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | + +## Per-repository settings (`RepositorySettings`) + +Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state +`Bool?` fields, `nil` means "inherit the global setting." + +| Field | Type | Default | Effect | +|-------|------|---------|--------| +| `setupScript` | String | `""` | Script run automatically after a worktree is created. | +| `archiveScript` | String | `""` | Script run automatically before a worktree is archived. | +| `runScript` | String | `""` | The on-demand Run Script (`⌘R`). | +| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`). | +| `worktreeBaseRef` | String? | `nil` | Default base branch/ref for new worktrees. | +| `worktreeBaseDirectoryPath` | String? | `nil` | Parent directory for new worktrees (overrides global). | +| `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | +| `copyUntrackedOnWorktreeCreate` | Bool? | `nil` | Copy untracked files; `nil` = use global. | +| `pullRequestMergeStrategy` | enum? | `nil` | PR merge strategy; `nil` = use global. | +| `customTitle` | String? | `nil` | Display name override for the repository. | +| `observeLineDiffsAutomatically` | Bool? | `nil` (= on) | Keep worktree line-change badges updated; set `false` for large repos. | +| `fetchPullRequestState` | Bool? | `nil` (= on) | Background-fetch PR state; set `false` to save GitHub rate limit. | + +**Custom Commands** (per-repo buttons/hotkeys) live separately in +`prowl.onevcat.json`. See [`components/custom-actions.md`](../components/custom-actions.md) +for their structure (title, icon, command, execution mode, close-on-success, +shortcut). + +## Notes for agents + +- Defaults here are the **factory** values; a human's file may differ. +- Tri-state `Bool?` per-repo fields: `nil`/absent = inherit global; `true`/`false` + = explicit override. +- Editing the JSON while Prowl is running may be overwritten on save — prefer the + Settings UI, or change settings while the app is closed. -- 2.51.2 From 043626a01d8ef9d43bbe04726be4eea113490da1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 14:21:07 +0900 Subject: [PATCH 345/528] Add docs maintenance: AGENTS.md rule + sync-docs skill - AGENTS.md: one-line rule to update docs/ in the same change as user-facing behavior changes; full audit via the sync-docs skill. - sync-docs skill: diff-driven from a committed commit baseline (.claude/skills/sync-docs/baseline.md, starting at 49235800), maps changed source to affected docs, verifies against source-of-truth files, and makes conservative minimal edits. Designed to hook into release prep later. --- .claude/skills/sync-docs/SKILL.md | 125 +++++++++++++++++++++++++++ .claude/skills/sync-docs/baseline.md | 10 +++ AGENTS.md | 1 + 3 files changed, 136 insertions(+) create mode 100644 .claude/skills/sync-docs/SKILL.md create mode 100644 .claude/skills/sync-docs/baseline.md diff --git a/.claude/skills/sync-docs/SKILL.md b/.claude/skills/sync-docs/SKILL.md new file mode 100644 index 00000000..6af14d56 --- /dev/null +++ b/.claude/skills/sync-docs/SKILL.md @@ -0,0 +1,125 @@ +--- +name: sync-docs +description: Keep the agent-facing manual under docs/ in sync with the implementation. Diff-driven from a committed commit baseline; makes conservative, minimal edits only where the code has actually changed user-facing behavior. Run on demand or as part of release prep. +--- + +# Sync Docs + +Keep the agent-facing documentation under `docs/` accurate as the implementation +evolves. This skill is **diff-driven** (it only looks at source that changed since +a committed baseline) and **deliberately conservative** (it makes the smallest +edits needed to restore accuracy — it does not rewrite, restyle, or expand the +docs). + +Read `docs/README.md` first for the doc set's structure and intent. The docs are +written **for AI agents to read**, are plain Markdown, and are designed to be +verifiable: each file lists `Keywords`/`Related`, and the `reference/` files name +their source-of-truth. + +## Guiding principle: be conservative + +The goal is to keep facts correct with **low churn**, not to perfect the prose. + +- **Default to leaving a doc unchanged.** Only edit when a documented fact is now + demonstrably **wrong**, a documented feature was **removed**, or a clearly + significant new user-facing feature/shortcut/setting was **added**. +- **Make the smallest possible edit.** Fix the wrong cell/line; don't reflow + tables, reorder sections, or reword surrounding prose. +- **Tolerate a wide range.** Ignore internal refactors, renames, and code + reshuffles that don't change user-facing behavior. Ignore stylistic/wording + differences. Approximate phrasings ("roughly", "typically", "best-effort") are + fine — don't tighten them just because you can. +- **Don't grow the doc set casually.** Do not add new sections or new files unless + a genuinely new top-level feature appeared. If a change is large (a new feature + that needs a whole new component manual, or a structural overhaul), **do not do + it silently** — describe it in the report and let the human decide. +- **Keep "source of truth" pointers correct** if files move or are renamed. +- Prefer touching **few files per run**. If you find yourself about to edit many + files, stop and re-read this section — most of those are probably tolerable. + +## Steps + +1. **Read the baseline.** Open `.claude/skills/sync-docs/baseline.md` and extract + the **last-synced commit** hash. + +2. **Diff the implementation since the baseline.** List source files that changed: + ```bash + git diff --name-only ..HEAD -- \ + supacode/ ProwlCLI/ Package.swift Resources/git-wt + ``` + If nothing relevant changed, report "Docs up to date as of ``." then go + to step 6 (bump the baseline) and stop. + +3. **Map changed source → docs to re-check.** Use this table; only open the docs + whose source actually changed. (Most changes touch zero or one doc.) + + | Source that changed | Docs to re-check | + |---------------------|------------------| + | `supacode/App/AppShortcuts.swift`, `supacode/Commands/**` | `reference/keyboard-shortcuts.md` (+ shortcut mentions in the relevant `components/*.md`) | + | `supacode/Features/Settings/Models/GlobalSettings.swift`, `RepositorySettings.swift`, `UserRepositorySettings.swift` | `reference/settings-fields.md`, `components/settings.md`, `components/custom-actions.md` | + | `ProwlCLI/**`, `supacode/CLIService/**` | `components/cli.md` | + | `supacode/Clients/CLIInstall/**` | `components/cli.md`, `components/settings.md` | + | `supacode/Features/Repositories/**`, `supacode/Domain/Worktree*.swift`, `supacode/Domain/Repository*.swift`, `supacode/Clients/Git/**`, `Resources/git-wt` | `components/repositories-and-worktrees.md` | + | `supacode/Features/Terminal/**`, `supacode/Infrastructure/Ghostty/**` | `components/terminal.md` | + | `supacode/Features/Canvas/**` | `components/canvas.md` | + | `supacode/Features/Shelf/**` | `components/shelf.md` | + | `supacode/Features/CommandPalette/**` | `components/command-palette.md` | + | `supacode/Features/ActiveAgents/**` | `components/active-agents.md` | + | `supacode/Domain/AgentDetection/**`, `supacode/Infrastructure/AgentDetection/**` | `components/agent-detection.md` | + | `supacode/Clients/Notifications/**`, `WorktreeTerminalState+Notifications.swift`, `supacode/Clients/Dock/**` | `components/notifications.md` | + | `supacode/Features/DiffView/**` | `components/diff-view.md` | + | `supacode/Clients/Github/**` | `components/github-pull-requests.md` | + | `supacode/Clients/Updates/**`, `supacode/Features/Updates/**` | `components/updates.md` | + | view-mode switching (`supacode/Features/App/**`, `ContentView.swift`) | `components/view-modes.md` | + + `overview.md`, `concepts.md`, and `README.md` only need touch-ups for the + addition/removal of a **major** feature — leave them alone otherwise. + +4. **Verify against the source of truth.** For each doc identified, check its + falsifiable claims against the authoritative source — not against the diff + summary — and apply minimal edits per the conservative rules above: + + | Claim type | Source of truth | + |------------|-----------------| + | Keyboard shortcuts (key + modifiers + command ID, remappability) | `supacode/App/AppShortcuts.swift`; menu wiring in `supacode/Commands/*.swift` | + | Settings field names / types / defaults | `supacode/Features/Settings/Models/GlobalSettings.swift`, `RepositorySettings.swift` | + | CLI commands / flags / ranges / error codes / JSON fields | `ProwlCLI/**` and `supacode/CLIService/**` (and, if the CLI is installed, `prowl --help` for confirmation) | + | Feature behavior / entry points | the corresponding `supacode/Features/**` or `supacode/Clients/**` | + + When in doubt whether something is a real, user-facing change, **leave the doc + as-is and flag it** in the report rather than editing. + +5. **Check internal links if files moved.** If you renamed/added/removed any doc + file, re-run a quick relative-link check so nothing in `docs/` is broken. + +6. **Bump the baseline.** Update `.claude/skills/sync-docs/baseline.md` to the + current `HEAD` (hash + date + one-line note). This file is **committed to git** + so the baseline persists across sessions and machines — never leave it + uncommitted. + +7. **Report.** Output a short summary: + ``` + ## Docs Sync + Baseline: → + Source files changed in range: + + ### Updated + - docs/ — + + ### Checked, left unchanged (tolerated) + - — + + ### Needs human decision (not applied) + - — + ``` + +## Committing + +- **Standalone run:** stage and commit only `docs/**` and + `.claude/skills/sync-docs/baseline.md` (never `git add .`). If anything in + `docs/` changed and you're not on `main`, open a PR targeting `onevcat/Prowl`. +- **As part of release prep:** leave the doc + baseline edits staged so they ride + along in the release commit (this is the intended integration point — a docs + freshness check before cutting a release). +- Always bump and commit the baseline even when no doc edits were needed, so the + next run starts from a tight diff range. diff --git a/.claude/skills/sync-docs/baseline.md b/.claude/skills/sync-docs/baseline.md new file mode 100644 index 00000000..3042165c --- /dev/null +++ b/.claude/skills/sync-docs/baseline.md @@ -0,0 +1,10 @@ +# sync-docs baseline + +The last commit at which the `docs/` manual was verified against the +implementation. The `sync-docs` skill diffs `HEAD` against this commit to find +what changed, then updates this file. **Committed to git on purpose** — the +baseline must persist across sessions and machines. + +- **Baseline commit:** `49235800` +- **Date:** 2026-06-07 +- **Note:** Initial docs set created and verified against source; baseline starts here. diff --git a/AGENTS.md b/AGENTS.md index e29296c2..4741ab8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream - After a task, ensure the app builds: `make build-app` - When working on CLI code (`ProwlCLI/`, `ProwlCLITests/`, `Package.swift`), run `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` before committing. +- When you change user-facing behavior (keyboard shortcuts, settings, the `prowl` CLI, or a feature's UX), update the matching file under `docs/` in the same change. For a full audit, run the `sync-docs` skill. - When implementing a new feature or fixing a bug that is unrelated to the current branch's active work, first create a dedicated branch from the latest `origin/main`; then work, commit, push, and open a PR from that branch. - Automatically commit your changes and your changes only. Do not use `git add .` - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch -- 2.51.2 From 0e92e8bbc6151de3f0c0b13459d485d155df8633 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 15:04:20 +0900 Subject: [PATCH 346/528] docs: fix inaccuracies found auditing the agent-facing manual against code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited the full docs/ set word-by-word against the supacode app and ProwlCLI sources. Corrections: - canvas: ⌘-click on a selected card removes it; clicking *without* ⌘ sets the primary. Esc only clears while broadcasting. Card layout persists across launches (UserDefaults), not just for the session. - active-agents: list is in first-detection order, not urgency-sorted. Notifications/Canvas highlight are driven by bell/OSC + command-finished, a separate signal from the detected Done state. Fix empty-state quote and ⌥⌃ glyph order. - repositories-and-worktrees: ⌘O opens the first installed editor (Finder only as fallback); app picker is the detail-toolbar Open dropdown, not a row submenu; Repo Settings is a repo-header action, not a worktree-row item; delete-branch toggle is "Also delete local branch"; add Antigravity/VSCodium/Android Studio to the detected-apps list. - terminal: Run Script tab is labeled "RUN SCRIPT" and title-locked. - settings: restore-layout-on-launch lives in Advanced, not General. - updates: Tip channel resolves to Stable (no longer published separately); updatesAutomaticallyDownloadUpdates is currently unwired. - settings-fields / notifications: dockBounceMode value is `continuous`. - keyboard-shortcuts / shelf: Control+Option renders as ⌥⌃; local actions conflict-check against all remappable actions; new_split is config-only. - cli: `delete` is an alias for backspace; `delete-forward` for forward delete. - view-modes: palette entries are "Toggle Canvas"/"Toggle Shelf". - agent-detection: process probe is cached ~0.75s per group. --- docs/components/active-agents.md | 16 +++++------ docs/components/agent-detection.md | 4 +-- docs/components/canvas.md | 11 +++++--- docs/components/cli.md | 5 ++-- docs/components/notifications.md | 13 +++++---- docs/components/repositories-and-worktrees.md | 27 +++++++++++-------- docs/components/settings.md | 4 +-- docs/components/shelf.md | 2 +- docs/components/terminal.md | 4 +-- docs/components/updates.md | 11 +++++--- docs/components/view-modes.md | 6 ++--- docs/reference/keyboard-shortcuts.md | 12 ++++----- docs/reference/settings-fields.md | 2 +- 13 files changed, 66 insertions(+), 51 deletions(-) diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index ba75ecf8..279dbe6f 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -36,22 +36,21 @@ Command Palette → "Toggle Active Agents Panel". | **Done** | finished, not yet seen | blue | | **Idle** | nothing running / seen | grey | -The list is sorted by urgency: **Blocked → Working → Done → Idle**, then by most -recently changed. (See [agent-detection](agent-detection.md) for how these states -are determined.) +Rows appear in the order agents are first detected. (See +[agent-detection](agent-detection.md) for how these states are determined.) ## Interactions - **Click a row** → focuses that worktree + tab + pane and brings Prowl forward. A **Done** row downgrades to **Idle** once focused. -- **Keyboard navigation:** `⌃⌥↓` next agent, `⌃⌥↑` previous agent (wraps). +- **Keyboard navigation:** `⌥⌃↓` next agent, `⌥⌃↑` previous agent (wraps). - **Resize** the panel by dragging its top edge (height is remembered). - **Auto-show:** if `autoShowActiveAgentsPanel` is on and the panel is hidden, a newly detected agent opens it automatically. ## Empty state -When nothing is running: "New agents will appear here." +When nothing is running: "New agents will appear here". ## Settings @@ -62,10 +61,11 @@ When nothing is running: "New agents will appear here." ## Relationship to other features - **Agent detection** ([agent-detection](agent-detection.md)) feeds this panel. -- **Notifications** ([notifications](notifications.md)) fire on the same - transitions (e.g. an agent finishing → Done). +- **Notifications** ([notifications](notifications.md)) are driven by a separate + signal — terminal bell / OSC desktop notifications and command-finished + events — which usually coincides with, but is not the same as, a detected finish. - **Canvas** ([canvas](canvas.md)) is the spatial counterpart — cards light up on - the same completion signal. + that same notification/unread signal, not on the detected status itself. ## Gotchas for agents diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 0bb7d95a..15e9af3a 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -56,8 +56,8 @@ A **Done** pane becomes **Idle** the moment you focus it. - ~**300 ms** when an agent is present or you're typing (active). - ~**2 s** when idle. -The heavier process probe is throttled (≈ every few seconds unless something -changes) so many panes don't add up to high CPU. Status indicators redraw on a +The heavier process probe is throttled (cached ≈ 0.75 s per process group unless +something changes) so many panes don't add up to high CPU. Status indicators redraw on a coarse tick rather than every frame for the same reason. ## The indicator diff --git a/docs/components/canvas.md b/docs/components/canvas.md index 37014b29..1b8140c7 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -32,9 +32,11 @@ Canvas lets you **type a command once and send it to many agents at once**: Selection controls: - `⌘`-click a card body → toggle it in/out of the selection. -- `⌘`-click an already-selected card → make it the **primary** (the one you type - into) without deselecting the rest. -- Click empty canvas, or press **Esc** → clear the selection. +- **Click (without `⌘`)** an already-selected card → make it the **primary** (the + one you type into) without deselecting the rest. (`⌘`-clicking a selected card + instead *removes* it from the selection.) +- Click empty canvas → clear the selection. **Esc** also clears it while + broadcasting (two or more cards selected). ## Working with cards @@ -74,7 +76,8 @@ explaining pan/zoom/expand. - Default card size adapts to screen width (roughly 800×550 on a 14", larger on a 27"). Cards have generous min/max bounds and snap-animate on resize. - On first entry, cards auto-arrange into a balanced grid. Positions, sizes, and - z-order are remembered for the session and restored when you return to Canvas. + z-order are persisted across launches (in `UserDefaults`) and restored when you + return to Canvas. - Closing a card's last tab prunes it; focus advances to a neighbor. ## Many agents at once diff --git a/docs/components/cli.md b/docs/components/cli.md index 36427bdd..fed71120 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -123,8 +123,9 @@ the command's output to a file and `cat` it. Send a keystroke. - `--repeat <1–100>` — repeat the key. -- Tokens: named keys (`enter`/`return`, `esc`, `tab`, `backspace`, `delete`, - `space`, arrows `up`/`down`/`left`/`right`, `pageup`/`pagedown`, `home`/`end`, +- Tokens: named keys (`enter`/`return`, `esc`, `tab`, `backspace` — `delete` is an + alias for backspace; use `delete-forward` for a forward delete — `space`, arrows + `up`/`down`/`left`/`right`, `pageup`/`pagedown`, `home`/`end`, `f1`–`f12`, punctuation), single characters (`a`–`z`, `0`–`9`, etc.), and modifier combos joined with `-`: `cmd`/`command`, `shift`, `opt`/`option`/`alt`, `ctrl`/`control` — e.g. `ctrl-c`, `cmd-k`, `shift-tab`, `cmd-shift-p`. diff --git a/docs/components/notifications.md b/docs/components/notifications.md index 3747daee..bee00625 100644 --- a/docs/components/notifications.md +++ b/docs/components/notifications.md @@ -12,8 +12,9 @@ Prowl watches your panes and surfaces three kinds of alerts so you can leave the screen and come back exactly when you're needed: -1. **Agent reminders** — when an agent in an unfocused worktree produces output or - finishes, Prowl flags that worktree (bell/unread) and can notify. +1. **Agent reminders** — when an agent in an unfocused worktree rings the bell or + emits a desktop notification (e.g. on finishing), Prowl flags that worktree + (bell/unread) and can notify. 2. **Command-finished notifications** — when a long-running command completes. 3. **Terminal bell / desktop notifications** — bell (BEL) and explicit terminal notifications increment unread indicators. @@ -30,8 +31,10 @@ screen and come back exactly when you're needed: Custom commands also post a success toast when they exit 0. Whether these become **macOS system banners** depends on -`systemNotificationsEnabled`; in-app alerts depend on `inAppNotificationsEnabled`; -sound depends on `notificationSoundEnabled`. +`systemNotificationsEnabled`; in-app alerts depend on `inAppNotificationsEnabled`. +A standalone sound (`notificationSoundEnabled`) plays only when system +notifications are **disabled** — when banners are on, the banner carries its own +sound. ## Where unread shows up @@ -52,7 +55,7 @@ top of its section. **Jump to Latest Unread** (`⌘⌥U`) takes you straight to - **Badge:** `showNotificationDotOnDock` shows an unread count on the Dock icon. Requires macOS notification permission + "Badge app icon" enabled; Prowl disables the toggle if the system doesn't allow it. -- **Bounce:** `dockBounceMode` — `off`, `once` (single bounce), or `continuously` +- **Bounce:** `dockBounceMode` — `off`, `once` (single bounce), or `continuous` (bounces until you bring Prowl forward). ## Settings (Settings → Notifications) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 34f4f7a8..3831453c 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -107,8 +107,9 @@ The **main worktree cannot be archived.** Deleting removes the worktree directory (and optionally its branch). - **Right-click** the row → "Delete Worktree", or **`⌘⇧⌫`**. -- A confirmation dialog offers **"Delete the local branch (`git branch -d`)"**. - Default behavior comes from `deleteBranchOnDeleteWorktree`. +- A confirmation dialog offers an **"Also delete local branch"** toggle (its + tooltip notes `git branch -d`). Default behavior comes from + `deleteBranchOnDeleteWorktree`. - Prowl removes the worktree (relocating + `git worktree prune` if needed). If branch deletion is rejected because the branch isn't merged, it offers a **force delete** (`git branch -D`). @@ -123,15 +124,19 @@ it from Prowl (closing its open terminals); it does **not** delete files on disk ## Opening a worktree in another app -`⌘O` opens the worktree (Finder by default). Right-click → submenu, or per-repo -default (`openActionID`) / global default (`defaultEditorID`), pick from any -installed app Prowl detects: Finder, Terminal, `$EDITOR`, VS Code (+ Insiders), -Cursor, Zed, Windsurf, Xcode, JetBrains IDEs, GitHub Desktop / Fork / GitKraken / -Sourcetree / Sublime Merge / SmartGit / GitUp, and terminals (Alacritty, Ghostty, -Kitty, Warp, WezTerm). If the chosen app isn't installed, Prowl shows an alert. - -Other per-row context-menu items: **Copy Path**, **Reveal in Finder**, -**Repo Settings**. +`⌘O` opens the worktree with the auto-detected default app — your first installed +editor (Cursor → Zed → VS Code → Windsurf → …), falling through to Xcode and then +**Finder only when no preferred app is found**. Use the **Open** dropdown in the +worktree's detail toolbar to pick a different app, or set a per-repo default +(`openActionID`) / global default (`defaultEditorID`). Prowl detects: Finder, +Terminal, `$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, +Antigravity, Xcode, Android Studio, JetBrains IDEs, GitHub Desktop / Fork / +GitKraken / Sourcetree / Sublime Merge / SmartGit / GitUp, and terminals +(Alacritty, Ghostty, Kitty, Warp, WezTerm). If the chosen app isn't installed, +Prowl shows an alert. + +Other per-row context-menu items: **Copy Path**, **Reveal in Finder**. (Repo +Settings lives on the repository **header** menu, not the worktree row.) ## Repository appearance (icon & color) diff --git a/docs/components/settings.md b/docs/components/settings.md index cb307dbb..abeb6d17 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -16,12 +16,12 @@ window is a sidebar of tabs plus a detail pane. | Tab | Controls | |-----|----------| -| **General** | Appearance (system/light/dark), default app for opening worktrees, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, restore layout on launch. | +| **General** | Appearance (system/light/dark), default app for opening worktrees, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & tab titles. | | **Notifications** | In-app alerts, sound, macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | | **Updates** | Update channel (Stable/Tip), auto-check toggle, "Check for Updates Now". → [updates](updates.md) | -| **Advanced** | Analytics, crash reports, and the **Install Command Line Tool** (`prowl` CLI) action. | +| **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout, and the **Install Command Line Tool** (`prowl` CLI) action. | | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | | **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 5dc320e9..fec1d92a 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -24,7 +24,7 @@ otherwise it's a no-op. |--------|-----| | Flip to **next book** (worktree) | `⌘⌃→` | | Flip to **previous book** | `⌘⌃←` | -| Jump to **book 1–9** | `⌃⌥1` … `⌃⌥9` | +| Jump to **book 1–9** | `⌥⌃1` … `⌥⌃9` | | Cycle the open book's **tabs** | `⌘⌃↓` (next) / `⌘⌃↑` (previous) | | Select the open book's **tab 1–9** | `⌘1` … `⌘9` | diff --git a/docs/components/terminal.md b/docs/components/terminal.md index bb0accaa..8c920355 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -63,8 +63,8 @@ A tab's displayed title is, in order of precedence: 2. the **live shell title** the running program emits (OSC 2), else 3. an auto-generated default like `project 1`, `project 2`. -Tabs created for a Run Script show the command and are **title-locked** until it -finishes. Prowl also "learns" your shell's idle prompt so it doesn't mistake it +The Run Script tab is labeled **RUN SCRIPT** and is **title-locked** for its +lifetime. Prowl also "learns" your shell's idle prompt so it doesn't mistake it for a meaningful title. > **Titles are free-form and can lie or lag.** Any program can set any title. diff --git a/docs/components/updates.md b/docs/components/updates.md index 4af5fc01..6743c1f7 100644 --- a/docs/components/updates.md +++ b/docs/components/updates.md @@ -20,14 +20,17 @@ Prowl uses the **Sparkle** framework for auto-updates. Releases are notarized. ## Channels -`updateChannel` selects the release track: **Stable** (default) or **Tip**. Choose -in Settings → Updates. (Day-to-day, Stable is the published track.) +`updateChannel` offers **Stable** (default) and **Tip** in Settings → Updates. +Tip is **no longer published separately** and currently resolves to the same feed +as Stable, so the two behave identically today. ## Settings -- `updateChannel` — `stable` or `tip`. +- `updateChannel` — `stable` or `tip` (Tip currently resolves to Stable). - `updatesAutomaticallyCheckForUpdates` — background checks (default on). -- `updatesAutomaticallyDownloadUpdates` — auto-download in the background. +- `updatesAutomaticallyDownloadUpdates` — present in settings but **not currently + wired** to Sparkle or exposed in the UI; the background-download preference is + chosen via Sparkle's own permission dialog. ## Install via Homebrew diff --git a/docs/components/view-modes.md b/docs/components/view-modes.md index f943a53d..e8b73816 100644 --- a/docs/components/view-modes.md +++ b/docs/components/view-modes.md @@ -18,9 +18,9 @@ ## How to switch - **Canvas:** `⌘⌥↩` (`toggle_canvas`), the sidebar Canvas button, or Command - Palette → "Canvas". + Palette → "Toggle Canvas". - **Shelf:** `⌘⇧↩` (`toggle_shelf`), the sidebar Shelf button, or Command Palette → - "Shelf". + "Toggle Shelf". - **Back to Normal:** toggle the active mode off, or select a worktree in the sidebar. @@ -29,7 +29,7 @@ Toggling Canvas or Shelf with **no open worktrees** does nothing. ## What's preserved across switches - Your running terminals, tabs, and panes are untouched. -- Canvas remembers card positions/sizes/z-order for the session. +- Canvas persists card positions/sizes/z-order across launches. - Entering Shelf keeps your current worktree open (or jumps to the first available one if you were on Canvas / archived / nothing). - Exiting Canvas returns you to the focused card's worktree, the worktree you had diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index fe3a433b..94afac63 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -41,8 +41,8 @@ Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control |--------|---------|------------|------------| | Toggle Left Sidebar | ⌘⌃S | `toggle_left_sidebar` | yes | | Toggle Active Agents Panel | ⌘⌥P | `toggle_active_agents_panel` | yes | -| Select Next Agent (in panel) | ⌃⌥↓ | `select_next_active_agent` | yes | -| Select Previous Agent (in panel) | ⌃⌥↑ | `select_previous_active_agent` | yes | +| Select Next Agent (in panel) | ⌥⌃↓ | `select_next_active_agent` | yes | +| Select Previous Agent (in panel) | ⌥⌃↑ | `select_previous_active_agent` | yes | | Jump to Latest Unread | ⌘⌥U | `jump_to_latest_unread` | yes | | Show Diff | ⌘⇧Y | `show_diff` | yes | | Toggle Canvas | ⌘⌥↩ | `toggle_canvas` | yes | @@ -54,7 +54,7 @@ Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control |--------|---------|------------|------------| | Select Next Book | ⌘⌃→ | `select_next_shelf_book` | yes | | Select Previous Book | ⌘⌃← | `select_previous_shelf_book` | yes | -| Select Book 1–9 | ⌃⌥1 … ⌃⌥9 | `select_shelf_book_1` … `_9` | yes | +| Select Book 1–9 | ⌥⌃1 … ⌥⌃9 | `select_shelf_book_1` … `_9` | yes | > In Shelf view, **⌘⌃←/→ flips between books (worktrees)** and **⌘⌃↑/↓ cycles the > tabs of the open book** (those are the `select_previous/next_worktree` actions). @@ -98,7 +98,7 @@ your Ghostty config (`~/.config/ghostty/config`). Typical defaults in parenthese | New Terminal (tab) | `new_tab` | ⌘T | | Close Terminal (pane/surface) | `close_surface` | ⌘W | | Close Terminal Tab | `close_tab` | ⌘⇧W | -| New Split (vertical / horizontal) | `new_split:right` / `new_split:down` | (Ghostty default) | +| New Split (vertical / horizontal) | `new_split:right` / `new_split:down` | (Ghostty default; config-only, not in the Terminal menu) | | Reset Font Size | `reset_font_size` | ⌘0 | | Increase Font Size | `increase_font_size:1` | ⌘+ | | Decrease Font Size | `decrease_font_size:1` | ⌘- | @@ -128,7 +128,7 @@ hotkey — see [`components/custom-actions.md`](../components/custom-actions.md) `~/.prowl/settings.json` under `keybindingUserOverrides`. - **`quit_application`** is fixed (`systemFixedAppAction`) — it can't be remapped. - **Canvas/local actions** (`localInteraction`, e.g. Arrange/Organize/Expand, - Rename Branch) are remappable but only checked against each other for conflicts. + Rename Branch) are remappable and conflict-checked against all remappable actions. - **Custom Command** hotkeys are repo-scoped and take precedence over app shortcuts within the focused repository; conflicts are surfaced when recording. - **Terminal engine keys** are owned by Ghostty. Prowl automatically *unbinds* @@ -140,7 +140,7 @@ hotkey — see [`components/custom-actions.md`](../components/custom-actions.md) - These are **defaults**. If a human says "my `⌘P` does X", trust them — they may have remapped it. The CLI is binding-independent; prefer it for automation. -- When two rows look like they share keys (e.g. `⌃⌥↑/↓` for agent navigation vs +- When two rows look like they share keys (e.g. `⌥⌃↑/↓` for agent navigation vs `⌘⌥↑/↓` for pane navigation), check the **modifiers carefully** — Control vs Command distinguishes them. - `select_previous/next_worktree` (⌘⌃↑/↓) is overloaded by design: in Shelf view diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index beae60b5..2186e9f3 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -58,7 +58,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | | `showRunButtonInToolbar` | Bool | `true` | Show the Run Script button in the toolbar. | | `showDefaultEditorInToolbar` | Bool | `true` | Show the open-in-editor button in the toolbar. | -| `dockBounceMode` | enum (`off`/`once`/`continuously`) | `off` | Dock bounce on notification. | +| `dockBounceMode` | enum (`off`/`once`/`continuous`) | `off` | Dock bounce on notification. | | `showNotificationDotOnDock` | Bool | `false` | Numeric unread badge on the Dock icon. | | `shelfSpineTintFallback` | enum (`neutral`/`systemTint`) | `neutral` | Shelf spine color when a repo has no color. | | `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | -- 2.51.2 From 22aa7f5f63d01767c8de8534cbef11a8d8803549 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 15:07:36 +0900 Subject: [PATCH 347/528] Move docs sync baseline to docs/.sync-meta.json The sync-docs baseline lived in .claude/skills/sync-docs/baseline.md, which churned the skill folder on every update and conceptually belongs with the docs, not Claude config. Move it to docs/.sync-meta.json (a dotfile so a future docs site won't render it as a page; JSON so release automation can read it with jq). Update all three references in the sync-docs SKILL.md. --- .claude/skills/sync-docs/SKILL.md | 26 +++++++++++++------------- .claude/skills/sync-docs/baseline.md | 10 ---------- docs/.sync-meta.json | 6 ++++++ 3 files changed, 19 insertions(+), 23 deletions(-) delete mode 100644 .claude/skills/sync-docs/baseline.md create mode 100644 docs/.sync-meta.json diff --git a/.claude/skills/sync-docs/SKILL.md b/.claude/skills/sync-docs/SKILL.md index 6af14d56..c91f588c 100644 --- a/.claude/skills/sync-docs/SKILL.md +++ b/.claude/skills/sync-docs/SKILL.md @@ -39,8 +39,8 @@ The goal is to keep facts correct with **low churn**, not to perfect the prose. ## Steps -1. **Read the baseline.** Open `.claude/skills/sync-docs/baseline.md` and extract - the **last-synced commit** hash. +1. **Read the baseline.** Open `docs/.sync-meta.json` and read the + `last_synced_commit` field (the **last-synced commit** hash). 2. **Diff the implementation since the baseline.** List source files that changed: ```bash @@ -92,9 +92,9 @@ The goal is to keep facts correct with **low churn**, not to perfect the prose. 5. **Check internal links if files moved.** If you renamed/added/removed any doc file, re-run a quick relative-link check so nothing in `docs/` is broken. -6. **Bump the baseline.** Update `.claude/skills/sync-docs/baseline.md` to the - current `HEAD` (hash + date + one-line note). This file is **committed to git** - so the baseline persists across sessions and machines — never leave it +6. **Bump the baseline.** Set `last_synced_commit` (and `last_synced_date`, `note`) + in `docs/.sync-meta.json` to the current `HEAD`. This file is **committed to + git** so the baseline persists across sessions and machines — never leave it uncommitted. 7. **Report.** Output a short summary: @@ -115,11 +115,11 @@ The goal is to keep facts correct with **low churn**, not to perfect the prose. ## Committing -- **Standalone run:** stage and commit only `docs/**` and - `.claude/skills/sync-docs/baseline.md` (never `git add .`). If anything in - `docs/` changed and you're not on `main`, open a PR targeting `onevcat/Prowl`. -- **As part of release prep:** leave the doc + baseline edits staged so they ride - along in the release commit (this is the intended integration point — a docs - freshness check before cutting a release). -- Always bump and commit the baseline even when no doc edits were needed, so the - next run starts from a tight diff range. +- **Standalone run:** stage and commit only `docs/**` (which includes + `docs/.sync-meta.json`); never `git add .`. If anything in `docs/` changed and + you're not on `main`, open a PR targeting `onevcat/Prowl`. +- **As part of release prep:** leave the doc + `docs/.sync-meta.json` edits staged + so they ride along in the release commit (this is the intended integration + point — a docs freshness check before cutting a release). +- Always bump and commit `docs/.sync-meta.json` even when no doc edits were + needed, so the next run starts from a tight diff range. diff --git a/.claude/skills/sync-docs/baseline.md b/.claude/skills/sync-docs/baseline.md deleted file mode 100644 index 3042165c..00000000 --- a/.claude/skills/sync-docs/baseline.md +++ /dev/null @@ -1,10 +0,0 @@ -# sync-docs baseline - -The last commit at which the `docs/` manual was verified against the -implementation. The `sync-docs` skill diffs `HEAD` against this commit to find -what changed, then updates this file. **Committed to git on purpose** — the -baseline must persist across sessions and machines. - -- **Baseline commit:** `49235800` -- **Date:** 2026-06-07 -- **Note:** Initial docs set created and verified against source; baseline starts here. diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json new file mode 100644 index 00000000..30a0742f --- /dev/null +++ b/docs/.sync-meta.json @@ -0,0 +1,6 @@ +{ + "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", + "last_synced_commit": "4923580042fffa019426950a824bac2a8ef892e5", + "last_synced_date": "2026-06-07", + "note": "Initial docs set created and verified against source; baseline starts here." +} -- 2.51.2 From 2259c61831a958f71b91cce65965c7d5a764088c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 15:15:00 +0900 Subject: [PATCH 348/528] sync-docs: advance baseline to 1890db8b (no impl changes; docs current) --- docs/.sync-meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 30a0742f..a2624d7f 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "4923580042fffa019426950a824bac2a8ef892e5", + "last_synced_commit": "1890db8b070f191e5d5554bed84be21df5774f27", "last_synced_date": "2026-06-07", - "note": "Initial docs set created and verified against source; baseline starts here." + "note": "No implementation changes (supacode/, ProwlCLI/, …) since the previous baseline; only docs/.claude edits in between. Docs current. Baseline advanced to keep the next diff range tight." } -- 2.51.2 From 34674271424b881ef68e0e2d7b04e55336e68c84 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 15:25:51 +0900 Subject: [PATCH 349/528] Hook sync-docs into release: docs synced before bump+tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a docs-sync step to the release skill, placed after the clean-tree check and before version bump + tag, so updated docs land on main as their own commit and ship inside the release tag (release.sh force-moves the tag onto the CHANGELOG commit, so any pre-existing main commit is an ancestor). release.sh aborts on a dirty tree, so docs must be committed, not staged — fix the sync-docs committing guidance accordingly. Baseline semantics: last_synced_commit is set to the HEAD at sync time (the code being released), recorded before the doc/bump/tag commits. Those commits touch no implementation files, so the next sync's diff is unaffected; pointing the baseline at the released code is both truthful and one commit cheaper than re-pointing it at the doc commit. --- .claude/skills/release/SKILL.md | 24 ++++++++++++++++++++---- .claude/skills/sync-docs/SKILL.md | 14 ++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2404aee7..9de836c3 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -11,20 +11,36 @@ Build, sign, notarize, and publish a Prowl release. - If not on main, abort and tell the user to switch first 2. Verify working tree is clean: `git status --porcelain` - If dirty, list the changes and ask whether to proceed or abort -3. Determine the version: +3. Sync the docs to the code being released — **before** the version bump and tag: + - Run the `sync-docs` skill. It diffs `docs/.sync-meta.json`'s `last_synced_commit` + against the current `main` HEAD (the code about to ship), updates any docs whose + implementation changed, and sets `last_synced_commit` to the **current HEAD** — + the exact commit being released, recorded *before* the bump/tag. + - Commit the result as its own commit (only `docs/**`, which includes + `docs/.sync-meta.json`; never `git add .`): e.g. `git commit -m "Sync docs for "`. + This is required: `release.sh` aborts on a dirty tree, and the doc commit must land + on `main` *now* so it is an ancestor of the tag and ships inside the release. + - **Ordering is load-bearing: docs first, then bump + tag. Never tag first.** The baseline + points at the released code (the pre-doc-commit HEAD), not at the later doc/bump/CHANGELOG + commits — that is correct and intentional (those commits touch no implementation files, + so the next sync starts from a tight, accurate diff range). + - If sync-docs reports "needs human decision" items, resolve or defer them before continuing. + - Most releases change little or no implementation, so this step is usually a no-op doc-wise + (just a baseline bump). Keep it cheap — see the `sync-docs` skill's cost notes. +4. Determine the version: - If `$ARGUMENTS` is provided, use it as the version (e.g., `2026.3.18`) - Otherwise, default to today's date format and confirm with the user before proceeding -4. Generate release notes: `./doc-onevcat/scripts/release-notes.sh ` +5. Generate release notes: `./doc-onevcat/scripts/release-notes.sh ` - This script compares HEAD against the previous release tag, gathers commits and PR descriptions, and generates user-facing notes via LLM into `build/release-notes.md`. - Read the generated `build/release-notes.md`, show the content to the user, and wait for explicit confirmation. If the user wants changes, edit the file directly. - **Do NOT proceed to the next step until the user confirms the release notes.** -5. Run the release script: `./doc-onevcat/scripts/release.sh ` +6. Run the release script: `./doc-onevcat/scripts/release.sh ` - The script reads `build/release-notes.md` (required — refuses to run without it). - It handles: version bump, build, sign, notarize, DMG, appcast, GitHub Release, and Prowl-Site update. If the tag already exists (e.g., from a prior interrupted run), it skips the bump step automatically. -6. Report the GitHub release URL and remind the user to verify: +7. Report the GitHub release URL and remind the user to verify: - The DMG downloads and installs correctly - Sparkle update check works (launch app → Check for Updates) diff --git a/.claude/skills/sync-docs/SKILL.md b/.claude/skills/sync-docs/SKILL.md index c91f588c..50e42e00 100644 --- a/.claude/skills/sync-docs/SKILL.md +++ b/.claude/skills/sync-docs/SKILL.md @@ -118,8 +118,14 @@ The goal is to keep facts correct with **low churn**, not to perfect the prose. - **Standalone run:** stage and commit only `docs/**` (which includes `docs/.sync-meta.json`); never `git add .`. If anything in `docs/` changed and you're not on `main`, open a PR targeting `onevcat/Prowl`. -- **As part of release prep:** leave the doc + `docs/.sync-meta.json` edits staged - so they ride along in the release commit (this is the intended integration - point — a docs freshness check before cutting a release). +- **As part of release prep** (the `release` skill, on `main`): commit the doc + + `docs/.sync-meta.json` changes as **their own commit before the version bump and + tag** — e.g. `git commit -m "Sync docs for "`. Do **not** leave them + staged/uncommitted: `release.sh` aborts on a dirty working tree, and the doc + commit must already be on `main` so it becomes an ancestor of the tag and ships + inside the release. Bump/tag happen after, never before. - Always bump and commit `docs/.sync-meta.json` even when no doc edits were - needed, so the next run starts from a tight diff range. + needed, so the next run starts from a tight diff range. The baseline records the + commit the docs were verified against (the current HEAD at run time) — for a + release that is the code being shipped, captured before the later doc/bump/tag + commits, which is correct (those commits touch no implementation files). -- 2.51.2 From e5d0e1e4e616abd516892051c618ef0742964845 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:02:42 +0900 Subject: [PATCH 350/528] Bundle docs into the app + add 'Ask Agent About Prowl' help Ship the agent-facing manual (docs/) inside the app bundle at Contents/Resources/docs, and surface a one-click way to point an AI agent at it. - Bundling: stage docs/ into Resources/docs (Makefile embed-docs, wired into build-app/archive/test; gitignored) and add it as a Resources folder reference, mirroring how ghostty/prowl-cli are bundled. SupacodePaths gains bundledDocs* helpers for the on-disk path. - Ask Agent help: a localized, copyable prompt (en/zh-Hans/zh-Hant/ja, fallback en; every variant asks the agent to reply in the user's language) that points the agent at the bundled docs and asks it to introduce Prowl and suggest features tailored to the user. Reachable from the sidebar Help menu and the macOS Help menu via a shared @Observable presenter + a sheet on the main window. - README: an English prompt pointing agents at docs/README.md on GitHub. - Tests for the locale->prompt logic. --- .gitignore | 1 + Makefile | 16 +- README.md | 20 ++ supacode.xcodeproj/project.pbxproj | 4 + supacode/App/supacodeApp.swift | 15 ++ .../Features/Help/AskAgentHelpPresenter.swift | 20 ++ .../Features/Help/AskAgentHelpPrompt.swift | 193 ++++++++++++++++++ supacode/Features/Help/AskAgentHelpView.swift | 84 ++++++++ .../Views/SidebarFooterView.swift | 6 + supacode/Support/SupacodePaths.swift | 21 ++ supacodeTests/AskAgentHelpPromptTests.swift | 48 +++++ 11 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 supacode/Features/Help/AskAgentHelpPresenter.swift create mode 100644 supacode/Features/Help/AskAgentHelpPrompt.swift create mode 100644 supacode/Features/Help/AskAgentHelpView.swift create mode 100644 supacodeTests/AskAgentHelpPromptTests.swift diff --git a/.gitignore b/.gitignore index 454ad20e..449e18de 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ fastlane/test_output Frameworks/GhosttyKit.xcframework Resources/ghostty Resources/terminfo +Resources/docs .ghostty_hash .ghostty_build_stamp *.profraw diff --git a/Makefile b/Makefile index 419f64e9..08bcda32 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -91,7 +91,15 @@ sync-ghostty: # Force sync GhosttyKit to current submodule HEAD (always rebuilds rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-* @echo "Done. Xcode module cache cleared for fresh compilation." -build-app: ensure-ghostty embed-cli-debug # Build the macOS app (Debug) +embed-docs: # Stage docs/ into Resources for bundling into the app (.app/Contents/Resources/docs) + @set -euo pipefail; \ + src="$(CURRENT_MAKEFILE_DIR)/docs"; \ + dst="$(CURRENT_MAKEFILE_DIR)/Resources/docs"; \ + mkdir -p "$$dst"; \ + rsync -a --delete --exclude '.sync-meta.json' "$$src/" "$$dst/"; \ + echo "embedded docs at $$dst" + +build-app: ensure-ghostty embed-cli-debug embed-docs # Build the macOS app (Debug) bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -w --format toon' sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion.swift @@ -271,13 +279,13 @@ install-release: build-ghostty-xcframework # Build Release, sign locally, instal ditto "$$APP_PATH" "$$DST"; \ echo "installed $$DST (Release build, locally signed)" -archive: build-ghostty-xcframework embed-cli # Archive Release build for distribution +archive: build-ghostty-xcframework embed-cli embed-docs # Archive Release build for distribution 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" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' export-archive: # Export xarchive 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' -test: ensure-ghostty embed-cli-debug test-app +test: ensure-ghostty embed-cli-debug embed-docs test-app test-app: ensure-ghostty # Run app/unit tests via xcodebuild @set -euo pipefail; \ diff --git a/README.md b/README.md index 159d8229..8c7d10a9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,26 @@ --- +## 🐾 Meet Prowl through your agent + +Prefer to learn the agentic way instead of scrolling? Hand this prompt to your coding agent (Claude Code, Codex, …) or any AI assistant — it reads Prowl's full documentation and gives you a tailored introduction: + +```text +Read Prowl's documentation and introduce it to me. + +Prowl is a native macOS command center for running many AI coding agents in parallel. Its full manual lives here: +https://github.com/onevcat/Prowl/blob/main/docs/README.md + +Fetch that index and read it (it links to an overview and per-feature manuals in the same docs/ folder — read the relevant ones). Then: +1. Briefly tell me what Prowl is and why it's worth my time. +2. Based on what you know about how I work, suggest 3–4 Prowl features that would genuinely help me, each with a one-line "how". +3. Then answer my follow-up questions, consulting the matching doc. + +Reply in my preferred language. +``` + +> Already installed Prowl? Your agent can read the same docs straight from the app bundle — choose **Help → Ask Agent About Prowl** in the app to copy a version localized to your language. + ## Why Prowl? You're not just typing commands anymore — you're orchestrating Claude Code, Codex, and friends across repos, branches, and ideas. Prowl is the terminal built for that. diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index cc0933ef..d00dfc2a 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */; }; D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB342F1F4AC2004FDABD /* Carbon.framework */; }; D6CLI0012F99000100000001 /* prowl-cli in Resources */ = {isa = PBXBuildFile; fileRef = D6CLI0022F99000100000001 /* prowl-cli */; }; + D6DOC0012F99000300000001 /* docs in Resources */ = {isa = PBXBuildFile; fileRef = D6DOC0022F99000300000001 /* docs */; }; D6D054282F2E3EBF00D70ED5 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = D6D054272F2E3EBF00D70ED5 /* PostHog */; }; DFE570C142184CC387C1BC78 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B5698573F7354B14B940EA60 /* Sparkle */; }; /* End PBXBuildFile section */ @@ -41,6 +42,7 @@ D6A1CB2F2F1F4A48004FDABD /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; D6A1CB342F1F4AC2004FDABD /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; D6CLI0022F99000100000001 /* prowl-cli */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "prowl-cli"; path = "Resources/prowl-cli"; sourceTree = ""; }; + D6DOC0022F99000300000001 /* docs */ = {isa = PBXFileReference; lastKnownFileType = folder; name = docs; path = "Resources/docs"; sourceTree = ""; }; D6F821012F1FCBC1004B4174 /* supacodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = supacodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -101,6 +103,7 @@ D69CE0412F1F378200584C57 = { isa = PBXGroup; children = ( + D6DOC0022F99000300000001 /* docs */, D64162AD2F23CAF100260CA3 /* ghostty */, D64162AF2F23CAF100260CA3 /* git-wt */, D6CLI0022F99000100000001 /* prowl-cli */, @@ -241,6 +244,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D6DOC0012F99000300000001 /* docs in Resources */, D64162B02F23CAF100260CA3 /* ghostty in Resources */, D64162B12F23CAF100260CA3 /* terminfo in Resources */, D64162B22F23CAF100260CA3 /* git-wt in Resources */, diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 5394eb16..ac782bd4 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -106,6 +106,7 @@ struct SupacodeApp: App { @State private var cliSocketServer: CLISocketServer @State private var store: StoreOf @State private var memoryWatchdog: MemoryWatchdog + @State private var askAgentHelp = AskAgentHelpPresenter() private static func cliLaunchOpenPath() -> String? { let args = ProcessInfo.processInfo.arguments @@ -749,6 +750,15 @@ struct SupacodeApp: App { .environment(ghosttyShortcuts) .environment(commandKeyObserver) .environment(\.resolvedKeybindings, store.resolvedKeybindings) + .environment(askAgentHelp) + .sheet( + isPresented: Binding( + get: { askAgentHelp.isPresented }, + set: { askAgentHelp.isPresented = $0 } + ) + ) { + AskAgentHelpView { askAgentHelp.dismiss() } + } } .registersMainWindowOpener() .onAppear { @@ -822,6 +832,11 @@ struct SupacodeApp: App { } #endif CommandGroup(replacing: .help) { + Button("Ask Agent About Prowl…", systemImage: "sparkles") { + askAgentHelp.present() + } + .help("Copy a prompt that points your AI agent at Prowl's bundled docs") + Divider() Button("Homepage", systemImage: "house") { if let url = URL(string: "https://prowl.onev.cat/") { NSWorkspace.shared.open(url) diff --git a/supacode/Features/Help/AskAgentHelpPresenter.swift b/supacode/Features/Help/AskAgentHelpPresenter.swift new file mode 100644 index 00000000..319f0ff4 --- /dev/null +++ b/supacode/Features/Help/AskAgentHelpPresenter.swift @@ -0,0 +1,20 @@ +import Observation + +/// Shared trigger for the "Ask an agent about Prowl" help dialog. +/// +/// Both the sidebar Help menu and the macOS Help menu set `isPresented`; the +/// main window observes it and presents the sheet. It's transient UI state, so +/// it lives in a lightweight `@Observable` store rather than the TCA tree. +@MainActor +@Observable +final class AskAgentHelpPresenter { + var isPresented = false + + func present() { + isPresented = true + } + + func dismiss() { + isPresented = false + } +} diff --git a/supacode/Features/Help/AskAgentHelpPrompt.swift b/supacode/Features/Help/AskAgentHelpPrompt.swift new file mode 100644 index 00000000..3e1d6c2a --- /dev/null +++ b/supacode/Features/Help/AskAgentHelpPrompt.swift @@ -0,0 +1,193 @@ +import Foundation + +/// User-facing strings for the "Ask an agent about Prowl" help dialog. +/// +/// `prompt` is the copyable text the user hands to their AI agent; the rest is +/// chrome for the dialog itself. Everything is localized to the user's device +/// language so the prompt is transparent at a glance, and every prompt also +/// asks the agent to answer in the user's preferred language. +nonisolated struct AskAgentHelpStrings: Equatable, Sendable { + var title: String + var explanation: String + var prompt: String + var copyButtonTitle: String + var copiedButtonTitle: String + var doneButtonTitle: String +} + +/// Builds the localized "Ask an agent" help content. Pure and locale-driven so +/// it can be unit-tested without a running app. +nonisolated enum AskAgentHelpPrompt { + enum LanguageKey: Equatable, Sendable { + case english + case simplifiedChinese + case traditionalChinese + case japanese + } + + /// Resolve which prompt language to use from a locale. Chinese disambiguates + /// Hans/Hant by script, falling back to region (TW/HK/MO → Traditional). + /// Anything we don't translate falls back to English (which still asks the + /// agent to reply in the user's preferred language). + static func languageKey(for locale: Locale) -> LanguageKey { + switch locale.language.languageCode?.identifier { + case "ja": + return .japanese + case "zh": + if let script = locale.language.script?.identifier { + return script == "Hant" ? .traditionalChinese : .simplifiedChinese + } + switch locale.region?.identifier { + case "TW", "HK", "MO": + return .traditionalChinese + default: + return .simplifiedChinese + } + default: + return .english + } + } + + static func strings(docsDirectoryPath: String, locale: Locale = .current) -> AskAgentHelpStrings { + let readme = "\(docsDirectoryPath)/README.md" + let overview = "\(docsDirectoryPath)/overview.md" + switch languageKey(for: locale) { + case .english: + return english(readme: readme, overview: overview) + case .simplifiedChinese: + return simplifiedChinese(readme: readme, overview: overview) + case .traditionalChinese: + return traditionalChinese(readme: readme, overview: overview) + case .japanese: + return japanese(readme: readme, overview: overview) + } + } + + // MARK: - English + + private static func english(readme: String, overview: String) -> AskAgentHelpStrings { + AskAgentHelpStrings( + title: "Ask an agent about Prowl", + explanation: + "Copy this prompt and paste it into your coding agent (Claude Code, Codex, …) " + + "in a terminal, or any AI assistant. It points the agent at the documentation " + + "bundled inside Prowl and asks it to introduce Prowl and suggest features useful to you.", + prompt: """ + Read Prowl's bundled documentation and introduce it to me. + + Prowl is a native macOS command center for running many AI coding agents in parallel. \ + Its full manual ships inside the app: + + - Index: \(readme) + - Highlights: \(overview) + + Read those two first — the index links to per-feature manuals in the same folder; \ + open whichever are relevant. Then: + + 1. Briefly tell me what Prowl is and why it's worth my time. + 2. Based on what you know about how I work (my projects, tools, and habits), suggest \ + 3–4 Prowl features or workflows that would genuinely help me — one line of "how" each. + 3. Then let me ask follow-up questions, consulting the matching file in that folder as needed. + + Reply in my preferred language. + """, + copyButtonTitle: "Copy Prompt", + copiedButtonTitle: "Copied!", + doneButtonTitle: "Done" + ) + } + + // MARK: - Simplified Chinese + + private static func simplifiedChinese(readme: String, overview: String) -> AskAgentHelpStrings { + AskAgentHelpStrings( + title: "让 agent 介绍 Prowl", + explanation: + "复制这段提示词,粘贴给你在终端里的编码 agent(Claude Code、Codex…)或任意 AI 助手。" + + "它会让 agent 读取 Prowl 内置的文档,介绍 Prowl 并推荐对你有用的功能。", + prompt: """ + 阅读 Prowl 自带的文档,并向我介绍它。 + + Prowl 是一款原生 macOS 指挥中心,用来并行运行多个 AI 编码 agent。它的完整说明书就打包在 app 内: + + - 索引: \(readme) + - 亮点: \(overview) + + 请先读这两份——索引里链接了各功能的分册(在同一文件夹下),按需打开相关的。然后: + + 1. 简要告诉我 Prowl 是什么、为什么值得我花时间。 + 2. 结合你对我工作方式的了解(我的项目、工具和习惯),推荐 3–4 个真正能帮到我的 Prowl 功能或用法,每个配一句“怎么用”。 + 3. 之后让我继续追问,按需查阅该文件夹下对应的文档。 + + 请用我的首选语言回答。 + """, + copyButtonTitle: "复制提示词", + copiedButtonTitle: "已复制!", + doneButtonTitle: "完成" + ) + } + + // MARK: - Traditional Chinese + + private static func traditionalChinese(readme: String, overview: String) -> AskAgentHelpStrings { + AskAgentHelpStrings( + title: "讓 agent 介紹 Prowl", + explanation: + "複製這段提示詞,貼給你在終端機裡的編碼 agent(Claude Code、Codex…)或任意 AI 助手。" + + "它會讓 agent 讀取 Prowl 內建的文件,介紹 Prowl 並推薦對你有用的功能。", + prompt: """ + 閱讀 Prowl 內建的文件,並向我介紹它。 + + Prowl 是一款原生 macOS 指揮中心,用來並行執行多個 AI 編碼 agent。它的完整說明書就打包在 app 內: + + - 索引: \(readme) + - 亮點: \(overview) + + 請先讀這兩份——索引裡連結了各功能的分冊(在同一資料夾下),按需開啟相關的。然後: + + 1. 簡要告訴我 Prowl 是什麼、為什麼值得我花時間。 + 2. 結合你對我工作方式的了解(我的專案、工具和習慣),推薦 3–4 個真正能幫到我的 Prowl 功能或用法,每個配一句「怎麼用」。 + 3. 之後讓我繼續追問,按需查閱該資料夾下對應的文件。 + + 請用我的首選語言回答。 + """, + copyButtonTitle: "複製提示詞", + copiedButtonTitle: "已複製!", + doneButtonTitle: "完成" + ) + } + + // MARK: - Japanese + + private static func japanese(readme: String, overview: String) -> AskAgentHelpStrings { + AskAgentHelpStrings( + title: "エージェントに Prowl を尋ねる", + explanation: + "このプロンプトをコピーして、ターミナルのコーディングエージェント(Claude Code、Codex など)" + + "や任意の AI アシスタントに貼り付けてください。Prowl に同梱されたドキュメントを読ませ、" + + "Prowl の紹介とあなたに役立つ機能の提案をしてもらえます。", + prompt: """ + Prowl に同梱されているドキュメントを読んで、私に紹介してください。 + + Prowl は、複数の AI コーディングエージェントを並行して動かすためのネイティブ macOS \ + コマンドセンターです。完全なマニュアルはアプリ内に同梱されています: + + - 索引: \(readme) + - ハイライト: \(overview) + + まずこの2つを読んでください。索引から各機能のマニュアル(同じフォルダ内)にリンクしているので、\ + 関連するものを開いてください。そのうえで: + + 1. Prowl が何で、なぜ使う価値があるのかを簡潔に教えてください。 + 2. 私の働き方(プロジェクト・ツール・習慣)について知っていることを踏まえて、本当に役立つ \ + Prowl の機能や使い方を3〜4個、それぞれ「どう使うか」を一言添えて提案してください。 + 3. その後の私の質問にも、同じフォルダ内の該当ドキュメントを参照しながら答えてください。 + + 私の優先言語で回答してください。 + """, + copyButtonTitle: "プロンプトをコピー", + copiedButtonTitle: "コピーしました!", + doneButtonTitle: "完了" + ) + } +} diff --git a/supacode/Features/Help/AskAgentHelpView.swift b/supacode/Features/Help/AskAgentHelpView.swift new file mode 100644 index 00000000..c5061e7b --- /dev/null +++ b/supacode/Features/Help/AskAgentHelpView.swift @@ -0,0 +1,84 @@ +import AppKit +import SwiftUI + +/// A small, self-contained help sheet that hands the user a ready-to-paste +/// prompt for their AI agent. The prompt points the agent at the documentation +/// bundled inside the app (`Contents/Resources/docs`). Localized to the device +/// language; purely presentational (no store). +struct AskAgentHelpView: View { + private let strings: AskAgentHelpStrings + private let onDone: () -> Void + @State private var didCopy = false + + init( + docsDirectoryPath: String = AskAgentHelpView.resolvedDocsDirectoryPath, + locale: Locale = .current, + onDone: @escaping () -> Void + ) { + self.strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docsDirectoryPath, locale: locale) + self.onDone = onDone + } + + /// The bundled docs directory, falling back to the standard install path so + /// the prompt always shows a sensible location even if the bundle lookup fails. + static var resolvedDocsDirectoryPath: String { + SupacodePaths.bundledDocsDirectoryPath ?? "/Applications/Prowl.app/Contents/Resources/docs" + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(strings.title, systemImage: "sparkles") + .font(.headline) + + Text(strings.explanation) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + ScrollView { + Text(strings.prompt) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .frame(minHeight: 200, maxHeight: 300) + .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.separator, lineWidth: 1) + ) + + HStack { + Button { + copyPrompt() + } label: { + Label( + didCopy ? strings.copiedButtonTitle : strings.copyButtonTitle, + systemImage: didCopy ? "checkmark" : "doc.on.doc" + ) + } + .buttonStyle(.borderedProminent) + + Spacer() + + Button(strings.doneButtonTitle) { + onDone() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 540) + } + + private func copyPrompt() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(strings.prompt, forType: .string) + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + didCopy = false + } + } +} diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 24664889..52919568 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -6,10 +6,16 @@ struct SidebarFooterView: View { @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity @Environment(\.openURL) private var openURL @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(AskAgentHelpPresenter.self) private var askAgentHelp var body: some View { HStack { Menu { + Button("Ask Agent About Prowl", systemImage: "sparkles") { + askAgentHelp.present() + } + .help("Copy a prompt that points your AI agent at Prowl's bundled docs") + Divider() Button("Homepage", systemImage: "house") { if let url = URL(string: "https://prowl.onev.cat/") { openURL(url) diff --git a/supacode/Support/SupacodePaths.swift b/supacode/Support/SupacodePaths.swift index 9b9fc0d1..f70b0667 100644 --- a/supacode/Support/SupacodePaths.swift +++ b/supacode/Support/SupacodePaths.swift @@ -32,6 +32,27 @@ nonisolated enum SupacodePaths { appSupportDirectory.appending(path: "cache", directoryHint: .isDirectory) } + /// The agent-facing documentation set bundled inside the app + /// (`Prowl.app/Contents/Resources/docs`). Shipped so users and their + /// AI agents can read the manual straight from the installed app. + static var bundledDocsURL: URL? { + Bundle.main.resourceURL?.appending(path: "docs", directoryHint: .isDirectory) + } + + /// On-disk path to the bundled docs index (`docs/README.md`), e.g. + /// `/Applications/Prowl.app/Contents/Resources/docs/README.md`. `nil` only + /// if the bundle has no resource directory (should not happen at runtime). + static var bundledDocsReadmePath: String? { + bundledDocsURL? + .appending(path: "README.md", directoryHint: .notDirectory) + .path(percentEncoded: false) + } + + /// On-disk path to the bundled docs directory itself. + static var bundledDocsDirectoryPath: String? { + bundledDocsURL?.path(percentEncoded: false) + } + static var reposDirectory: URL { baseDirectory.appending(path: "repos", directoryHint: .isDirectory) } diff --git a/supacodeTests/AskAgentHelpPromptTests.swift b/supacodeTests/AskAgentHelpPromptTests.swift new file mode 100644 index 00000000..74ed62f7 --- /dev/null +++ b/supacodeTests/AskAgentHelpPromptTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import supacode + +struct AskAgentHelpPromptTests { + @Test func languageKeyMapsCommonLocales() { + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "en_US")) == .english) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "ja_JP")) == .japanese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "fr_FR")) == .english) + } + + @Test func chineseDisambiguatesByScriptThenRegion() { + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh_CN")) == .simplifiedChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh_TW")) == .traditionalChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh_HK")) == .traditionalChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hant")) == .traditionalChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hans")) == .simplifiedChinese) + } + + @Test func promptEmbedsResolvedDocPaths() { + let docs = "/Applications/Prowl.app/Contents/Resources/docs" + let strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "en_US")) + #expect(strings.prompt.contains("\(docs)/README.md")) + #expect(strings.prompt.contains("\(docs)/overview.md")) + } + + @Test func everyLanguageAsksToReplyInPreferredLanguage() { + let docs = "/Applications/Prowl.app/Contents/Resources/docs" + let locales = ["en_US", "zh_CN", "zh_TW", "ja_JP"] + for identifier in locales { + let strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: identifier)) + #expect(!strings.prompt.isEmpty) + #expect(!strings.title.isEmpty) + #expect(strings.prompt.contains(docs)) + } + } + + @Test func localizedTitlesDiffer() { + let docs = "/tmp/docs" + let english = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "en_US")) + let japanese = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "ja_JP")) + let simplified = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "zh_CN")) + #expect(english.prompt != japanese.prompt) + #expect(english.prompt != simplified.prompt) + #expect(english.copyButtonTitle == "Copy Prompt") + } +} -- 2.51.2 From 6ece84ff9f2d239acc355e37284f84b5d086f783 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:09:21 +0900 Subject: [PATCH 351/528] README: point the agent prompt at raw docs URL (clean markdown) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c7d10a9..736cd549 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Prefer to learn the agentic way instead of scrolling? Hand this prompt to your c Read Prowl's documentation and introduce it to me. Prowl is a native macOS command center for running many AI coding agents in parallel. Its full manual lives here: -https://github.com/onevcat/Prowl/blob/main/docs/README.md +https://raw.githubusercontent.com/onevcat/Prowl/refs/heads/main/docs/README.md Fetch that index and read it (it links to an overview and per-feature manuals in the same docs/ folder — read the relevant ones). Then: 1. Briefly tell me what Prowl is and why it's worth my time. -- 2.51.2 From d4a9529e40f642aafb07fbc08190477bc1bb78aa Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:24:19 +0900 Subject: [PATCH 352/528] Ask Agent prompt: follow system language, not app-capped Locale.current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prowl is English-only, so Locale.current is capped to English even when the OS is set to another language — the prompt always rendered in English. Resolve the locale from Locale.preferredLanguages (the uncapped system preference) instead, and harden Chinese script detection so zh-Hans-JP (Simplified user in Japan) maps to Simplified regardless of region. --- .../Features/Help/AskAgentHelpPrompt.swift | 24 ++++++++++++++----- supacode/Features/Help/AskAgentHelpView.swift | 2 +- supacodeTests/AskAgentHelpPromptTests.swift | 8 +++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/supacode/Features/Help/AskAgentHelpPrompt.swift b/supacode/Features/Help/AskAgentHelpPrompt.swift index 3e1d6c2a..47e4ed40 100644 --- a/supacode/Features/Help/AskAgentHelpPrompt.swift +++ b/supacode/Features/Help/AskAgentHelpPrompt.swift @@ -25,18 +25,30 @@ nonisolated enum AskAgentHelpPrompt { case japanese } + /// The user's top system-preferred language, independent of the app's bundle + /// localizations. Prowl ships English only, so `Locale.current` is capped to + /// English even on, say, a Simplified-Chinese system — but the prompt should + /// follow the *system* language. `Locale.preferredLanguages` is not capped. + static func systemPreferredLocale() -> Locale { + Locale(identifier: Locale.preferredLanguages.first ?? "en") + } + /// Resolve which prompt language to use from a locale. Chinese disambiguates - /// Hans/Hant by script, falling back to region (TW/HK/MO → Traditional). - /// Anything we don't translate falls back to English (which still asks the - /// agent to reply in the user's preferred language). + /// Hans/Hant by script (then the identifier text, then region — e.g. + /// `zh-Hans-JP` is Simplified even though its region is JP). Anything we don't + /// translate falls back to English (which still asks the agent to reply in the + /// user's preferred language). static func languageKey(for locale: Locale) -> LanguageKey { switch locale.language.languageCode?.identifier { case "ja": return .japanese case "zh": - if let script = locale.language.script?.identifier { - return script == "Hant" ? .traditionalChinese : .simplifiedChinese - } + let script = locale.language.script?.identifier + if script == "Hant" { return .traditionalChinese } + if script == "Hans" { return .simplifiedChinese } + let identifier = locale.identifier + if identifier.contains("Hant") { return .traditionalChinese } + if identifier.contains("Hans") { return .simplifiedChinese } switch locale.region?.identifier { case "TW", "HK", "MO": return .traditionalChinese diff --git a/supacode/Features/Help/AskAgentHelpView.swift b/supacode/Features/Help/AskAgentHelpView.swift index c5061e7b..3831d2cc 100644 --- a/supacode/Features/Help/AskAgentHelpView.swift +++ b/supacode/Features/Help/AskAgentHelpView.swift @@ -12,7 +12,7 @@ struct AskAgentHelpView: View { init( docsDirectoryPath: String = AskAgentHelpView.resolvedDocsDirectoryPath, - locale: Locale = .current, + locale: Locale = AskAgentHelpPrompt.systemPreferredLocale(), onDone: @escaping () -> Void ) { self.strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docsDirectoryPath, locale: locale) diff --git a/supacodeTests/AskAgentHelpPromptTests.swift b/supacodeTests/AskAgentHelpPromptTests.swift index 74ed62f7..95925b39 100644 --- a/supacodeTests/AskAgentHelpPromptTests.swift +++ b/supacodeTests/AskAgentHelpPromptTests.swift @@ -18,6 +18,14 @@ struct AskAgentHelpPromptTests { #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hans")) == .simplifiedChinese) } + // Script wins over region: e.g. a Simplified-Chinese user living in Japan + // reports `zh-Hans-JP` — region JP must not flip it to English/Traditional. + @Test func chineseScriptWinsOverNonChineseRegion() { + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hans-JP")) == .simplifiedChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hant-JP")) == .traditionalChinese) + #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hans-US")) == .simplifiedChinese) + } + @Test func promptEmbedsResolvedDocPaths() { let docs = "/Applications/Prowl.app/Contents/Resources/docs" let strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "en_US")) -- 2.51.2 From 03955574db04bcc9abeb218f10e69301bfe60257 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:31:54 +0900 Subject: [PATCH 353/528] Ask Agent prompt: fix double slash in docs path; smoother zh wording - Strip the trailing slash from the resolved docs directory before composing README.md/overview.md so the path is docs/README.md, not docs//README.md. - Reword the zh-Hans/zh-Hant 'open the relevant ones' line to read naturally. --- supacode/Features/Help/AskAgentHelpPrompt.swift | 14 ++++++++++---- supacodeTests/AskAgentHelpPromptTests.swift | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Help/AskAgentHelpPrompt.swift b/supacode/Features/Help/AskAgentHelpPrompt.swift index 47e4ed40..380e39b1 100644 --- a/supacode/Features/Help/AskAgentHelpPrompt.swift +++ b/supacode/Features/Help/AskAgentHelpPrompt.swift @@ -61,8 +61,14 @@ nonisolated enum AskAgentHelpPrompt { } static func strings(docsDirectoryPath: String, locale: Locale = .current) -> AskAgentHelpStrings { - let readme = "\(docsDirectoryPath)/README.md" - let overview = "\(docsDirectoryPath)/overview.md" + // The resolved bundle path is a directory URL, so it can carry a trailing + // slash — strip it so we don't compose `docs//README.md`. + var base = docsDirectoryPath + while base.hasSuffix("/") { + base.removeLast() + } + let readme = "\(base)/README.md" + let overview = "\(base)/overview.md" switch languageKey(for: locale) { case .english: return english(readme: readme, overview: overview) @@ -125,7 +131,7 @@ nonisolated enum AskAgentHelpPrompt { - 索引: \(readme) - 亮点: \(overview) - 请先读这两份——索引里链接了各功能的分册(在同一文件夹下),按需打开相关的。然后: + 请先读这两份——索引里链接了各功能的分册(在同一文件夹下),可以按需打开。然后: 1. 简要告诉我 Prowl 是什么、为什么值得我花时间。 2. 结合你对我工作方式的了解(我的项目、工具和习惯),推荐 3–4 个真正能帮到我的 Prowl 功能或用法,每个配一句“怎么用”。 @@ -155,7 +161,7 @@ nonisolated enum AskAgentHelpPrompt { - 索引: \(readme) - 亮點: \(overview) - 請先讀這兩份——索引裡連結了各功能的分冊(在同一資料夾下),按需開啟相關的。然後: + 請先讀這兩份——索引裡連結了各功能的分冊(在同一資料夾下),可以按需開啟。然後: 1. 簡要告訴我 Prowl 是什麼、為什麼值得我花時間。 2. 結合你對我工作方式的了解(我的專案、工具和習慣),推薦 3–4 個真正能幫到我的 Prowl 功能或用法,每個配一句「怎麼用」。 diff --git a/supacodeTests/AskAgentHelpPromptTests.swift b/supacodeTests/AskAgentHelpPromptTests.swift index 95925b39..0ffc6749 100644 --- a/supacodeTests/AskAgentHelpPromptTests.swift +++ b/supacodeTests/AskAgentHelpPromptTests.swift @@ -26,6 +26,16 @@ struct AskAgentHelpPromptTests { #expect(AskAgentHelpPrompt.languageKey(for: Locale(identifier: "zh-Hans-US")) == .simplifiedChinese) } + @Test func docPathTrailingSlashIsNormalized() { + let strings = AskAgentHelpPrompt.strings( + docsDirectoryPath: "/Applications/Prowl.app/Contents/Resources/docs/", + locale: Locale(identifier: "en_US") + ) + #expect(strings.prompt.contains("/Applications/Prowl.app/Contents/Resources/docs/README.md")) + #expect(strings.prompt.contains("/Applications/Prowl.app/Contents/Resources/docs/overview.md")) + #expect(!strings.prompt.contains("docs//README.md")) + } + @Test func promptEmbedsResolvedDocPaths() { let docs = "/Applications/Prowl.app/Contents/Resources/docs" let strings = AskAgentHelpPrompt.strings(docsDirectoryPath: docs, locale: Locale(identifier: "en_US")) -- 2.51.2 From 8fa2ee927ec220983b979107ac765e079e9d0f78 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:33:47 +0900 Subject: [PATCH 354/528] docs(overview): promote Active Agents panel to its own high-priority highlight It was folded into the Agent Reminders bullet and listed last, so agents reading the overview rarely surfaced it. Split it into a dedicated, earlier highlight framed as the fleet 'dispatcher' (live roster, urgency sort, one-click/keyboard jump-to-agent), and slim the reminders bullet to focus on notifications. --- docs/overview.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/overview.md b/docs/overview.md index 9f05d5ab..726f380c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -41,6 +41,18 @@ cycles books, `⌘⌃↑`/`⌘⌃↓` cycles tabs** — so six agents in flight one-keystroke triage instead of a hunt. → [`components/shelf.md`](components/shelf.md) +### 👀 Active Agents panel — your dispatcher for a fleet +With several agents in flight, the bottleneck isn't running them — it's knowing +**where to look next**. The Active Agents panel is a live roster of **every agent +across every worktree, tab, and split**, sorted by urgency: **Blocked** (waiting +on your approval) first, then **Working**, **Done** (just finished), and **Idle**. +One click jumps straight to that agent's pane, and **`⌃⌥↑`/`⌃⌥↓`** cycle through +them from the keyboard — so "which of my ten agents needs me right now?" becomes a +glance instead of a hunt. For supervising many agents at once, this is the single +most efficient habit in Prowl. +→ [`components/active-agents.md`](components/active-agents.md) · +[`components/agent-detection.md`](components/agent-detection.md) + ### 🤖 The `prowl` CLI — let your agents drive the terminal Prowl ships a `prowl` command-line tool so **both you and your agents** can inspect and control the app programmatically. An agent can discover sibling @@ -61,15 +73,13 @@ command every day. Paired with `claude -p` / `codex exec`, your terminal becomes a daily AI-powered assistant on a keystroke. → [`components/custom-actions.md`](components/custom-actions.md) -### 🔔 Active Agents panel & Agent Reminders -A live list of **every running agent across every worktree**, each with a -status — Working, Blocked (needs you), Done, or Idle — and one-click jump-to-agent. -The moment an agent finishes, Prowl can fire a macOS notification, badge the -Dock, and float the worktree to the top, so you can leave the screen and come -back exactly when you're needed. -→ [`components/active-agents.md`](components/active-agents.md) · -[`components/agent-detection.md`](components/agent-detection.md) · -[`components/notifications.md`](components/notifications.md) +### 🔔 Agent Reminders — come back exactly when you're needed +Walk away from the screen. The moment an agent finishes — or blocks on a prompt +waiting for you — Prowl fires a macOS notification, badges the Dock, and floats +that worktree to the top, so a long run never sits unnoticed. Pairs naturally +with the Active Agents panel above: get pinged, then jump straight to whoever +needs you. +→ [`components/notifications.md`](components/notifications.md) ## And the fundamentals, done right -- 2.51.2 From 42180405d77d03d288b182465d286f73c810be16 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 16:37:34 +0900 Subject: [PATCH 355/528] docs(overview): Active Agents panel is color-coded by status, not urgency-sorted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel renders entries in detection order (IdentifiedArray, updated in place; no status sort anywhere in ActiveAgents). Status is conveyed by row color/label so you spot Blocked at a glance — correct the overview wording that wrongly claimed urgency sorting. active-agents.md already states the detection-order behavior accurately. --- docs/overview.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/overview.md b/docs/overview.md index 726f380c..2a03389b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -44,12 +44,13 @@ one-keystroke triage instead of a hunt. ### 👀 Active Agents panel — your dispatcher for a fleet With several agents in flight, the bottleneck isn't running them — it's knowing **where to look next**. The Active Agents panel is a live roster of **every agent -across every worktree, tab, and split**, sorted by urgency: **Blocked** (waiting -on your approval) first, then **Working**, **Done** (just finished), and **Idle**. -One click jumps straight to that agent's pane, and **`⌃⌥↑`/`⌃⌥↓`** cycle through -them from the keyboard — so "which of my ten agents needs me right now?" becomes a -glance instead of a hunt. For supervising many agents at once, this is the single -most efficient habit in Prowl. +across every worktree, tab, and split**, each row **color-coded by status** so you +can tell at a glance who needs you: **Blocked** (waiting on your approval), +**Working**, **Done** (just finished), or **Idle**. One click jumps straight to +that agent's pane, and **`⌃⌥↑`/`⌃⌥↓`** cycle through them from the keyboard — so +"which of my ten agents needs me right now?" becomes a glance instead of a hunt. +For supervising many agents at once, this is the single most efficient habit in +Prowl. → [`components/active-agents.md`](components/active-agents.md) · [`components/agent-detection.md`](components/agent-detection.md) -- 2.51.2 From 90b52b0a19e28d35a699d9dc9d6914d809a6b5c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 17:10:06 +0900 Subject: [PATCH 356/528] Sync docs for 2026.6.7 --- docs/.sync-meta.json | 4 ++-- docs/overview.md | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index a2624d7f..1e60e100 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "1890db8b070f191e5d5554bed84be21df5774f27", + "last_synced_commit": "42180405d77d03d288b182465d286f73c810be16", "last_synced_date": "2026-06-07", - "note": "No implementation changes (supacode/, ProwlCLI/, …) since the previous baseline; only docs/.claude edits in between. Docs current. Baseline advanced to keep the next diff range tight." + "note": "Added 'Ask Agent About Prowl' in-app help (Help menu + sidebar footer) that copies a localized prompt pointing the user's agent at the bundled docs; documented it in overview.md's 'How a human typically uses Prowl with this doc set' section. Other changes in range (SupacodePaths bundled-docs path, app wiring) are internal." } diff --git a/docs/overview.md b/docs/overview.md index 2a03389b..f4eb661e 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -98,6 +98,12 @@ needs you. ## How a human typically uses Prowl with this doc set +This whole manual ships **inside the app** (`Prowl.app/Contents/Resources/docs`). +The fastest way in is **Help → "Ask Agent About Prowl…"** (also on the sidebar +footer's Help menu): it copies a ready-to-paste prompt — localized to your system +language — that points your coding agent at these bundled docs and asks it to +introduce Prowl and suggest features for how you work. From there: + 1. The human asks their agent something about Prowl. 2. The agent reads this `overview.md` (for breadth) and/or the specific `components/*.md` file (for depth), then answers. -- 2.51.2 From e021d5f844c4e66e98aeaee65f30d28011b23e8e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 17:14:26 +0900 Subject: [PATCH 357/528] bump v2026.6.7 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index d00dfc2a..09752471 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260606; + CURRENT_PROJECT_VERSION = 20260607; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.6; + MARKETING_VERSION = 2026.6.7; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260606; + CURRENT_PROJECT_VERSION = 20260607; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.6; + MARKETING_VERSION = 2026.6.7; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260606; + CURRENT_PROJECT_VERSION = 20260607; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.6; + MARKETING_VERSION = 2026.6.7; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260606; + CURRENT_PROJECT_VERSION = 20260607; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.6; + MARKETING_VERSION = 2026.6.7; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index be1f97fc..349e9d88 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.6" + public static let current = "2026.6.7" } -- 2.51.2 From 13e61f35421f447f7aaedf603aea3531a4cd35b3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 17:14:27 +0900 Subject: [PATCH 358/528] Update CHANGELOG for 2026.6.7 --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbad63a9..7d47c857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [2026.6.7](https://github.com/onevcat/Prowl/releases/tag/v2026.6.7) + +This release focuses on Canvas improvements, CLI automation additions, and a new in-app help feature for agent-assisted onboarding. + +### New + +- **Canvas expand-in-place**: cards now expand to fill the viewport with a smooth animation instead of switching to Normal view. Press **⌥⌘E** to expand or restore the focused card; a blurred scrim freezes the background while it is open. The shortcut and Expand/Restore commands are also available in the Command Palette. +- **Canvas adaptive card size**: the default card size now scales to the host screen width, so cards on a 14" MacBook Pro are appropriately smaller and stay legible after Organize/Arrange. +- **CLI tab and pane management**: `prowl tab create`, `prowl tab close`, and `prowl pane close` are now available for scripting and agent automation. +- **"Ask Agent About Prowl"**: a new action in the sidebar Help menu (and macOS Help menu) generates a ready-to-paste prompt that points your coding agent at the bundled documentation and asks it to suggest features tailored to how you work. The docs are also available directly inside the app bundle at `Prowl.app/Contents/Resources/docs/`. + +### Fixed + +- Deleting a worktree created outside Prowl under a symlinked path (e.g. `/tmp`) now works correctly; previously the row would disappear and immediately reappear after the next refresh. +- Collapsed-then-expanded sidebar sections no longer cause a layout spin that made scrolling unresponsive. +- Background update downloads are now handled correctly: Sparkle manages the preference, and a toolbar badge appears when an update is ready to install. + +### Improved + +- Canvas card terminal content and title bar now resize together during Organize/Arrange transitions instead of snapping to the final size. +- The CLI socket is now restricted to owner-only permissions and rejects connections from other users on the same machine. + ## [2026.6.6](https://github.com/onevcat/Prowl/releases/tag/v2026.6.6) This release focuses on Canvas mode: keyboard shortcuts, smoother focus navigation, and consistent behavior across sidebar, command palette, and new-tab actions. -- 2.51.2 From c9f5100e0e80453044917642cfe67ca0589d5a8e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 23:07:18 +0900 Subject: [PATCH 359/528] Stabilize menu-bar focused actions with an Equatable FocusedAction wrapper Menu-bar commands publish their actions through `focusedSceneValue` / `focusedValue` as bare `() -> Void` closures. Closures have no Equatable conformance, so SwiftUI treats every publisher-view body run as a focused-value change and AppKit rebuilds the system menu, dropping open-submenu and hover state. The detail view's body re-runs on every OSC-9 progress tick during agent activity, so the menu bar rebuilds continuously and becomes hard to use while an agent is streaming output. Wrap focused action closures in `FocusedAction: Equatable`, which dedupes on `(isEnabled, token)` instead of closure identity, so AppKit only rebuilds the menu when something it displays actually changes. Tokens carry a hashable projection of any captured state that affects behavior (the selected/canvas worktree, archive/delete targets, the pending confirm alert) so a command never fires against a stale target. The fork keeps its disabled-as-`nil` convention, so consumers' `action?()` and `action == nil` sites are unchanged; only the focused-value types switch to `FocusedAction`. --- supacode/App/ContentView.swift | 6 +- supacode/App/Models/FocusedAction.swift | 89 +++++++++++++++++++ supacode/Commands/SidebarCommands.swift | 8 +- supacode/Commands/TerminalCommands.swift | 44 ++++----- supacode/Commands/WindowCommands.swift | 32 +++---- supacode/Commands/WorktreeCommands.swift | 24 ++--- .../DiffView/DiffWindowContentView.swift | 4 +- .../RepositoriesFeature+StateQueries.swift | 14 +++ .../Views/ArchivedWorktreesDetailView.swift | 14 +-- .../Repositories/Views/SidebarView.swift | 14 +-- .../Views/WorktreeDetailView.swift | 75 +++++++++++----- supacodeTests/FocusedActionTests.swift | 58 ++++++++++++ 12 files changed, 288 insertions(+), 94 deletions(-) create mode 100644 supacode/App/Models/FocusedAction.swift create mode 100644 supacodeTests/FocusedActionTests.swift diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 4c03a34f..f501419f 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -107,8 +107,10 @@ struct ContentView: View { } ) } - .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) - .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) + .focusedSceneAction(\.toggleLeftSidebarAction, enabled: true) { + toggleLeftSidebar() + } + .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction.asFocusedAction()) .overlay { CommandPaletteOverlayView( store: store.scope(state: \.commandPalette, action: \.commandPalette), diff --git a/supacode/App/Models/FocusedAction.swift b/supacode/App/Models/FocusedAction.swift new file mode 100644 index 00000000..b1381836 --- /dev/null +++ b/supacode/App/Models/FocusedAction.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Equatable wrapper around a focused-value action closure. +/// +/// SwiftUI's `focusedSceneValue` / `focusedValue` re-publishes whenever the +/// stored value's identity changes. A bare `() -> Void` closure has no +/// Equatable conformance, so every publisher-view body run looks like a +/// "value changed" event to AppKit, which then rebuilds the system menu and +/// drops open-submenu / hover state. During agent activity the detail view's +/// body runs on every OSC-9 progress tick, so the menu bar rebuilds +/// continuously and becomes hard to use. Wrapping the closure in this +/// Equatable adapter keeps the focused value stable across no-op body runs. +/// +/// **Contract**: `token` must hash any captured state that affects the +/// closure's behavior. If the closure captures only stable references +/// (the store, the terminal manager), `token` can stay `nil`. If it captures +/// the selected worktree, a list of targets, an alert payload, etc., set +/// `token` to a hashable projection of those values so a real change triggers +/// a republish. +struct FocusedAction: Equatable { + let isEnabled: Bool + let token: AnyHashable? + private let perform: (Input) -> Void + + init( + isEnabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) { + self.isEnabled = isEnabled + self.token = token + self.perform = perform + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.isEnabled == rhs.isEnabled && lhs.token == rhs.token + } + + func callAsFunction(_ input: Input) { + guard isEnabled else { return } + perform(input) + } +} + +extension FocusedAction where Input == Void { + func callAsFunction() { + callAsFunction(()) + } +} + +extension Optional { + /// Wraps an optional `() -> Void` closure into a `FocusedAction`, preserving + /// the disabled-as-`nil` convention the fork already uses for focused values: + /// a `nil` closure stays `nil` (disabled), a non-`nil` closure becomes an + /// enabled `FocusedAction` carrying `token`. + func asFocusedAction(token: AnyHashable? = nil) -> FocusedAction? where Wrapped == () -> Void { + map { FocusedAction(isEnabled: true, token: token, perform: $0) } + } +} + +extension View { + /// Publishes a stable `FocusedAction` through `focusedSceneValue`. + /// Prefer this over a raw closure: AppKit only sees a "value changed" + /// event when `enabled` or `token` flip, instead of on every body run. + func focusedSceneAction( + _ keyPath: WritableKeyPath?>, + enabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) -> some View { + focusedSceneValue( + keyPath, + FocusedAction(isEnabled: enabled, token: token, perform: perform) + ) + } + + /// `focusedValue` variant. Same contract as `focusedSceneAction`. + func focusedAction( + _ keyPath: WritableKeyPath?>, + enabled: Bool, + token: AnyHashable? = nil, + perform: @escaping (Input) -> Void + ) -> some View { + focusedValue( + keyPath, + FocusedAction(isEnabled: enabled, token: token, perform: perform) + ) + } +} diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 01727d70..e164d686 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -113,20 +113,20 @@ struct SidebarCommands: Commands { } private struct ToggleLeftSidebarActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct RevealInSidebarActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var toggleLeftSidebarAction: (() -> Void)? { + var toggleLeftSidebarAction: FocusedAction? { get { self[ToggleLeftSidebarActionKey.self] } set { self[ToggleLeftSidebarActionKey.self] = newValue } } - var revealInSidebarAction: (() -> Void)? { + var revealInSidebarAction: FocusedAction? { get { self[RevealInSidebarActionKey.self] } set { self[RevealInSidebarActionKey.self] = newValue } } diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index 225b19e4..1fa4a41d 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -113,121 +113,121 @@ struct TerminalCommands: Commands { } private struct NewTerminalActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var newTerminalAction: (() -> Void)? { + var newTerminalAction: FocusedAction? { get { self[NewTerminalActionKey.self] } set { self[NewTerminalActionKey.self] = newValue } } } private struct CloseSurfaceActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var closeSurfaceAction: (() -> Void)? { + var closeSurfaceAction: FocusedAction? { get { self[CloseSurfaceActionKey.self] } set { self[CloseSurfaceActionKey.self] = newValue } } } private struct CloseTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var closeTabAction: (() -> Void)? { + var closeTabAction: FocusedAction? { get { self[CloseTabActionKey.self] } set { self[CloseTabActionKey.self] = newValue } } } private struct ResetFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var resetFontSizeAction: (() -> Void)? { + var resetFontSizeAction: FocusedAction? { get { self[ResetFontSizeActionKey.self] } set { self[ResetFontSizeActionKey.self] = newValue } } } private struct IncreaseFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var increaseFontSizeAction: (() -> Void)? { + var increaseFontSizeAction: FocusedAction? { get { self[IncreaseFontSizeActionKey.self] } set { self[IncreaseFontSizeActionKey.self] = newValue } } } private struct DecreaseFontSizeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var decreaseFontSizeAction: (() -> Void)? { + var decreaseFontSizeAction: FocusedAction? { get { self[DecreaseFontSizeActionKey.self] } set { self[DecreaseFontSizeActionKey.self] = newValue } } } private struct StartSearchActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var startSearchAction: (() -> Void)? { + var startSearchAction: FocusedAction? { get { self[StartSearchActionKey.self] } set { self[StartSearchActionKey.self] = newValue } } } private struct SearchSelectionActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var searchSelectionAction: (() -> Void)? { + var searchSelectionAction: FocusedAction? { get { self[SearchSelectionActionKey.self] } set { self[SearchSelectionActionKey.self] = newValue } } } private struct NavigateSearchNextActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var navigateSearchNextAction: (() -> Void)? { + var navigateSearchNextAction: FocusedAction? { get { self[NavigateSearchNextActionKey.self] } set { self[NavigateSearchNextActionKey.self] = newValue } } } private struct NavigateSearchPreviousActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var navigateSearchPreviousAction: (() -> Void)? { + var navigateSearchPreviousAction: FocusedAction? { get { self[NavigateSearchPreviousActionKey.self] } set { self[NavigateSearchPreviousActionKey.self] = newValue } } } private struct EndSearchActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var endSearchAction: (() -> Void)? { + var endSearchAction: FocusedAction? { get { self[EndSearchActionKey.self] } set { self[EndSearchActionKey.self] = newValue } } diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index f16f94d7..9b95845d 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -190,88 +190,88 @@ struct KeyboardShortcutModifier: ViewModifier { } private struct SelectPreviousTerminalTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectPreviousTerminalTabAction: (() -> Void)? { + var selectPreviousTerminalTabAction: FocusedAction? { get { self[SelectPreviousTerminalTabActionKey.self] } set { self[SelectPreviousTerminalTabActionKey.self] = newValue } } } private struct SelectNextTerminalTabActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectNextTerminalTabAction: (() -> Void)? { + var selectNextTerminalTabAction: FocusedAction? { get { self[SelectNextTerminalTabActionKey.self] } set { self[SelectNextTerminalTabActionKey.self] = newValue } } } private struct SelectPreviousTerminalPaneActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectPreviousTerminalPaneAction: (() -> Void)? { + var selectPreviousTerminalPaneAction: FocusedAction? { get { self[SelectPreviousTerminalPaneActionKey.self] } set { self[SelectPreviousTerminalPaneActionKey.self] = newValue } } } private struct SelectNextTerminalPaneActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectNextTerminalPaneAction: (() -> Void)? { + var selectNextTerminalPaneAction: FocusedAction? { get { self[SelectNextTerminalPaneActionKey.self] } set { self[SelectNextTerminalPaneActionKey.self] = newValue } } } private struct SelectTerminalPaneAboveActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneAboveAction: (() -> Void)? { + var selectTerminalPaneAboveAction: FocusedAction? { get { self[SelectTerminalPaneAboveActionKey.self] } set { self[SelectTerminalPaneAboveActionKey.self] = newValue } } } private struct SelectTerminalPaneBelowActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneBelowAction: (() -> Void)? { + var selectTerminalPaneBelowAction: FocusedAction? { get { self[SelectTerminalPaneBelowActionKey.self] } set { self[SelectTerminalPaneBelowActionKey.self] = newValue } } } private struct SelectTerminalPaneLeftActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneLeftAction: (() -> Void)? { + var selectTerminalPaneLeftAction: FocusedAction? { get { self[SelectTerminalPaneLeftActionKey.self] } set { self[SelectTerminalPaneLeftActionKey.self] = newValue } } } private struct SelectTerminalPaneRightActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var selectTerminalPaneRightAction: (() -> Void)? { + var selectTerminalPaneRightAction: FocusedAction? { get { self[SelectTerminalPaneRightActionKey.self] } set { self[SelectTerminalPaneRightActionKey.self] = newValue } } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 9e0e6a1a..1dc7dec5 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -294,48 +294,48 @@ private struct WorktreeMenuEntry: Identifiable { } private struct ArchiveWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct OpenSelectedWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct DeleteWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct ConfirmWorktreeActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } extension FocusedValues { - var openSelectedWorktreeAction: (() -> Void)? { + var openSelectedWorktreeAction: FocusedAction? { get { self[OpenSelectedWorktreeActionKey.self] } set { self[OpenSelectedWorktreeActionKey.self] = newValue } } - var confirmWorktreeAction: (() -> Void)? { + var confirmWorktreeAction: FocusedAction? { get { self[ConfirmWorktreeActionKey.self] } set { self[ConfirmWorktreeActionKey.self] = newValue } } - var archiveWorktreeAction: (() -> Void)? { + var archiveWorktreeAction: FocusedAction? { get { self[ArchiveWorktreeActionKey.self] } set { self[ArchiveWorktreeActionKey.self] = newValue } } - var deleteWorktreeAction: (() -> Void)? { + var deleteWorktreeAction: FocusedAction? { get { self[DeleteWorktreeActionKey.self] } set { self[DeleteWorktreeActionKey.self] = newValue } } - var runScriptAction: (() -> Void)? { + var runScriptAction: FocusedAction? { get { self[RunScriptActionKey.self] } set { self[RunScriptActionKey.self] = newValue } } - var stopRunScriptAction: (() -> Void)? { + var stopRunScriptAction: FocusedAction? { get { self[StopRunScriptActionKey.self] } set { self[StopRunScriptActionKey.self] = newValue } } @@ -347,11 +347,11 @@ extension FocusedValues { } private struct RunScriptActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct StopRunScriptActionKey: FocusedValueKey { - typealias Value = () -> Void + typealias Value = FocusedAction } private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { diff --git a/supacode/Features/DiffView/DiffWindowContentView.swift b/supacode/Features/DiffView/DiffWindowContentView.swift index 4554daf4..9caac76e 100644 --- a/supacode/Features/DiffView/DiffWindowContentView.swift +++ b/supacode/Features/DiffView/DiffWindowContentView.swift @@ -29,7 +29,9 @@ struct DiffWindowContentView: View { } detail: { diffDetail } - .focusedSceneValue(\.toggleLeftSidebarAction, toggleSidebar) + .focusedSceneAction(\.toggleLeftSidebarAction, enabled: true) { + toggleSidebar() + } .toolbar(id: "diffToolbar") { ToolbarItem(id: "sidebarToggle", placement: .navigation) { Button { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index bfd0a876..21be338f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -425,6 +425,20 @@ extension RepositoriesFeature.State { return nil } + /// Hashable projection of `confirmWorktreeAlert`, used as a `FocusedAction` + /// token so the confirm command republishes only when the pending alert's + /// targets actually change rather than on every view body run. + var confirmWorktreeActionToken: [Worktree.ID]? { + switch confirmWorktreeAlert { + case .confirmArchiveWorktree(let worktreeID, _): + return [worktreeID] + case .confirmArchiveWorktrees(let targets): + return targets.map(\.worktreeID) + default: + return nil + } + } + func isRemovingRepository(_ repository: Repository) -> Bool { removingRepositoryIDs.contains(repository.id) } diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index 61d32b9a..8d4eec62 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -84,7 +84,7 @@ struct ArchivedWorktreesDetailView: View { } @ToolbarContentBuilder - private func toolbarContent(deleteWorktreeAction: (() -> Void)?) -> some ToolbarContent { + private func toolbarContent(deleteWorktreeAction: FocusedAction?) -> some ToolbarContent { ToolbarItem { let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display Button("Delete Selected", systemImage: "trash", role: .destructive) { @@ -100,8 +100,8 @@ struct ArchivedWorktreesDetailView: View { var groupIDs: Set var archivedRowIDs: [Worktree.ID] var archivedWorktreeIDs: Set - var deleteWorktreeAction: (() -> Void)? - var confirmWorktreeAction: (() -> Void)? + var deleteWorktreeAction: FocusedAction? + var confirmWorktreeAction: FocusedAction? } private func makeSnapshot() -> ArchivedSnapshot { @@ -131,20 +131,20 @@ struct ArchivedWorktreesDetailView: View { } } - let deleteWorktreeAction: (() -> Void)? + let deleteWorktreeAction: FocusedAction? if selectedTargets.isEmpty { deleteWorktreeAction = nil } else { let store = self.store - deleteWorktreeAction = { + deleteWorktreeAction = FocusedAction(isEnabled: true, token: selectedTargets.map(\.worktreeID)) { store.send(.worktreeLifecycle(.requestDeleteWorktrees(selectedTargets))) } } - let confirmWorktreeAction: (() -> Void)? + let confirmWorktreeAction: FocusedAction? if let alert = store.state.confirmWorktreeAlert { let store = self.store - confirmWorktreeAction = { + confirmWorktreeAction = FocusedAction(isEnabled: true, token: store.state.confirmWorktreeActionToken) { store.send(.alert(.presented(alert))) } } else { diff --git a/supacode/Features/Repositories/Views/SidebarView.swift b/supacode/Features/Repositories/Views/SidebarView.swift index 35195ced..c2b73fc2 100644 --- a/supacode/Features/Repositories/Views/SidebarView.swift +++ b/supacode/Features/Repositories/Views/SidebarView.swift @@ -33,6 +33,8 @@ struct SidebarView: View { .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) + // `visibleHotkeyWorktreeRows` stays a plain value: `WorktreeRowModel` is + // Equatable, so SwiftUI already skips republishing it on no-op body runs. .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } .onChange(of: state.selection) { _, _ in syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) @@ -75,16 +77,16 @@ struct SidebarView: View { private func makeConfirmWorktreeAction( state: RepositoriesFeature.State - ) -> (() -> Void)? { + ) -> FocusedAction? { guard let alert = state.confirmWorktreeAlert else { return nil } - return { + return FocusedAction(isEnabled: true, token: state.confirmWorktreeActionToken) { store.send(.alert(.presented(alert))) } } private func makeArchiveWorktreeAction( rows: [WorktreeRowModel] - ) -> (() -> Void)? { + ) -> FocusedAction? { let targets = rows .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting } @@ -95,7 +97,7 @@ struct SidebarView: View { ) } guard !targets.isEmpty else { return nil } - return { + return FocusedAction(isEnabled: true, token: targets.map(\.worktreeID)) { if targets.count == 1, let target = targets.first { store.send(.worktreeLifecycle(.requestArchiveWorktree(target.worktreeID, target.repositoryID))) } else { @@ -106,7 +108,7 @@ struct SidebarView: View { private func makeDeleteWorktreeAction( rows: [WorktreeRowModel] - ) -> (() -> Void)? { + ) -> FocusedAction? { let targets = rows .filter { $0.isRemovable && !$0.isDeleting } @@ -117,7 +119,7 @@ struct SidebarView: View { ) } guard !targets.isEmpty else { return nil } - return { + return FocusedAction(isEnabled: true, token: targets.map(\.worktreeID)) { if targets.count == 1, let target = targets.first { store.send(.worktreeLifecycle(.requestDeleteWorktree(target.worktreeID, target.repositoryID))) } else { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4240533d..33a12150 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -142,7 +142,12 @@ struct WorktreeDetailView: View { runScriptEnabled: runScriptEnabled, runScriptIsRunning: runScriptIsRunning ) - return applyFocusedActions(content: content, actions: actions) + let actionToken = WorktreeActionContext( + selectedWorktreeID: selectedTerminalWorktree?.id, + isShowingCanvas: repositories.isShowingCanvas, + canvasFocusedWorktreeID: repositories.isShowingCanvas ? terminalManager.canvasFocusedWorktreeID : nil + ) + return applyFocusedActions(content: content, actions: actions, token: actionToken) } @ToolbarContentBuilder @@ -530,31 +535,40 @@ struct WorktreeDetailView: View { private func applyFocusedActions( content: Content, - actions: FocusedActions + actions: FocusedActions, + token: WorktreeActionContext ) -> some View { content - .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree) - .focusedSceneValue(\.newTerminalAction, actions.newTerminal) - .focusedSceneValue(\.closeTabAction, actions.closeTab) - .focusedSceneValue(\.closeSurfaceAction, actions.closeSurface) - .focusedSceneValue(\.resetFontSizeAction, actions.resetFontSize) - .focusedSceneValue(\.increaseFontSizeAction, actions.increaseFontSize) - .focusedSceneValue(\.decreaseFontSizeAction, actions.decreaseFontSize) - .focusedSceneValue(\.startSearchAction, actions.startSearch) - .focusedSceneValue(\.searchSelectionAction, actions.searchSelection) - .focusedSceneValue(\.navigateSearchNextAction, actions.navigateSearchNext) - .focusedSceneValue(\.navigateSearchPreviousAction, actions.navigateSearchPrevious) - .focusedSceneValue(\.endSearchAction, actions.endSearch) - .focusedSceneValue(\.selectPreviousTerminalTabAction, actions.selectPreviousTerminalTab) - .focusedSceneValue(\.selectNextTerminalTabAction, actions.selectNextTerminalTab) - .focusedSceneValue(\.selectPreviousTerminalPaneAction, actions.selectPreviousTerminalPane) - .focusedSceneValue(\.selectNextTerminalPaneAction, actions.selectNextTerminalPane) - .focusedSceneValue(\.selectTerminalPaneAboveAction, actions.selectTerminalPaneAbove) - .focusedSceneValue(\.selectTerminalPaneBelowAction, actions.selectTerminalPaneBelow) - .focusedSceneValue(\.selectTerminalPaneLeftAction, actions.selectTerminalPaneLeft) - .focusedSceneValue(\.selectTerminalPaneRightAction, actions.selectTerminalPaneRight) - .focusedSceneValue(\.runScriptAction, actions.runScript) - .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript) + .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree.asFocusedAction(token: token)) + .focusedSceneValue(\.newTerminalAction, actions.newTerminal.asFocusedAction(token: token)) + .focusedSceneValue(\.closeTabAction, actions.closeTab.asFocusedAction(token: token)) + .focusedSceneValue(\.closeSurfaceAction, actions.closeSurface.asFocusedAction(token: token)) + .focusedSceneValue(\.resetFontSizeAction, actions.resetFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.increaseFontSizeAction, actions.increaseFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.decreaseFontSizeAction, actions.decreaseFontSize.asFocusedAction(token: token)) + .focusedSceneValue(\.startSearchAction, actions.startSearch.asFocusedAction(token: token)) + .focusedSceneValue(\.searchSelectionAction, actions.searchSelection.asFocusedAction(token: token)) + .focusedSceneValue(\.navigateSearchNextAction, actions.navigateSearchNext.asFocusedAction(token: token)) + .focusedSceneValue( + \.navigateSearchPreviousAction, actions.navigateSearchPrevious.asFocusedAction(token: token) + ) + .focusedSceneValue(\.endSearchAction, actions.endSearch.asFocusedAction(token: token)) + .focusedSceneValue( + \.selectPreviousTerminalTabAction, actions.selectPreviousTerminalTab.asFocusedAction(token: token) + ) + .focusedSceneValue(\.selectNextTerminalTabAction, actions.selectNextTerminalTab.asFocusedAction(token: token)) + .focusedSceneValue( + \.selectPreviousTerminalPaneAction, actions.selectPreviousTerminalPane.asFocusedAction(token: token) + ) + .focusedSceneValue( + \.selectNextTerminalPaneAction, actions.selectNextTerminalPane.asFocusedAction(token: token) + ) + .focusedSceneValue(\.selectTerminalPaneAboveAction, actions.selectTerminalPaneAbove.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneBelowAction, actions.selectTerminalPaneBelow.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneLeftAction, actions.selectTerminalPaneLeft.asFocusedAction(token: token)) + .focusedSceneValue(\.selectTerminalPaneRightAction, actions.selectTerminalPaneRight.asFocusedAction(token: token)) + .focusedSceneValue(\.runScriptAction, actions.runScript.asFocusedAction(token: token)) + .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript.asFocusedAction(token: token)) } private func makeFocusedActions( @@ -688,6 +702,19 @@ struct WorktreeDetailView: View { } } + /// Hashable identity of the inputs the focused actions capture, used as the + /// `FocusedAction` token. The detail body re-runs on every OSC-9 progress + /// tick during agent activity; without a stable token each run would look + /// like a focused-value change and rebuild the menu bar. Including the + /// selected / canvas-focused worktree here keeps the published actions stable + /// while the same worktree is focused, yet still republishes when the target + /// worktree changes (so a menu item never fires against a stale worktree). + private struct WorktreeActionContext: Hashable { + let selectedWorktreeID: Worktree.ID? + let isShowingCanvas: Bool + let canvasFocusedWorktreeID: Worktree.ID? + } + private struct FocusedActions { let openSelectedWorktree: (() -> Void)? let newTerminal: (() -> Void)? diff --git a/supacodeTests/FocusedActionTests.swift b/supacodeTests/FocusedActionTests.swift new file mode 100644 index 00000000..2bc6bb20 --- /dev/null +++ b/supacodeTests/FocusedActionTests.swift @@ -0,0 +1,58 @@ +import Testing + +@testable import supacode + +struct FocusedActionTests { + @Test func equalWhenEnabledAndTokenMatchDespiteDifferentClosures() { + // Two distinct closure instances with the same enabled flag and token must + // compare equal so SwiftUI does not treat a no-op body run as a value change. + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: true, token: "a") {} + #expect(lhs == rhs) + } + + @Test func notEqualWhenEnabledDiffers() { + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: false, token: "a") {} + #expect(lhs != rhs) + } + + @Test func notEqualWhenTokenDiffers() { + let lhs = FocusedAction(isEnabled: true, token: "a") {} + let rhs = FocusedAction(isEnabled: true, token: "b") {} + #expect(lhs != rhs) + } + + @Test func nilTokensCompareEqual() { + let lhs = FocusedAction(isEnabled: true, token: nil) {} + let rhs = FocusedAction(isEnabled: true, token: nil) {} + #expect(lhs == rhs) + } + + @Test func callExecutesPerformWhenEnabled() { + var ran = false + let action = FocusedAction(isEnabled: true) { ran = true } + action() + #expect(ran) + } + + @Test func callDoesNotExecutePerformWhenDisabled() { + var ran = false + let action = FocusedAction(isEnabled: false) { ran = true } + action() + #expect(!ran) + } + + @Test func callForwardsInputValue() { + var received: Int? + let action = FocusedAction(isEnabled: true) { received = $0 } + action(42) + #expect(received == 42) + } + + @Test func tokensOfDifferentHashableTypesCompareUnequal() { + let lhs = FocusedAction(isEnabled: true, token: ["w1"]) {} + let rhs = FocusedAction(isEnabled: true, token: ["w2"]) {} + #expect(lhs != rhs) + } +} -- 2.51.2 From 7e0c9c760c8a8307c8490dc08707e78ffce8cfbd Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 23:14:58 +0900 Subject: [PATCH 360/528] Coalesce OSC-9 progress reports to cut tab-bar lag during agent activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents (e.g. Claude Code) emit OSC-9 progress reports far faster than the UI needs. The bridge previously cancelled and re-created a 15s reset Task and wrote observable state on every report, churning the main thread while the agent streamed output; the determinate bar then relaid out a GeometryReader frame width and restarted a 0.2s easeInOut per distinct percent. The visible symptom is a laggy / stuttering progress indicator and elevated CPU while an agent is busy. - GhosttySurfaceBridge: route OSC-9 through `ingestProgressReport`, coalescing behind two single-purpose tasks — a leading-edge-then-trailing flush at the throttle cadence (50ms) and a slow stale-watch (1s wakeups, 15s timeout) that clears a bar whose reports stopped without a REMOVE. Identical reports dedupe at ingest, REMOVE wins over an unapplied trailing value, and the flush task guards cancellation before clearing its handle. The clock and intervals are injected for testing. - SET_TITLE and TerminalTabManager.updateTitle/updateDirty skip no-op writes a TUI re-emits constantly, so an unchanged value no longer invalidates the tab. - GhosttySurfaceProgressBar: bucket determinate percent to 5% steps and render the fill with a leading-anchored scaleEffect instead of a GeometryReader frame width, dropping the per-percent animation so a step composites rather than relaying out. Ported from upstream supacode #347, adapted to the fork's bridge, tab manager, and surface-level progress bar (the fork has no tab-stripe / shimmer views). --- .../Terminal/Models/TerminalTabManager.swift | 6 + .../Views/GhosttySurfaceProgressBar.swift | 32 ++-- .../Ghostty/GhosttySurfaceBridge.swift | 144 +++++++++++++--- supacodeTests/GhosttySurfaceBridgeTests.swift | 160 ++++++++++++++++++ .../GhosttySurfaceProgressBarTests.swift | 29 ++++ 5 files changed, 339 insertions(+), 32 deletions(-) create mode 100644 supacodeTests/GhosttySurfaceProgressBarTests.swift diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index 14be24f0..b8d74f37 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -38,6 +38,9 @@ final class TerminalTabManager { func updateTitle(_ id: TerminalTabID, title: String) -> Bool { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return false } guard !tabs[index].isTitleLocked else { return false } + // A TUI re-emits the same title constantly; skip the no-op write so it + // doesn't invalidate the tab bar while an agent streams output. + guard tabs[index].title != title else { return false } let previousDisplayTitle = tabs[index].displayTitle tabs[index].title = title return tabs[index].displayTitle != previousDisplayTitle @@ -98,6 +101,9 @@ final class TerminalTabManager { func updateDirty(_ id: TerminalTabID, isDirty: Bool) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + // OSC-9 drives this on every progress tick; skip the no-op write so an + // unchanged dirty flag doesn't re-render the tab bar during agent activity. + guard tabs[index].isDirty != isDirty else { return } tabs[index].isDirty = isDirty } diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift index 1d0e699e..1f7efcdd 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift @@ -13,7 +13,7 @@ struct GhosttySurfaceProgressBar: View { default: .accentColor } let progress: Int? = - progressValue ?? (progressState == GHOSTTY_PROGRESS_STATE_PAUSE ? 100 : nil) + progressValue.map(Self.bucketedPercent) ?? (progressState == GHOSTTY_PROGRESS_STATE_PAUSE ? 100 : nil) let accessibilityLabel: String = switch progressState { case GHOSTTY_PROGRESS_STATE_ERROR: "Terminal progress - Error" @@ -33,17 +33,15 @@ struct GhosttySurfaceProgressBar: View { } } - GeometryReader { geometry in - ZStack(alignment: .leading) { - if let progress { - Rectangle() - .fill(color) - .frame( - width: geometry.size.width * CGFloat(progress) / 100, - height: geometry.size.height - ) - .animation(.easeInOut(duration: 0.2), value: progress) - } else { + Group { + if let progress { + // Leading-anchored scaleEffect composites a percent step instead of + // relaying out a GeometryReader frame width on every OSC-9 tick. + Rectangle() + .fill(color) + .scaleEffect(x: CGFloat(progress) / 100, y: 1, anchor: .leading) + } else { + GeometryReader { geometry in ZStack(alignment: .leading) { Rectangle() .fill(color.opacity(0.3)) @@ -67,4 +65,14 @@ struct GhosttySurfaceProgressBar: View { .accessibilityLabel(accessibilityLabel) .accessibilityValue(accessibilityValue) } + + /// Quantize a determinate percent to 5% steps so a 0->100 sweep collapses to + /// ~20 distinct values that mostly no-op at the view's equality gates. 0 and + /// the >=100 terminus pass through unchanged so the bar still empties and tops + /// out exactly. + static func bucketedPercent(_ percent: Int) -> Int { + guard percent > 0 else { return 0 } + guard percent < 100 else { return 100 } + return (percent / 5) * 5 + } } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 59c0c7e7..30e39272 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -21,10 +21,40 @@ final class GhosttySurfaceBridge { var onDesktopNotification: ((String, String) -> Void)? var onCommandFinished: ((Int?, UInt64) -> Void)? var onPromptTitle: ((ghostty_action_prompt_title_e) -> Void)? - private var progressResetTask: Task? + + // Coalesce OSC-9 progress: a flush task applies the latest value at the + // throttle cadence while it moves, and a slow stale-watch clears a bar whose + // reports stopped without a REMOVE. + private let clock: any Clock + private let progressThrottleInterval: Duration + private let progressIdleInterval: Duration + private let progressStaleTimeout: Duration + private var pendingProgress: ProgressUpdate? + private var appliedProgress: ProgressUpdate? + private var progressReportCount = 0 + private var progressFlushTask: Task? + private var progressStaleTask: Task? + + init( + clock: any Clock = ContinuousClock(), + progressThrottleInterval: Duration = .milliseconds(50), + progressIdleInterval: Duration = .seconds(1), + progressStaleTimeout: Duration = .seconds(15) + ) { + self.clock = clock + self.progressThrottleInterval = progressThrottleInterval + self.progressIdleInterval = progressIdleInterval + self.progressStaleTimeout = progressStaleTimeout + } deinit { - progressResetTask?.cancel() + progressFlushTask?.cancel() + progressStaleTask?.cancel() + } + + private struct ProgressUpdate: Equatable { + let state: ghostty_action_progress_report_state_e + let value: Int? } func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { @@ -200,7 +230,8 @@ final class GhosttySurfaceBridge { private func handleTitleAndPath(_ action: ghostty_action_s) -> Bool { switch action.tag { case GHOSTTY_ACTION_SET_TITLE: - if let title = string(from: action.action.set_title.title) { + // TUIs re-emit the same title constantly; skip the no-op write + a11y post. + if let title = string(from: action.action.set_title.title), title != state.title { state.title = title onTitleChange?(title) if let surfaceView { @@ -243,23 +274,10 @@ final class GhosttySurfaceBridge { switch action.tag { case GHOSTTY_ACTION_PROGRESS_REPORT: let report = action.action.progress_report - progressResetTask?.cancel() - state.progressValue = report.progress == -1 ? nil : Int(report.progress) - if report.state == GHOSTTY_PROGRESS_STATE_REMOVE { - state.progressState = nil - state.progressValue = nil - progressResetTask = nil - } else { - state.progressState = report.state - progressResetTask = Task { @MainActor [weak self] in - try? await ContinuousClock().sleep(for: .seconds(15)) - guard let self, !Task.isCancelled else { return } - self.state.progressState = nil - self.state.progressValue = nil - self.onProgressReport?(GHOSTTY_PROGRESS_STATE_REMOVE) - } - } - onProgressReport?(report.state) + ingestProgressReport( + state: report.state, + value: report.progress == -1 ? nil : Int(report.progress) + ) return true case GHOSTTY_ACTION_COMMAND_FINISHED: @@ -289,6 +307,92 @@ final class GhosttySurfaceBridge { } } + /// Coalescing entry point for OSC-9 progress. REMOVE clears immediately; a + /// value identical to what's already shown only refreshes the stale window. + func ingestProgressReport(state: ghostty_action_progress_report_state_e, value: Int?) { + guard state != GHOSTTY_PROGRESS_STATE_REMOVE else { + flushProgressRemoval() + return + } + // The counter is the stale watch's liveness signal; bump it on every report. + progressReportCount &+= 1 + startProgressStaleWatchIfNeeded() + let update = ProgressUpdate(state: state, value: value) + guard update != appliedProgress else { return } + pendingProgress = update + scheduleProgressFlush() + } + + /// Leading-edge then trailing throttle: paint a new value immediately when no + /// flush is in flight, then batch any further changes into one flush per + /// throttle interval. Idles to nothing once the value stops moving. + private func scheduleProgressFlush() { + guard progressFlushTask == nil else { return } + applyPendingProgress() + progressFlushTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await self.clock.sleep(for: self.progressThrottleInterval) + // Check cancellation before clearing the handle: a cancelled task (REMOVE + // raced a reschedule) must not clobber the live task's handle. + guard !Task.isCancelled else { return } + self.progressFlushTask = nil + guard self.pendingProgress != nil else { return } + self.scheduleProgressFlush() + } + } + + /// Slow watch that clears a bar whose reports stopped without a REMOVE (e.g. + /// the process died). Wakes at the idle cadence, not the throttle cadence, so + /// a held bar doesn't pin a high-frequency wakeup on the main thread. + private func startProgressStaleWatchIfNeeded() { + guard progressStaleTask == nil else { return } + let startCount = progressReportCount + progressStaleTask = Task { @MainActor [weak self] in + guard let self else { return } + var lastSeenCount = startCount + var idleElapsed: Duration = .zero + while !Task.isCancelled { + try? await self.clock.sleep(for: self.progressIdleInterval) + guard !Task.isCancelled else { return } + if self.progressReportCount != lastSeenCount { + lastSeenCount = self.progressReportCount + idleElapsed = .zero + continue + } + idleElapsed += self.progressIdleInterval + if idleElapsed >= self.progressStaleTimeout { + self.flushProgressRemoval() + return + } + } + } + } + + private func applyPendingProgress() { + guard let pending = pendingProgress else { return } + pendingProgress = nil + guard pending != appliedProgress else { return } + appliedProgress = pending + state.progressState = pending.state + state.progressValue = pending.value + onProgressReport?(pending.state) + } + + private func flushProgressRemoval() { + // REMOVE wins over any unapplied trailing value: applying it first would + // emit a spurious determinate paint that coalesces away before render, + // since the bar is clearing anyway. + progressFlushTask?.cancel() + progressFlushTask = nil + progressStaleTask?.cancel() + progressStaleTask = nil + pendingProgress = nil + appliedProgress = nil + state.progressState = nil + state.progressValue = nil + onProgressReport?(GHOSTTY_PROGRESS_STATE_REMOVE) + } + private func handleMouseAndLink(_ action: ghostty_action_s) -> Bool { switch action.tag { case GHOSTTY_ACTION_MOUSE_SHAPE: diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index bb75132d..8a471983 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -1,9 +1,14 @@ +import Clocks import GhosttyKit import Testing @testable import supacode +// Serialized: the coalescing tests drive a TestClock with two concurrent +// sleepers (flush + stale watch); parallel execution can race `advance` before +// a task suspends and flake. @MainActor +@Suite(.serialized) struct GhosttySurfaceBridgeTests { @Test func activeTextReturnsNilWithoutSurfaceView() { let bridge = GhosttySurfaceBridge() @@ -52,4 +57,159 @@ struct GhosttySurfaceBridgeTests { #expect(callbackCount == 1) #expect(bridge.state.configChangeCount == 1) } + + @Test func coalescesBurstOfProgressReports() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .seconds(15) + ) + var callbackCount = 0 + bridge.onProgressReport = { _ in callbackCount += 1 } + + // Leading edge applies the first report immediately; the rest coalesce. + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 10) + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 20) + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 50) + #expect(bridge.state.progressValue == 10) + #expect(callbackCount == 1) + + // One throttle tick flushes only the latest coalesced value. + await clock.advance(by: .milliseconds(50)) + #expect(bridge.state.progressValue == 50) + #expect(callbackCount == 2) + } + + @Test func staleProgressClearsAfterTimeout() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .milliseconds(200) + ) + var lastState: ghostty_action_progress_report_state_e? + bridge.onProgressReport = { lastState = $0 } + + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) + + // No further reports: the driver synthesizes a REMOVE once the window lapses. + await clock.advance(by: .milliseconds(200)) + #expect(bridge.state.progressState == nil) + #expect(lastState == GHOSTTY_PROGRESS_STATE_REMOVE) + } + + @Test func continuedReportsKeepProgressAlivePastStaleWindow() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .milliseconds(100) + ) + var lastState: ghostty_action_progress_report_state_e? + bridge.onProgressReport = { lastState = $0 } + + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + // A long indeterminate run re-fires identical reports; the stale timer must + // keep resetting even though the value never changes. + for _ in 0..<6 { + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + await clock.advance(by: .milliseconds(50)) + } + #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) + #expect(lastState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) + } + + @Test func progressDriverRestartsAfterStaleRemoval() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .milliseconds(100) + ) + bridge.onProgressReport = { _ in } + + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + // No further reports: the stale window synthesizes a REMOVE and tears down + // the driver. + await clock.advance(by: .milliseconds(100)) + #expect(bridge.state.progressState == nil) + + // A report after the stale REMOVE must re-arm the driver, not freeze. + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 30) + #expect(bridge.state.progressValue == 30) + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 60) + await clock.advance(by: .milliseconds(50)) + #expect(bridge.state.progressValue == 60) + } + + @Test func determinateValuePaintsPromptlyAfterIdlePeriod() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .seconds(15) + ) + bridge.onProgressReport = { _ in } + + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 10) + #expect(bridge.state.progressValue == 10) + + // Sit idle well past the throttle window, then a fresh value must paint on + // its leading edge instead of waiting for a slow idle tick. + await clock.advance(by: .seconds(1)) + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 80) + #expect(bridge.state.progressValue == 80) + } + + @Test func identicalReportsNeverReapply() async { + let clock = TestClock() + let bridge = GhosttySurfaceBridge( + clock: clock, + progressThrottleInterval: .milliseconds(50), + progressIdleInterval: .milliseconds(50), + progressStaleTimeout: .seconds(15) + ) + var callbackCount = 0 + bridge.onProgressReport = { _ in callbackCount += 1 } + + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + #expect(callbackCount == 1) + + // A flood of identical reports keeps the bar alive but never re-applies, so + // the downstream callback fires exactly once across the whole stream. + for _ in 0..<10 { + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) + await clock.advance(by: .milliseconds(50)) + } + #expect(callbackCount == 1) + #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) + } + + @Test func removeWinsOverUnappliedTrailingValue() { + let bridge = GhosttySurfaceBridge( + clock: TestClock(), + progressThrottleInterval: .milliseconds(50), + progressStaleTimeout: .seconds(15) + ) + var states: [ghostty_action_progress_report_state_e] = [] + bridge.onProgressReport = { states.append($0) } + + // First SET applies on the leading edge; the second sits un-applied in + // pendingProgress because no throttle tick has fired yet. + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 50) + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 100) + // REMOVE before the tick drops the trailing 100 and clears. + bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_REMOVE, value: nil) + + #expect(bridge.state.progressState == nil) + #expect(bridge.state.progressValue == nil) + #expect(states == [GHOSTTY_PROGRESS_STATE_SET, GHOSTTY_PROGRESS_STATE_REMOVE]) + } } diff --git a/supacodeTests/GhosttySurfaceProgressBarTests.swift b/supacodeTests/GhosttySurfaceProgressBarTests.swift new file mode 100644 index 00000000..6b76dff6 --- /dev/null +++ b/supacodeTests/GhosttySurfaceProgressBarTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import supacode + +struct GhosttySurfaceProgressBarTests { + @Test func zeroPassesThrough() { + #expect(GhosttySurfaceProgressBar.bucketedPercent(0) == 0) + } + + @Test func terminusPassesThrough() { + #expect(GhosttySurfaceProgressBar.bucketedPercent(100) == 100) + #expect(GhosttySurfaceProgressBar.bucketedPercent(150) == 100) + } + + @Test func midValuesQuantizeDownToFivePercentSteps() { + #expect(GhosttySurfaceProgressBar.bucketedPercent(1) == 0) + #expect(GhosttySurfaceProgressBar.bucketedPercent(4) == 0) + #expect(GhosttySurfaceProgressBar.bucketedPercent(5) == 5) + #expect(GhosttySurfaceProgressBar.bucketedPercent(7) == 5) + #expect(GhosttySurfaceProgressBar.bucketedPercent(47) == 45) + #expect(GhosttySurfaceProgressBar.bucketedPercent(99) == 95) + } + + @Test func collapsesASweepToAtMostTwentyOneDistinctValues() { + let distinct = Set((0...100).map(GhosttySurfaceProgressBar.bucketedPercent)) + // 0, 5, 10, ..., 95, 100 -> 21 buckets. + #expect(distinct.count == 21) + } +} -- 2.51.2 From 2800fd0bf9d2d08e620d753e61590840111782de Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 23:23:01 +0900 Subject: [PATCH 361/528] Cap and coalesce the terminal event stream to bound memory growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal event stream was an unbounded `AsyncStream`, and `emit` yielded / buffered every event with no dedup. A producer that outruns the single main-actor consumer (notably the fork's `agentEntryChanged`, re-emitted for every pane on each title change during an agent tool storm) grows the stream's internal buffer and the pre-subscription `pendingEvents` backlog without bound, so a long multi-agent session slowly climbs in memory. - Cap the stream with `.bufferingNewest(2048)` and cap `pendingEvents` at 1024 (drop oldest + log), so the buffers can't grow without bound. - Add `TerminalEventCoalescer`: drop a "latest value wins" state event (task status, focus, run-script status, font size, agent entry) that is an exact repeat of the last one emitted for its slot. Lifecycle and one-shot events — notifications, custom-command completions, tab open/close, agent removal, setup-script consumption, layout restore — are never coalesced, so per-tab unread tracking and the command-finished notification keep every event. - Reset the dedup cache on resubscribe so a fresh stream isn't starved, and forget per-worktree slots on prune so a returning worktree starts clean. Ported in spirit from upstream supacode #376, adapted to the fork's event set. --- .../TerminalEventCoalescer.swift | 67 +++++++++++ .../WorktreeTerminalManager.swift | 21 +++- .../TerminalEventCoalescerTests.swift | 112 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift create mode 100644 supacodeTests/TerminalEventCoalescerTests.swift diff --git a/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift new file mode 100644 index 00000000..c029f0a6 --- /dev/null +++ b/supacode/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Drops a state-notification event that is identical to the last one emitted +/// for its slot, so an agent re-emitting the same status / focus / run-script / +/// font size doesn't flood the terminal event stream during a tool storm. +/// +/// Only "latest value wins" events are coalesced. Lifecycle and one-shot events +/// — notifications, custom-command completions, tab open/close, agent removal, +/// setup-script consumption, layout restore — are never coalesced, because each +/// occurrence is meaningful (e.g. two identical notifications are two distinct +/// user-facing events). +struct TerminalEventCoalescer { + enum CoalesceKey: Hashable { + case focusChanged + case fontSize + case taskStatus(Worktree.ID) + case runScriptStatus(Worktree.ID) + case agentEntry(UUID) + } + + private var lastEmitted: [CoalesceKey: TerminalClient.Event] = [:] + + /// The coalesce slot for an event, or `nil` when the event must never be + /// coalesced. + static func coalesceKey(for event: TerminalClient.Event) -> CoalesceKey? { + switch event { + case .focusChanged: + return .focusChanged + case .fontSizeChanged: + return .fontSize + case .taskStatusChanged(let worktreeID, _): + return .taskStatus(worktreeID) + case .runScriptStatusChanged(let worktreeID, _): + return .runScriptStatus(worktreeID) + case .agentEntryChanged(let entry): + return .agentEntry(entry.id) + case .customCommandSucceeded, .notificationReceived, .notificationIndicatorChanged, + .tabCreated, .tabClosed, .agentEntryRemoved, .commandPaletteToggleRequested, + .setupScriptConsumed, .layoutRestored, .layoutRestoreFailed: + return nil + } + } + + /// Returns `true` when the event should be forwarded, `false` when it is an + /// exact repeat of the last value emitted for its slot. + mutating func shouldEmit(_ event: TerminalClient.Event) -> Bool { + guard let key = Self.coalesceKey(for: event) else { return true } + if lastEmitted[key] == event { return false } + lastEmitted[key] = event + return true + } + + /// Clears the cache so a freshly subscribed stream can be re-seeded with the + /// current state instead of having it suppressed as a duplicate. + mutating func reset() { + lastEmitted.removeAll() + } + + /// Drops per-worktree slots for torn-down worktrees so a worktree ID that + /// returns doesn't inherit a stale cached value. + mutating func forget(worktreeIDs: Set) { + for worktreeID in worktreeIDs { + lastEmitted[.taskStatus(worktreeID)] = nil + lastEmitted[.runScriptStatus(worktreeID)] = nil + } + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 6e5f2880..0a6c0683 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -21,6 +21,11 @@ final class WorktreeTerminalManager { private var lastNotificationIndicatorCount: Int? private var eventContinuation: AsyncStream.Continuation? private var pendingEvents: [TerminalClient.Event] = [] + private var eventCoalescer = TerminalEventCoalescer() + /// Caps the live stream and the pre-subscription backlog so a producer that + /// outruns the single main-actor consumer can't grow memory without bound. + private static let eventBufferCap = 2048 + private static let pendingEventCap = 1024 var selectedWorktreeID: Worktree.ID? /// The worktree+tab focused in Canvas, updated by CanvasView on card tap. /// Used by toggleCanvas to know which worktree to return to. @@ -189,9 +194,15 @@ final class WorktreeTerminalManager { func eventStream() -> AsyncStream { eventContinuation?.finish() - let (stream, continuation) = AsyncStream.makeStream(of: TerminalClient.Event.self) + let (stream, continuation) = AsyncStream.makeStream( + of: TerminalClient.Event.self, + bufferingPolicy: .bufferingNewest(Self.eventBufferCap) + ) eventContinuation = continuation lastNotificationIndicatorCount = nil + // A new subscriber must be re-seeded with current state, so the dedup cache + // can't suppress the next emit as a duplicate of one the old stream saw. + eventCoalescer.reset() if !pendingEvents.isEmpty { let bufferedEvents = pendingEvents pendingEvents.removeAll() @@ -359,8 +370,10 @@ final class WorktreeTerminalManager { func prune(keeping worktreeIDs: Set) { var removed: [WorktreeTerminalState] = [] + var removedIDs: Set = [] for (id, state) in states where !worktreeIDs.contains(id) { removed.append(state) + removedIDs.insert(id) } for state in removed { state.closeAllSurfaces() @@ -369,6 +382,7 @@ final class WorktreeTerminalManager { terminalLogger.info("Pruned \(removed.count) terminal state(s)") } states = states.filter { worktreeIDs.contains($0.key) } + eventCoalescer.forget(worktreeIDs: removedIDs) emitNotificationIndicatorCountIfNeeded() } @@ -527,7 +541,12 @@ final class WorktreeTerminalManager { } private func emit(_ event: TerminalClient.Event) { + guard eventCoalescer.shouldEmit(event) else { return } guard let eventContinuation else { + if pendingEvents.count >= Self.pendingEventCap { + pendingEvents.removeFirst() + terminalLogger.debug("Dropped oldest pending terminal event (backlog cap reached)") + } pendingEvents.append(event) return } diff --git a/supacodeTests/TerminalEventCoalescerTests.swift b/supacodeTests/TerminalEventCoalescerTests.swift new file mode 100644 index 00000000..2f21422b --- /dev/null +++ b/supacodeTests/TerminalEventCoalescerTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import supacode + +struct TerminalEventCoalescerTests { + @Test func dropsConsecutiveIdenticalStateEvents() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + // A re-emitted identical status is redundant: the slot already holds it. + let second = coalescer.shouldEmit(event) + #expect(first) + #expect(!second) + } + + @Test func passesDistinctValuesForSameSlot() { + var coalescer = TerminalEventCoalescer() + let running = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + let idle = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .idle)) + // Flipping back is still a real change. + let runningAgain = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + #expect(running) + #expect(idle) + #expect(runningAgain) + } + + @Test func keysStateEventsPerWorktree() { + var coalescer = TerminalEventCoalescer() + let firstWorktree = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w1", status: .running)) + // A different worktree has its own slot, so an identical status still passes. + let secondWorktree = coalescer.shouldEmit(.taskStatusChanged(worktreeID: "w2", status: .running)) + #expect(firstWorktree) + #expect(secondWorktree) + } + + @Test func neverCoalescesNotifications() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.notificationReceived( + worktreeID: "w1", + surfaceID: UUID(), + title: "Build", + body: "done" + ) + let first = coalescer.shouldEmit(event) + // Two identical notifications are two distinct user-facing events. + let second = coalescer.shouldEmit(event) + #expect(first) + #expect(second) + } + + @Test func neverCoalescesLifecycleEvents() { + var coalescer = TerminalEventCoalescer() + let tab1 = coalescer.shouldEmit(.tabCreated(worktreeID: "w1")) + let tab2 = coalescer.shouldEmit(.tabCreated(worktreeID: "w1")) + let cmd1 = coalescer.shouldEmit(.customCommandSucceeded(worktreeID: "w1", name: "test", durationMs: 5)) + let cmd2 = coalescer.shouldEmit(.customCommandSucceeded(worktreeID: "w1", name: "test", durationMs: 5)) + let id = UUID() + let removed1 = coalescer.shouldEmit(.agentEntryRemoved(id)) + let removed2 = coalescer.shouldEmit(.agentEntryRemoved(id)) + #expect(tab1) + #expect(tab2) + #expect(cmd1) + #expect(cmd2) + #expect(removed1) + #expect(removed2) + } + + @Test func coalescesFocusRunScriptAndFontSize() { + var coalescer = TerminalEventCoalescer() + let surfaceID = UUID() + let focus1 = coalescer.shouldEmit(.focusChanged(worktreeID: "w1", surfaceID: surfaceID)) + let focus2 = coalescer.shouldEmit(.focusChanged(worktreeID: "w1", surfaceID: surfaceID)) + let run1 = coalescer.shouldEmit(.runScriptStatusChanged(worktreeID: "w1", isRunning: true)) + let run2 = coalescer.shouldEmit(.runScriptStatusChanged(worktreeID: "w1", isRunning: true)) + let font1 = coalescer.shouldEmit(.fontSizeChanged(14)) + let font2 = coalescer.shouldEmit(.fontSizeChanged(14)) + let font3 = coalescer.shouldEmit(.fontSizeChanged(16)) + #expect(focus1) + #expect(!focus2) + #expect(run1) + #expect(!run2) + #expect(font1) + #expect(!font2) + #expect(font3) + } + + @Test func resetClearsCacheSoAFreshSubscriberIsNotStarved() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + let second = coalescer.shouldEmit(event) + // After a resubscribe the cache is cleared, so the current value can be + // re-delivered to the new stream. + coalescer.reset() + let afterReset = coalescer.shouldEmit(event) + #expect(first) + #expect(!second) + #expect(afterReset) + } + + @Test func forgetDropsKeysForRemovedWorktrees() { + var coalescer = TerminalEventCoalescer() + let event = TerminalClient.Event.taskStatusChanged(worktreeID: "w1", status: .running) + let first = coalescer.shouldEmit(event) + coalescer.forget(worktreeIDs: ["w1"]) + // The slot is gone, so a returning worktree starts clean. + let afterForget = coalescer.shouldEmit(event) + #expect(first) + #expect(afterForget) + } +} -- 2.51.2 From db2f39d08d1eb1fdaaf6c07972af1965fce328eb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 7 Jun 2026 23:26:15 +0900 Subject: [PATCH 362/528] Drop AnyView erasure in the split-tree AX container to cut re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split-tree accessibility container hosted its SwiftUI content as `NSHostingView`, reassigning a fresh `AnyView(...)` on every `updateNSView`. Type erasure defeats SwiftUI's diffing, so the whole split tree re-rendered on each assignment — e.g. once per terminal notification, since the detail body re-runs and feeds the container a new root. Host the concrete `TerminalSplitTreeView` instead so the host can skip unchanged content. Pure rendering optimization; the view content and accessibility output are unchanged. Inspired by the per-surface notification-dot work in upstream supacode #332. The fork does not share that PR's stale-dot correctness bug (its `notifications` are observed, so the dot is already accurate), so only this self-contained render cleanup applies; the full per-surface observable mirror is intentionally not ported. --- .../Views/TerminalSplitTreeView.swift | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 4c7111b3..9bed4550 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -391,15 +391,17 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { nsView.update( - rootView: AnyView( - TerminalSplitTreeView( - tree: tree, - activeSurfaceID: activeSurfaceID, - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - hasNotification: hasNotification, - action: action - ) + // Concrete type (not `AnyView`): erasing the type defeats SwiftUI's + // diffing, forcing a full re-render of the split tree on every assignment + // — e.g. once per terminal notification. The concrete root lets the host + // skip unchanged content. + rootView: TerminalSplitTreeView( + tree: tree, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: hasNotification, + action: action ), panes: tree.visibleLeaves() ) @@ -408,12 +410,12 @@ struct TerminalSplitTreeAXContainer: NSViewRepresentable { @MainActor final class TerminalSplitAXContainerView: NSView { - private var hostingView: NSHostingView? + private var hostingView: NSHostingView? private var panes: [GhosttySurfaceView] = [] private var panesLabel: String = "Terminal split: 0 panes" private var lastPaneIDs: [UUID] = [] - func update(rootView: AnyView, panes: [GhosttySurfaceView]) { + func update(rootView: TerminalSplitTreeView, panes: [GhosttySurfaceView]) { if let hostingView { hostingView.rootView = rootView } else { -- 2.51.2 From 24027a919c1b293ce4646e9725e72b5f4d6bb9e3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 21:29:04 +0900 Subject: [PATCH 363/528] Tolerate login-shell noise in GitHub CLI JSON output A login shell sources .zprofile/.zlogin before `gh` runs, so a banner or version-manager line can prepend to captured stdout and corrupt the JSON. `JSONDecoder` then throws an opaque DecodingError that surfaces in the GitHub settings pane as "The data couldn't be read because it isn't in the correct format", even though gh is installed, authed, and returns valid JSON in the user's terminal. - Add `GithubCLIOutput`: enumerate every balanced top-level JSON value in the output (skipping a stray unbalanced opener), and decode the last decodable span since gh prints its JSON after any leading noise. A missing payload throws a shell-pollution message; a present-but-undecodable one throws a version-incompatibility message; the raw output is logged (capped). - Route all six gh JSON decode sites through it. `latestRun` and `resolveRemoteInfo` use `decodeIfPresent` so a no-payload result stays nil while leading noise is tolerated. - Add `GithubAuthStatusParsing.activeAccount`: prefer github.com then a stable sort, fixing the latent multi-host bug where auth status scanned an arbitrary dictionary entry. Ported from upstream supacode #378; PR decode sites keep the fork's `CrossRepoPullRequestResponse`. `SupaLogger` has no `error` level, so failures log at `warning`. --- supacode/Clients/Github/GithubCLIClient.swift | 179 +++++++++++++++--- supacodeTests/GithubCLIOutputTests.swift | 136 +++++++++++++ 2 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 supacodeTests/GithubCLIOutputTests.swift diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 61d784d1..ad036c3a 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -2,6 +2,148 @@ import ComposableArchitecture import Darwin import Foundation +// A login shell sources `.zprofile` / `.zlogin` before `gh` runs, so a banner or version-manager +// line can prepend to captured stdout and corrupt the JSON. +enum GithubCLIOutput { + // No JSON found at all: the likely cause is shell startup output polluting stdout. + nonisolated static let noPayloadMessage = + "Could not read GitHub CLI output. Your shell startup files may be printing extra output to stdout." + + // Valid JSON was found but failed to decode: the likely cause is an incompatible gh version. + nonisolated static let undecodableMessage = + "Could not parse GitHub CLI output. The installed GitHub CLI version may be incompatible." + + nonisolated private static let logger = SupaLogger("GithubCLI") + + // Every balanced top-level JSON value ({...} or [...]) in `output`, in source order. An opener that + // never balances (a stray brace from shell noise) is skipped, so leading noise cannot swallow a real + // payload that follows. + nonisolated static func balancedJSONSpans(in output: String) -> [Substring] { + var spans: [Substring] = [] + var searchStart = output.startIndex + while let start = output[searchStart...].firstIndex(where: { $0 == "{" || $0 == "[" }) { + let opener = output[start] + let closer: Character = opener == "{" ? "}" : "]" + var depth = 0 + var inString = false + var escaped = false + var matchedEnd: String.Index? + var index = start + scan: while index < output.endIndex { + let character = output[index] + switch character { + case _ where inString && escaped: + escaped = false + case "\\" where inString: + escaped = true + case "\"" where inString: + inString = false + case _ where inString: + break + case "\"": + inString = true + case opener: + depth += 1 + case closer: + depth -= 1 + guard depth == 0 else { break } + matchedEnd = output.index(after: index) + break scan + default: + break + } + index = output.index(after: index) + } + guard let end = matchedEnd else { + // Unbalanced opener (stray bracket in noise): skip it and keep scanning. + searchStart = output.index(after: start) + continue + } + spans.append(output[start..( + _ type: T.Type, + from output: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + guard let value = try decodeIfPresent(T.self, from: output, decoder: decoder) else { + logDecodeFailure(output) + throw GithubCLIError.commandFailed(noPayloadMessage) + } + return value + } + + // Returns nil when `output` carries no JSON payload at all (e.g. `gh run list` with no runs), and + // throws only when a payload is present but cannot be decoded. gh prints its JSON after any leading + // shell noise, so the last decodable span wins. + nonisolated static func decodeIfPresent( + _ type: T.Type, + from output: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T? { + let spans = balancedJSONSpans(in: output) + guard !spans.isEmpty else { + return nil + } + var lastDecodingError: Error? + var sawValidJSON = false + for span in spans.reversed() { + let data = Data(span.utf8) + do { + return try decoder.decode(T.self, from: data) + } catch { + lastDecodingError = error + if (try? JSONSerialization.jsonObject(with: data)) != nil { + sawValidJSON = true + } + } + } + let detail = lastDecodingError.map { "\($0)" } ?? "unknown" + logger.warning("Failed to decode GitHub CLI JSON output: \(snapshot(of: output)) error=\(detail)") + // Valid JSON that failed to decode points at gh schema drift; otherwise the brackets were noise. + throw GithubCLIError.commandFailed(sawValidJSON ? undecodableMessage : noPayloadMessage) + } + + // Logs a length-capped snapshot of the offending output so the user can retrieve it from the log + // stream without flooding it with a large banner. + nonisolated static func logDecodeFailure(_ output: String) { + logger.warning("Failed to read GitHub CLI JSON output: \(snapshot(of: output))") + } + + // Caps the logged output so a large banner does not flood the log stream. + nonisolated private static func snapshot(of output: String) -> String { + let limit = 500 + let prefix = output.prefix(limit) + return prefix.endIndex == output.endIndex ? String(prefix) : "\(prefix)… (truncated)" + } +} + +enum GithubAuthStatusParsing { + // `hosts` is an unordered dictionary, so prefer github.com and otherwise sort the keys: the result + // is deterministic even when more than one host has an active account. + nonisolated static func activeAccount( + in response: GithubAuthStatusResponse + ) -> (host: String, login: String)? { + let orderedHosts = response.hosts.keys.sorted { lhs, rhs in + if lhs == "github.com" { return true } + if rhs == "github.com" { return false } + return lhs < rhs + } + for host in orderedHosts { + if let active = response.hosts[host]?.first(where: { $0.active }) { + return (host, active.login) + } + } + return nil + } +} + struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? @@ -76,10 +218,9 @@ nonisolated private func defaultBranchFetcher( arguments: ["repo", "view", "--json", "defaultBranchRef"], repoRoot: repoRoot ) - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let response = try decoder.decode(GithubRepoViewResponse.self, from: data) + let response = try GithubCLIOutput.decode(GithubRepoViewResponse.self, from: output, decoder: decoder) return response.defaultBranchRef.name } } @@ -96,8 +237,10 @@ nonisolated private func resolveRemoteInfoFetcher( arguments: ["repo", "view", "--json", "owner,name,url"], repoRoot: repoRoot ) - let data = Data(output.utf8) - let response = try JSONDecoder().decode(GithubRepoViewRemoteInfoResponse.self, from: data) + guard let response = try GithubCLIOutput.decodeIfPresent(GithubRepoViewRemoteInfoResponse.self, from: output) + else { + return nil + } return response.remoteInfo } catch { return nil @@ -125,14 +268,11 @@ nonisolated private func latestRunFetcher( ], repoRoot: repoRoot ) - if output.isEmpty { - return nil - } - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let runs = try decoder.decode([GithubWorkflowRun].self, from: data) - return runs.first + // nil payload means no runs; a present-but-undecodable payload still throws. + let runs = try GithubCLIOutput.decodeIfPresent([GithubWorkflowRun].self, from: output, decoder: decoder) + return runs?.first } } @@ -319,10 +459,9 @@ nonisolated private func fetchCrossRepoChunk( } return CrossRepoChunkOutcome(successByRepo: [:], failedRepos: failed) } - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let response = try decoder.decode(CrossRepoPullRequestResponse.self, from: data) + let response = try GithubCLIOutput.decode(CrossRepoPullRequestResponse.self, from: output, decoder: decoder) let errorMessagesByAlias = response.errorMessagesByAlias() var success: [RepoKey: [String: GithubPullRequest]] = [:] @@ -642,14 +781,11 @@ nonisolated private func authStatusFetcher( arguments: ["auth", "status", "--json", "hosts"], repoRoot: nil ) - let data = Data(output.utf8) - let response = try decodeAuthStatusResponse(from: data) - guard let (host, accounts) = response.hosts.first, - let activeAccount = accounts.first(where: { $0.active }) - else { + let response = try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: output) + guard let active = GithubAuthStatusParsing.activeAccount(in: response) else { return nil } - return GithubAuthStatus(username: activeAccount.login, host: host) + return GithubAuthStatus(username: active.login, host: active.host) } } @@ -772,10 +908,9 @@ nonisolated private func fetchPullRequestsChunk( return (chunkIndex, [:]) } - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) + let response = try GithubCLIOutput.decode(GithubGraphQLPullRequestResponse.self, from: output, decoder: decoder) let prsByBranch = response.pullRequestsByBranch( aliasMap: aliasMap, owner: request.owner, @@ -928,7 +1063,3 @@ nonisolated private func shouldRetryGhExecution(after error: Error) -> Bool { } return false } - -nonisolated private func decodeAuthStatusResponse(from data: Data) throws -> GithubAuthStatusResponse { - try JSONDecoder().decode(GithubAuthStatusResponse.self, from: data) -} diff --git a/supacodeTests/GithubCLIOutputTests.swift b/supacodeTests/GithubCLIOutputTests.swift new file mode 100644 index 00000000..7d0f571f --- /dev/null +++ b/supacodeTests/GithubCLIOutputTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing + +@testable import supacode + +struct GithubCLIOutputTests { + private struct Payload: Decodable, Equatable { + let name: String + } + + // MARK: - balancedJSONSpans + + @Test func findsACleanObject() { + let spans = GithubCLIOutput.balancedJSONSpans(in: #"{"name":"main"}"#) + #expect(spans.map(String.init) == [#"{"name":"main"}"#]) + } + + @Test func skipsLeadingBannerBeforeObject() { + let output = "nvm: version manager loaded\n{\"name\":\"main\"}" + let spans = GithubCLIOutput.balancedJSONSpans(in: output) + #expect(spans.map(String.init) == [#"{"name":"main"}"#]) + } + + @Test func skipsLeadingBannerBeforeArray() { + let output = "Welcome!\n[{\"name\":\"main\"}]" + let spans = GithubCLIOutput.balancedJSONSpans(in: output) + #expect(spans.map(String.init) == [#"[{"name":"main"}]"#]) + } + + @Test func ignoresBracesAndEscapedQuotesInsideStrings() { + let output = #"{"title":"a } b \" [c]"}"# + let spans = GithubCLIOutput.balancedJSONSpans(in: output) + #expect(spans.map(String.init) == [output]) + } + + @Test func returnsMultipleTopLevelValuesInOrder() { + let output = #"{"name":"a"} trailing noise {"name":"b"}"# + let spans = GithubCLIOutput.balancedJSONSpans(in: output) + #expect(spans.map(String.init) == [#"{"name":"a"}"#, #"{"name":"b"}"#]) + } + + @Test func skipsStrayUnbalancedOpenerAndKeepsScanning() { + // A verbose shell can emit a lone "{" that never balances; it must not + // swallow the real payload that follows. + let output = "{ partial banner\n{\"name\":\"main\"}" + let spans = GithubCLIOutput.balancedJSONSpans(in: output) + #expect(spans.map(String.init) == [#"{"name":"main"}"#]) + } + + @Test func yieldsNoSpansForNoiseEmptyOrUnterminated() { + #expect(GithubCLIOutput.balancedJSONSpans(in: "").isEmpty) + #expect(GithubCLIOutput.balancedJSONSpans(in: "just a banner, no json").isEmpty) + #expect(GithubCLIOutput.balancedJSONSpans(in: #"{"name":"unterminated"#).isEmpty) + } + + // MARK: - decode / decodeIfPresent + + @Test func decodesBannerPrefixedPayload() throws { + let output = "Loading shell config...\n{\"name\":\"main\"}" + let value = try GithubCLIOutput.decode(Payload.self, from: output) + #expect(value == Payload(name: "main")) + } + + @Test func prefersLastDecodableSpan() throws { + // gh prints its JSON after any leading noise, so a leading banner-shaped + // object must lose to the real trailing payload. + let output = #"{"unrelated":"banner"} {"name":"real"}"# + let value = try GithubCLIOutput.decode(Payload.self, from: output) + #expect(value == Payload(name: "real")) + } + + @Test func decodeIfPresentReturnsNilWhenNoPayload() throws { + let value = try GithubCLIOutput.decodeIfPresent(Payload.self, from: "no json here") + #expect(value == nil) + } + + @Test func decodeThrowsNoPayloadMessageWhenMissing() { + #expect(throws: GithubCLIError.commandFailed(GithubCLIOutput.noPayloadMessage)) { + _ = try GithubCLIOutput.decode(Payload.self, from: "banner only") + } + } + + @Test func decodeThrowsUndecodableMessageForValidButWrongShapeJSON() { + // Valid JSON whose shape doesn't match points at a gh version mismatch, not + // shell pollution. + #expect(throws: GithubCLIError.commandFailed(GithubCLIOutput.undecodableMessage)) { + _ = try GithubCLIOutput.decode(Payload.self, from: #"{"unexpected":123}"#) + } + } + + // MARK: - activeAccount + + private func authResponse(_ json: String) throws -> GithubAuthStatusResponse { + try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: json) + } + + @Test func picksActiveAccountFromNonFirstHost() throws { + let response = try authResponse( + #"{"hosts":{"git.example.com":[{"active":true,"login":"enterprise-user"}]}}"# + ) + let active = GithubAuthStatusParsing.activeAccount(in: response) + #expect(active?.host == "git.example.com") + #expect(active?.login == "enterprise-user") + } + + @Test func prefersGithubComWhenMultipleHostsActive() throws { + let response = try authResponse( + """ + {"hosts":{"git.example.com":[{"active":true,"login":"enterprise"}],\ + "github.com":[{"active":true,"login":"dotcom"}]}} + """ + ) + let active = GithubAuthStatusParsing.activeAccount(in: response) + #expect(active?.host == "github.com") + #expect(active?.login == "dotcom") + } + + @Test func sortsHostsDeterministicallyWhenGithubComAbsent() throws { + let response = try authResponse( + """ + {"hosts":{"zeta.example.com":[{"active":true,"login":"z"}],\ + "alpha.example.com":[{"active":true,"login":"a"}]}} + """ + ) + let active = GithubAuthStatusParsing.activeAccount(in: response) + #expect(active?.host == "alpha.example.com") + #expect(active?.login == "a") + } + + @Test func returnsNilWhenNoActiveAccount() throws { + let response = try authResponse( + #"{"hosts":{"github.com":[{"active":false,"login":"inactive"}]}}"# + ) + #expect(GithubAuthStatusParsing.activeAccount(in: response) == nil) + } +} -- 2.51.2 From e02c76f0f678c4775f8e1d3400d5489a543b951e Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 21:36:30 +0900 Subject: [PATCH 364/528] Focus the terminal after next/previous and history worktree navigation Sidebar clicks and arrow selection already request terminal focus, but cycling with Select Next/Previous Worktree and Back/Forward in Worktree History landed on a worktree without focusing its terminal, leaving keystrokes going nowhere until the user clicked into the pane. - `selectNextWorktree` / `selectPreviousWorktree` now send `.selectWorktree(id, focusTerminal: true)` (the Shelf tab-navigation branch is untouched). - `navigateWorktreeHistory` inserts the destination into `pendingTerminalFocusWorktreeIDs`, matching the sidebar/arrow path. Existing exhaustive navigation tests are updated to assert the new `pendingTerminalFocusWorktreeIDs` mutation; the `selectWorktree`-with-default (focusTerminal: false) test is intentionally left unchanged. Ported from upstream supacode #371, reimplemented against the fork's `pendingTerminalFocusWorktreeIDs` mechanism (the fork does not use upstream's row-action focus model). --- .../Reducer/RepositoriesFeature+CoreReducer.swift | 4 ++-- .../Reducer/RepositoriesFeature+Selection.swift | 2 ++ supacodeTests/RepositoriesFeatureTests.swift | 12 ++++++++++++ supacodeTests/ShelfFeatureTests.swift | 1 + 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index f693905f..3819d0ac 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -589,7 +589,7 @@ extension RepositoriesFeature { } } guard let id = state.worktreeID(byOffset: 1) else { return .none } - return .send(.selectWorktree(id)) + return .send(.selectWorktree(id, focusTerminal: true)) case .selectPreviousWorktree: if state.isShelfActive, let worktree = state.selectedTerminalWorktree { @@ -598,7 +598,7 @@ extension RepositoriesFeature { } } guard let id = state.worktreeID(byOffset: -1) else { return .none } - return .send(.selectWorktree(id)) + return .send(.selectWorktree(id, focusTerminal: true)) case .consumeCanvasFocusRequest(let id): if state.pendingCanvasFocusRequest?.id == id { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift index b084bf95..47bf243e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -184,6 +184,8 @@ func navigateWorktreeHistory( } setSingleWorktreeSelection(destinationID, state: &state, recordHistory: false) state.openedWorktreeIDs.insert(destinationID) + // Match sidebar/arrow navigation: focus the terminal of the worktree we land on. + state.pendingTerminalFocusWorktreeIDs.insert(destinationID) return .send(.delegate(.selectedWorktreeChanged(state.worktree(for: destinationID)))) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 6a38aeae..b11cec15 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -4812,6 +4812,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt2.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4832,6 +4833,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4849,6 +4851,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4871,6 +4874,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4888,6 +4892,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4914,6 +4919,7 @@ struct RepositoriesFeatureTests { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4938,6 +4944,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt3.id] $0.openedWorktreeIDs = [wt3.id] $0.worktreeHistoryBackStack = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt3.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -4962,6 +4969,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt3.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -5012,6 +5020,7 @@ struct RepositoriesFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt3.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -5056,6 +5065,7 @@ struct RepositoriesFeatureTests { $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [] $0.worktreeHistoryForwardStack = [wt2.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -5077,6 +5087,7 @@ struct RepositoriesFeatureTests { $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] $0.worktreeHistoryForwardStack = [] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) } @@ -5098,6 +5109,7 @@ struct RepositoriesFeatureTests { $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [] $0.worktreeHistoryForwardStack = [wt3.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) } diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 6e513b19..cc2700a9 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -513,6 +513,7 @@ struct ShelfFeatureTests { $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() -- 2.51.2 From 02e1319207024e2d1fcab9b2648b2ce7a428f918 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 21:39:39 +0900 Subject: [PATCH 365/528] Persist the main window's position and size across launches Set frameAutosaveName on the main window in disallowTabbing() so macOS restores its frame on relaunch, matching the diff window (which already autosaves). The call is idempotent, so re-running it on later passes is a no-op. Ported from upstream supacode #313. --- supacode/App/WindowTabbingDisabler.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supacode/App/WindowTabbingDisabler.swift b/supacode/App/WindowTabbingDisabler.swift index 358f7308..9c70b35b 100644 --- a/supacode/App/WindowTabbingDisabler.swift +++ b/supacode/App/WindowTabbingDisabler.swift @@ -21,6 +21,9 @@ final class WindowTabbingView: NSView, NSWindowDelegate { guard let window else { return } window.tabbingMode = .disallowed window.identifier = NSUserInterfaceItemIdentifier(WindowID.main) + // Persist the main window's position and size across launches. Idempotent: + // re-associating the same autosave name on later passes is a no-op. + window.setFrameAutosaveName(NSWindow.FrameAutosaveName(WindowID.main)) window.isExcludedFromWindowsMenu = true if window.delegate !== self { window.delegate = self -- 2.51.2 From 3b499662a342092b510f76e2db078f472bd6b697 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 21:39:39 +0900 Subject: [PATCH 366/528] Keep the selected command palette row legible in light mode The selected row paints an accent (selectedContentBackgroundColor) background, but its label and shortcut symbols kept the ambient color scheme, washing out in light mode. Force a dark color scheme for the selected row's content via transformEnvironment so text and symbols stay readable on the accent fill. Ported from upstream supacode #353, adapted to the fork's row layout. --- .../CommandPalette/Views/CommandPaletteOverlayView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index d92a9921..e9fa92fc 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -697,6 +697,11 @@ private struct CommandPaletteRowView: View { } } .padding(8) + // The selected row paints an accent background; force a dark color scheme + // for its content so the label and symbols stay legible in light mode. + .transformEnvironment(\.colorScheme) { colorScheme in + if isSelected { colorScheme = .dark } + } .background(rowBackground) .clipShape(.rect(cornerRadius: 14)) } -- 2.51.2 From 838b1405d5ae1fc86e8e8748457890499608461a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 21:39:39 +0900 Subject: [PATCH 367/528] Fix empty shortcut hint on Find Next/Previous terminal menu items The Find Next / Find Previous menu items looked up their keyboard-shortcut hint under "search:next" / "search:previous", but the fork's search navigation binds "navigate_search:next" / "navigate_search:previous" (see GhosttySearchNavigation), so the lookup missed and the menu showed no shortcut. Use the correct binding action names. Spotted via upstream supacode #318 (which fixed the same latent bug). --- supacode/Commands/TerminalCommands.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index 1fa4a41d..d87b2fb5 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -77,7 +77,7 @@ struct TerminalCommands: Commands { navigateSearchNextAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:next")) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next")) ) .disabled(navigateSearchNextAction == nil) @@ -85,7 +85,7 @@ struct TerminalCommands: Commands { navigateSearchPreviousAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:previous")) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous")) ) .disabled(navigateSearchPreviousAction == nil) -- 2.51.2 From 5cb67724c70cb6d0799ca4e441e0a572db22e175 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 22:09:49 +0900 Subject: [PATCH 368/528] Fix command palette color-scheme flicker on first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette card forced a color scheme computed from `NSColor.windowBackgroundColor.isLightColor` inside `body`. That dynamic color resolves against the current drawing appearance context, which isn't settled on the palette's first render, so in Light mode the card briefly flashed the wrong scheme on open and only corrected on the next re-render (e.g. the first keystroke). Drop the forced override entirely and let the card inherit the ambient `@Environment(\.colorScheme)` — which already reflects the window's appearance, is correct on the first render, and updates reactively. Also removes the now-unused `isLightColor` NSColor extension (its `luminance` helper stays; it's still used by the Ghostty theme code). The selected-row dark-scheme override from the legibility fix is unaffected. --- .../Views/CommandPaletteOverlayView.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index e9fa92fc..206a9844 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -203,10 +203,6 @@ private struct CommandPaletteCard: View { Color(nsColor: .windowBackgroundColor) } - private var colorScheme: ColorScheme { - NSColor.windowBackgroundColor.isLightColor ? .light : .dark - } - var body: some View { VStack(alignment: .leading, spacing: 0) { CommandPaletteQuery(query: $query, isTextFieldFocused: isQueryFocused) { event in @@ -233,7 +229,6 @@ private struct CommandPaletteCard: View { .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 14)) .shadow(radius: 32, x: 0, y: 12) .padding(Self.padding) - .environment(\.colorScheme, colorScheme) } } @@ -874,9 +869,3 @@ private func commandPaletteShortcutSymbols(for index: Int) -> [String] { private func commandPaletteShortcutLabel(for index: Int) -> String { "Cmd+\(index + 1)" } - -extension NSColor { - fileprivate var isLightColor: Bool { - luminance > 0.5 - } -} -- 2.51.2 From 5f5393e21917cc04aee53119bbe0fca638a9eb10 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 22:49:19 +0900 Subject: [PATCH 369/528] Migrate sheet/alert presentation to TCA view-side APIs to fix dismiss flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecated `.sheet(store:)` / `.alert(store:)` / `IfLetStore` APIs replace their content with `EmptyView` the moment the presentation state goes nil — before SwiftUI's dismiss animation finishes — so the content flashes empty during the animation. - ContentView: the new-worktree sheet moves to `.sheet(item: $store.scope(...))` (the one user-visible flash), and the two alerts move to `.alert($store.scope(...))`. - SettingsView: the repository-settings `IfLetStore` becomes `if let` with an explicit `ProgressView` else branch, so the detail pane shows a settled placeholder instead of blank while the scoped store is briefly nil; the alert moves to `.alert($store.scope(...))`. Reducer-side `.ifLet` presentation stays unchanged. Ported from upstream supacode #310, adapted to the fork's (diverged, smaller) set of presentation sites; the `deleteWorktreeConfirmation` `isPresented` sheet has the same latent flash but is out of scope here. --- supacode/App/ContentView.swift | 6 +++--- .../Features/Settings/Views/SettingsView.swift | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index f501419f..21950872 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -74,9 +74,9 @@ struct ContentView: View { ) } } - .alert(store: repositoriesStore.scope(state: \.$alert, action: \.alert)) - .alert(store: store.scope(state: \.$alert, action: \.alert)) - .sheet(store: repositoriesStore.scope(state: \.$worktreeCreationPrompt, action: \.worktreeCreationPrompt)) { + .alert($repositoriesStore.scope(state: \.alert, action: \.alert)) + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $repositoriesStore.scope(state: \.worktreeCreationPrompt, action: \.worktreeCreationPrompt)) { promptStore in WorktreeCreationPromptView(store: promptStore) } diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 11d9f2a2..e09f93dc 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -107,13 +107,21 @@ struct SettingsView: View { case .repository(let repositoryID): if let repository = repositories[id: repositoryID] { SettingsDetailView { - IfLetStore( - settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) - ) { repositorySettingsStore in + if let repositorySettingsStore = settingsStore.scope( + state: \.repositorySettings, + action: \.repositorySettings + ) { RepositorySettingsView(store: repositorySettingsStore) .id(repository.id) .navigationTitle(customTitles[repository.id] ?? repository.name) .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) + } else { + // Settled placeholder while the scoped store is briefly nil (e.g. mid + // repository switch), instead of `IfLetStore` flashing an empty pane. + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(customTitles[repository.id] ?? repository.name) + .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) } } } else { @@ -127,7 +135,7 @@ struct SettingsView: View { } } .navigationSplitViewStyle(.balanced) - .alert(store: settingsStore.scope(state: \.$alert, action: \.alert)) + .alert($settingsStore.scope(state: \.alert, action: \.alert)) .frame(minWidth: 800, minHeight: 500) .background { WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme) -- 2.51.2 From da8cfb393e57a38459ca17e0dbf6c74c0596d9ad Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 22:54:48 +0900 Subject: [PATCH 370/528] Refresh hyperlink highlight on Cmd press over an unfocused split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Cmd over a terminal link in a non-focused split did not underline it or switch the cursor to a pointer until the mouse was moved across it again. Bare modifier presses arrive as flagsChanged events, which the responder chain only delivers to the first responder — so a hovered but unfocused surface never saw the modifier change and only refreshed its link highlight once a mouse move drove Ghostty's cursor-position callback. - Forward flagsChanged through the per-surface local event monitor to any surface that isn't the first responder (`localEventFlagsChanged`), and add `.flagsChanged` to the monitored event mask. - Guard `translationState` against non-key events: the `characters`-family APIs throw on a flagsChanged event, which would abort the send before Ghostty saw the modifier. (`ghosttyCharacters` already has this guard via the fork's earlier #374 work — that half only covered the focused path.) Ported from upstream supacode #344 (Fixes #242). Confirmed repro: focused terminal already worked; the non-focused split / Cmd-without-mouse-move case did not — now fixed. --- .../Ghostty/GhosttySurfaceView+EventTranslation.swift | 5 +++++ .../Ghostty/GhosttySurfaceView+Mouse.swift | 11 +++++++++++ .../Infrastructure/Ghostty/GhosttySurfaceView.swift | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift index 8802e969..17c77cce 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+EventTranslation.swift @@ -6,6 +6,11 @@ extension GhosttySurfaceView { func translationState(_ event: NSEvent, surface: ghostty_surface_t) -> ( NSEvent, NSEvent.ModifierFlags ) { + // `characters`-family APIs throw on non-key events, so skip translation for a + // modifier-only event (otherwise a bare Cmd aborts the send before Ghostty sees it). + guard event.type == .keyDown || event.type == .keyUp else { + return (event, event.modifierFlags) + } let translatedModsGhostty = ghostty_surface_key_translation_mods( surface, ghosttyMods(event.modifierFlags)) let translatedMods = appKitMods(translatedModsGhostty) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift index 93cd1fd3..65016aa1 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Mouse.swift @@ -148,6 +148,8 @@ extension GhosttySurfaceView { localEventKeyUp(event) case .leftMouseDown: localEventLeftMouseDown(event) + case .flagsChanged: + localEventFlagsChanged(event) default: event } @@ -160,6 +162,15 @@ extension GhosttySurfaceView { return nil } + // The responder chain delivers flagsChanged only to the first responder, so forward it to + // every other surface; a hovered but unfocused terminal still needs to refresh its links + // (e.g. holding Cmd over a link in a non-focused split, without moving the mouse). + func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { + guard window != nil, window?.firstResponder !== self else { return event } + flagsChanged(with: event) + return event + } + func localEventLeftMouseDown(_ event: NSEvent) -> NSEvent? { guard let window, event.window != nil, window == event.window else { return event } let location = convert(event.locationInWindow, from: nil) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 33aedc20..607e1b6e 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -315,7 +315,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } registerForDraggedTypes(Array(Self.dropTypes)) - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyUp, .leftMouseDown]) { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyUp, .leftMouseDown, .flagsChanged]) { [weak self] event in self?.localEventHandler(event) } -- 2.51.2 From 2acb948bd8ba1d7565e78a6afebef122940638c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 23:29:01 +0900 Subject: [PATCH 371/528] Let users override the new worktree's name and parent directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a default-collapsed "Advanced" section to the New Worktree dialog with two optional overrides: the worktree folder name (placeholder: the branch name) and the parent directory (placeholder: the resolved default base). Both empty keeps `wt`'s default `base/` placement; the footer previews the full destination path, or an inline validation error. - `WorktreePlacementOverride` carries the optional name/path; its `nameValidationError` rejects slashes, `.`/`..`, and `.git` (the leaf is a single folder). Shared by the prompt and the reducer create path. - `SupacodePaths` gains `resolvedWorktreeDirectory` (nil when no override → keep default) and `previewWorktreeDirectory` (always concrete, for the live preview). - The override threads submit → startPrompted → checked → createInRepository as a defaulted `placement`, so the random-worktree path is unaffected, and is applied via `wt sw --path ` (branch stays the positional arg, so the sidebar name still tracks the branch). The progress command preview shows `--path` too. Ported from upstream supacode #351 (GUI only); the fork's `wt` already supports `sw --path`. The CLI/deeplink overrides from upstream don't apply — the fork's `prowl` CLI has no worktree-management commands. --- docs/components/repositories-and-worktrees.md | 10 + supacode/Clients/Git/GitClient.swift | 13 +- .../Repositories/GitClientDependency.swift | 8 +- .../Models/WorktreePlacementOverride.swift | 30 +++ .../RepositoriesFeature+CoreReducer.swift | 5 +- ...RepositoriesFeature+WorktreeCreation.swift | 33 ++- .../RepositoriesFeature+WorktreeState.swift | 7 +- .../Reducer/RepositoriesFeature.swift | 7 +- .../WorktreeCreationPromptFeature.swift | 54 ++++- .../Views/WorktreeCreationPromptView.swift | 33 ++- supacode/Support/SupacodePaths.swift | 74 +++++++ supacodeTests/RepositoriesFeatureTests.swift | 38 +++- .../WorktreeCreationPlacementTests.swift | 194 ++++++++++++++++++ 13 files changed, 476 insertions(+), 30 deletions(-) create mode 100644 supacode/Features/Repositories/Models/WorktreePlacementOverride.swift create mode 100644 supacodeTests/WorktreeCreationPlacementTests.swift diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 3831453c..9d1f60a1 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -53,6 +53,16 @@ By default a **creation prompt** appears (controlled by - choose the **base ref** (branch/tag) to branch from, - optionally **fetch the remote first**. +An optional, default-collapsed **Advanced** section lets you override where the +worktree lands: +- **Worktree name** — the leaf folder name (defaults to the branch name). +- **Parent folder** — the directory it's created in (defaults to the repo's + resolved base directory). + +Leave both blank to keep the default `base/` placement. The footer shows +the full destination path as you type, or an inline error (the worktree name is a +single folder, so slashes, `.`/`..`, and `.git` are rejected). + Press **↩** to create, **Esc** to cancel. Branch names are validated live (`git check-ref-format`). diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index af8c90ac..f7ec223c 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -240,7 +240,8 @@ struct GitClient { in repoRoot: URL, baseDirectory: URL, copyFiles: (ignored: Bool, untracked: Bool), - baseRef: String + baseRef: String, + directoryOverride: URL? = nil ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { @@ -252,7 +253,8 @@ struct GitClient { name: name, copyIgnored: copyFiles.ignored, copyUntracked: copyFiles.untracked, - baseRef: baseRef + baseRef: baseRef, + directoryOverride: directoryOverride ) let envURL = URL(fileURLWithPath: "/usr/bin/env") let localeArguments = ["LANG=C", "LC_ALL=C", "LC_MESSAGES=C"] @@ -323,7 +325,8 @@ struct GitClient { name: String, copyIgnored: Bool, copyUntracked: Bool, - baseRef: String + baseRef: String, + directoryOverride: URL? = nil ) -> [String] { var arguments = ["--base-dir", baseDirectory.path(percentEncoded: false), "sw"] if copyIgnored { @@ -336,6 +339,10 @@ struct GitClient { arguments.append("--from") arguments.append(baseRef) } + if let directoryOverride { + arguments.append("--path") + arguments.append(directoryOverride.path(percentEncoded: false)) + } if copyIgnored || copyUntracked { arguments.append("--verbose") } diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index 26ee0f36..0d8b8584 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -29,7 +29,8 @@ struct GitClientDependency: Sendable { _ baseDirectory: URL, _ copyIgnored: Bool, _ copyUntracked: Bool, - _ baseRef: String + _ baseRef: String, + _ directoryOverride: URL? ) -> AsyncThrowingStream var removeWorktree: @Sendable (_ worktree: Worktree, _ deleteBranch: Bool) async throws -> URL var deleteLocalBranch: @@ -68,13 +69,14 @@ extension GitClientDependency: DependencyKey { baseRef: baseRef ) }, - createWorktreeStream: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef in + createWorktreeStream: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef, directoryOverride in GitClient().createWorktreeStream( named: name, in: repoRoot, baseDirectory: baseDirectory, copyFiles: (ignored: copyIgnored, untracked: copyUntracked), - baseRef: baseRef + baseRef: baseRef, + directoryOverride: directoryOverride ) }, removeWorktree: { worktree, deleteBranch in diff --git a/supacode/Features/Repositories/Models/WorktreePlacementOverride.swift b/supacode/Features/Repositories/Models/WorktreePlacementOverride.swift new file mode 100644 index 00000000..d905ddcc --- /dev/null +++ b/supacode/Features/Repositories/Models/WorktreePlacementOverride.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Optional per-creation overrides from the New Worktree dialog's Advanced +/// section. `name` is the leaf folder name (default: the branch name); `path` +/// is the parent directory (default: the resolved base directory). Both `nil` +/// keeps `wt`'s default `base/` placement. +struct WorktreePlacementOverride: Equatable { + var name: String? + var path: String? +} + +extension WorktreePlacementOverride { + /// Validates a worktree-name leaf override. Returns an error message when the + /// name would escape the parent folder or collide with git internals, or + /// `nil` when it is empty (use the default) or acceptable. Shared by the + /// prompt and the reducer create path. + nonisolated static func nameValidationError(_ name: String?) -> String? { + let trimmed = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + guard !trimmed.contains("/"), !trimmed.contains("\\") else { + return "Worktree name can't contain slashes." + } + guard trimmed != ".", trimmed != "..", trimmed.lowercased() != ".git" else { + return "Worktree name is invalid." + } + return nil + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 3819d0ac..63b04ec6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -698,14 +698,15 @@ extension RepositoriesFeature { return .send(.worktreeCreation(.promptCanceled)) case .worktreeCreationPrompt( - .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) + .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef, let placement))) ): return .send( .worktreeCreation( .startPromptedWorktreeCreation( repositoryID: repositoryID, branchName: branchName, - baseRef: baseRef + baseRef: baseRef, + placement: placement ) ) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift index afe9228e..bf4df3b4 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift @@ -139,19 +139,27 @@ extension RepositoriesFeature { return .none } @Shared(.settingsFile) var settingsFile + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + let defaultWorktreeBaseDirectory = SupacodePaths.worktreeBaseDirectory( + for: repository.rootURL, + globalDefaultPath: settingsFile.global.defaultWorktreeBaseDirectoryPath, + repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath + ) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, + repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: automaticBaseRef, baseRefOptions: baseRefOptions, branchName: "", selectedBaseRef: selectedBaseRef, fetchRemote: settingsFile.global.fetchOriginBeforeWorktreeCreation, + defaultWorktreeBaseDirectory: defaultWorktreeBaseDirectory.path(percentEncoded: false), validationMessage: nil ) return .none - case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef): + case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef, let placement): guard let repository = state.repositories[id: repositoryID] else { state.worktreeCreationPrompt = nil state.alert = messageAlert( @@ -186,6 +194,7 @@ extension RepositoriesFeature { branchName: branchName, baseRef: baseRef, fetchRemote: fetchRemote, + placement: placement, duplicateMessage: duplicateMessage ) ) @@ -198,6 +207,7 @@ extension RepositoriesFeature { let branchName, let baseRef, let fetchRemote, + let placement, let duplicateMessage ): guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { @@ -215,12 +225,14 @@ extension RepositoriesFeature { repositoryID: repositoryID, nameSource: .explicit(branchName), baseRefSource: .explicit(baseRef), - fetchRemote: fetchRemote + fetchRemote: fetchRemote, + placement: placement ) ) ) - case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource, let fetchRemote): + case .createWorktreeInRepository( + let repositoryID, let nameSource, let baseRefSource, let fetchRemote, let placement): guard let repository = state.repositories[id: repositoryID] else { state.alert = messageAlert( title: "Unable to create worktree", @@ -448,13 +460,23 @@ extension RepositoriesFeature { copyIgnored ? ((try? await gitClient.ignoredFileCount(repository.rootURL)) ?? 0) : 0 progress.untrackedFilesToCopyCount = copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 + // Resolve an explicit destination from the dialog's name / parent-folder + // overrides; nil keeps `wt`'s default `base/` placement. + let directoryOverride = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: worktreeBaseDirectory, + repositoryRootURL: repository.rootURL, + nameOverride: placement.name, + pathOverride: placement.path, + branchName: name + ) progress.stage = .creatingWorktree progress.commandText = worktreeCreateCommand( baseDirectoryURL: worktreeBaseDirectory, name: name, copyIgnored: copyIgnored, copyUntracked: copyUntracked, - baseRef: resolvedBaseRef + baseRef: resolvedBaseRef, + directoryOverride: directoryOverride ) await send( .worktreeCreation( @@ -470,7 +492,8 @@ extension RepositoriesFeature { worktreeBaseDirectory, copyIgnored, copyUntracked, - resolvedBaseRef + resolvedBaseRef, + directoryOverride ) for try await event in stream { switch event { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift index 16a047fa..32f2cc89 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeState.swift @@ -184,7 +184,8 @@ nonisolated func worktreeCreateCommand( name: String, copyIgnored: Bool, copyUntracked: Bool, - baseRef: String + baseRef: String, + directoryOverride: URL? = nil ) -> String { let baseDir = baseDirectoryURL.path(percentEncoded: false) var parts = ["wt", "--base-dir", baseDir, "sw"] @@ -198,6 +199,10 @@ nonisolated func worktreeCreateCommand( parts.append("--from") parts.append(baseRef) } + if let directoryOverride { + parts.append("--path") + parts.append(directoryOverride.path(percentEncoded: false)) + } if copyIgnored || copyUntracked { parts.append("--verbose") } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 6388f836..5bbeac2f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -96,7 +96,8 @@ struct RepositoriesFeature { repositoryID: Repository.ID, nameSource: WorktreeCreationNameSource, baseRefSource: WorktreeCreationBaseRefSource, - fetchRemote: Bool + fetchRemote: Bool, + placement: WorktreePlacementOverride = WorktreePlacementOverride() ) case promptedWorktreeCreationDataLoaded( repositoryID: Repository.ID, @@ -107,13 +108,15 @@ struct RepositoriesFeature { case startPromptedWorktreeCreation( repositoryID: Repository.ID, branchName: String, - baseRef: String? + baseRef: String?, + placement: WorktreePlacementOverride = WorktreePlacementOverride() ) case promptedWorktreeCreationChecked( repositoryID: Repository.ID, branchName: String, baseRef: String?, fetchRemote: Bool, + placement: WorktreePlacementOverride = WorktreePlacementOverride(), duplicateMessage: String? ) case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) diff --git a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift index a6454878..5662c653 100644 --- a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift @@ -6,18 +6,53 @@ struct WorktreeCreationPromptFeature { @ObservableState struct State: Equatable { let repositoryID: Repository.ID + /// Canonical repository root, used to resolve relative path overrides in the + /// preview the same way the reducer does (not reconstructed from the ID). + let repositoryRootURL: URL let repositoryName: String let automaticBaseRef: String let baseRefOptions: [String] var branchName: String var selectedBaseRef: String? var fetchRemote: Bool + /// Resolved default base directory, used to compute the location preview. + let defaultWorktreeBaseDirectory: String + /// Leaf folder name override; empty falls back to the branch name. + var worktreeNameOverride: String = "" + /// Parent directory override; empty falls back to `defaultWorktreeBaseDirectory`. + var worktreePathOverride: String = "" + /// Disclosure state for the advanced placement section. Collapsed by default. + var showAdvancedOptions: Bool = false var validationMessage: String? var isValidating = false var automaticBaseRefLabel: String { automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" } + + /// Default leaf folder name shown as the name-override placeholder. + var worktreeNamePlaceholder: String { + branchName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Live validity of the current name override, so the footer can flag an + /// invalid leaf instead of previewing a destination submit will reject. + var worktreeNameValidationError: String? { + WorktreePlacementOverride.nameValidationError(worktreeNameOverride) + } + + /// Full destination path the worktree will be created at, mirroring the + /// reducer's resolution. + var resolvedWorktreeLocationPreview: String { + SupacodePaths.previewWorktreeDirectory( + defaultBaseDirectory: URL(filePath: defaultWorktreeBaseDirectory, directoryHint: .isDirectory), + repositoryRootURL: repositoryRootURL, + nameOverride: worktreeNameOverride, + pathOverride: worktreePathOverride, + branchName: branchName + ) + .path(percentEncoded: false) + } } enum Action: BindableAction, Equatable { @@ -32,7 +67,12 @@ struct WorktreeCreationPromptFeature { @CasePathable enum Delegate: Equatable { case cancel - case submit(repositoryID: Repository.ID, branchName: String, baseRef: String?) + case submit( + repositoryID: Repository.ID, + branchName: String, + baseRef: String?, + placement: WorktreePlacementOverride + ) } var body: some Reducer { @@ -56,13 +96,23 @@ struct WorktreeCreationPromptFeature { state.validationMessage = "Branch names can't contain spaces." return .none } + let nameOverride = state.worktreeNameOverride.trimmingCharacters(in: .whitespacesAndNewlines) + if let nameError = WorktreePlacementOverride.nameValidationError(nameOverride) { + state.validationMessage = nameError + return .none + } state.validationMessage = nil + let pathOverride = state.worktreePathOverride.trimmingCharacters(in: .whitespacesAndNewlines) return .send( .delegate( .submit( repositoryID: state.repositoryID, branchName: trimmed, - baseRef: state.selectedBaseRef + baseRef: state.selectedBaseRef, + placement: WorktreePlacementOverride( + name: nameOverride.isEmpty ? nil : nameOverride, + path: pathOverride.isEmpty ? nil : pathOverride + ) ) ) ) diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 0db3e6b3..03cf56a1 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -45,10 +45,39 @@ struct WorktreeCreationPromptView: View { .foregroundStyle(.secondary) } - if let validationMessage = store.validationMessage, !validationMessage.isEmpty { - Text(validationMessage) + DisclosureGroup("Advanced", isExpanded: $store.showAdvancedOptions) { + VStack(alignment: .leading, spacing: 8) { + TextField( + "Worktree name", + text: $store.worktreeNameOverride, + prompt: Text(store.worktreeNamePlaceholder) + ) + .textFieldStyle(.roundedBorder) + TextField( + "Parent folder", + text: $store.worktreePathOverride, + prompt: Text(store.defaultWorktreeBaseDirectory) + ) + .textFieldStyle(.roundedBorder) + } + .padding(.top, 4) + } + + // Footer: surface a validation error, otherwise preview the full destination + // path the worktree will be created at (mirrors the reducer's resolution). + if let message = store.validationMessage ?? store.worktreeNameValidationError, !message.isEmpty { + Text(message) .font(.footnote) .foregroundStyle(.red) + } else { + Text(store.resolvedWorktreeLocationPreview) + .font(.footnote) + .monospaced() + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) } HStack { diff --git a/supacode/Support/SupacodePaths.swift b/supacode/Support/SupacodePaths.swift index f70b0667..2c560a34 100644 --- a/supacode/Support/SupacodePaths.swift +++ b/supacode/Support/SupacodePaths.swift @@ -76,6 +76,80 @@ nonisolated enum SupacodePaths { ) } + /// Resolves an explicit worktree directory from the dialog's optional name / + /// path overrides. Returns `nil` when neither is set so callers keep `wt`'s + /// default `base/` placement. The path override sets the parent + /// directory (default: the resolved base); the name override sets the leaf + /// folder (default: the branch name). + static func resolvedWorktreeDirectory( + defaultBaseDirectory: URL, + repositoryRootURL: URL, + nameOverride: String?, + pathOverride: String?, + branchName: String + ) -> URL? { + let trimmedName = nameOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPath = pathOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedName.isEmpty || !trimmedPath.isEmpty else { + return nil + } + return worktreePlacement( + defaultBaseDirectory: defaultBaseDirectory, + repositoryRootURL: repositoryRootURL, + trimmedName: trimmedName, + trimmedPath: trimmedPath, + branchName: branchName + ) + } + + /// Always-concrete counterpart to `resolvedWorktreeDirectory` for the dialog's + /// live destination preview. Falls back to the default base and branch name + /// when the overrides are empty. + static func previewWorktreeDirectory( + defaultBaseDirectory: URL, + repositoryRootURL: URL, + nameOverride: String?, + pathOverride: String?, + branchName: String + ) -> URL { + worktreePlacement( + defaultBaseDirectory: defaultBaseDirectory, + repositoryRootURL: repositoryRootURL, + trimmedName: nameOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + trimmedPath: pathOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + branchName: branchName + ) + } + + /// Shared base + leaf join for both resolvers. `trimmedName` / `trimmedPath` + /// are expected pre-trimmed by callers; only the branch-name fallback is + /// re-trimmed defensively. + private static func worktreePlacement( + defaultBaseDirectory: URL, + repositoryRootURL: URL, + trimmedName: String, + trimmedPath: String, + branchName: String + ) -> URL { + let baseURL: URL + if let normalizedPath = normalizedWorktreeBaseDirectoryPath( + trimmedPath, + repositoryRootURL: repositoryRootURL + ) { + baseURL = URL(filePath: normalizedPath, directoryHint: .isDirectory).standardizedFileURL + } else { + baseURL = defaultBaseDirectory.standardizedFileURL + } + let leaf = + trimmedName.isEmpty + ? branchName.trimmingCharacters(in: .whitespacesAndNewlines) + : trimmedName + guard !leaf.isEmpty else { + return baseURL + } + return baseURL.appending(path: leaf, directoryHint: .isDirectory).standardizedFileURL + } + static func worktreeBaseDirectory( for repositoryRootURL: URL, globalDefaultPath: String?, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b11cec15..71afe289 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1610,12 +1610,18 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, + repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/dev", "origin/main"], branchName: "", selectedBaseRef: nil, fetchRemote: true, + defaultWorktreeBaseDirectory: SupacodePaths.worktreeBaseDirectory( + for: repository.rootURL, + globalDefaultPath: nil, + repositoryOverridePath: nil + ).path(percentEncoded: false), validationMessage: nil ) } @@ -1628,12 +1634,14 @@ struct RepositoriesFeatureTests { var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, + repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/new-branch", selectedBaseRef: nil, fetchRemote: true, + defaultWorktreeBaseDirectory: "", validationMessage: nil ) let store = TestStore(initialState: state) { @@ -1653,12 +1661,14 @@ struct RepositoriesFeatureTests { var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, + repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/existing", selectedBaseRef: nil, fetchRemote: true, + defaultWorktreeBaseDirectory: "", validationMessage: nil ) let store = TestStore(initialState: state) { @@ -1736,12 +1746,18 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repoB.id, + repositoryRootURL: repoB.rootURL, repositoryName: repoB.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "", selectedBaseRef: nil, fetchRemote: true, + defaultWorktreeBaseDirectory: SupacodePaths.worktreeBaseDirectory( + for: repoB.rootURL, + globalDefaultPath: nil, + repositoryOverridePath: nil + ).path(percentEncoded: false), validationMessage: nil ) } @@ -1756,12 +1772,14 @@ struct RepositoriesFeatureTests { var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, + repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/new-branch", selectedBaseRef: nil, fetchRemote: true, + defaultWorktreeBaseDirectory: "", validationMessage: nil ) let store = TestStore(initialState: state) { @@ -1898,7 +1916,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) @@ -1946,7 +1964,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in events.withValue { $0.append("create") } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -1997,7 +2015,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2042,7 +2060,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2087,7 +2105,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2139,7 +2157,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _, _ in observedBaseDirectory.withValue { $0 = baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -2191,7 +2209,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _, _ in observedBaseDirectory.withValue { $0 = baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -2244,7 +2262,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _ in + $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _, _ in observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -2293,7 +2311,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _ in + $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _, _ in observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -2330,7 +2348,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) diff --git a/supacodeTests/WorktreeCreationPlacementTests.swift b/supacodeTests/WorktreeCreationPlacementTests.swift new file mode 100644 index 00000000..2047a39e --- /dev/null +++ b/supacodeTests/WorktreeCreationPlacementTests.swift @@ -0,0 +1,194 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +struct WorktreeCreationPlacementTests { + // MARK: - Name validation + + @Test func emptyNameIsValid() { + #expect(WorktreePlacementOverride.nameValidationError(nil) == nil) + #expect(WorktreePlacementOverride.nameValidationError("") == nil) + #expect(WorktreePlacementOverride.nameValidationError(" ") == nil) + } + + @Test func plainNameIsValid() { + #expect(WorktreePlacementOverride.nameValidationError("my-worktree") == nil) + } + + @Test func slashesAreRejected() { + #expect(WorktreePlacementOverride.nameValidationError("a/b") != nil) + #expect(WorktreePlacementOverride.nameValidationError("a\\b") != nil) + } + + @Test func dotSegmentsAndGitAreRejected() { + #expect(WorktreePlacementOverride.nameValidationError(".") != nil) + #expect(WorktreePlacementOverride.nameValidationError("..") != nil) + #expect(WorktreePlacementOverride.nameValidationError(".git") != nil) + #expect(WorktreePlacementOverride.nameValidationError(".GIT") != nil) + } + + // MARK: - resolvedWorktreeDirectory + + private let defaultBase = URL(filePath: "/tmp/base", directoryHint: .isDirectory) + private let repoRoot = URL(filePath: "/tmp/repo", directoryHint: .isDirectory) + + /// Compares paths ignoring a trailing slash. The resolved URLs use + /// `directoryHint: .isDirectory`, so `path(percentEncoded:)` may keep a + /// trailing slash — harmless for `wt --path` and the preview, but we don't + /// want the assertions to depend on it. + private func path(_ url: URL?) -> String? { + guard let value = url?.path(percentEncoded: false) else { return nil } + return value.count > 1 && value.hasSuffix("/") ? String(value.dropLast()) : value + } + + @Test func noOverridesResolvesToNil() { + let resolved = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: nil, + pathOverride: nil, + branchName: "feature/x" + ) + #expect(resolved == nil) + } + + @Test func nameOnlyOverridesLeafUnderDefaultBase() { + let resolved = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: "custom-leaf", + pathOverride: nil, + branchName: "feature/x" + ) + #expect(path(resolved) == "/tmp/base/custom-leaf") + } + + @Test func pathOnlyOverridesParentKeepingBranchLeaf() { + let resolved = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: nil, + pathOverride: "/other/parent", + branchName: "mybranch" + ) + #expect(path(resolved) == "/other/parent/mybranch") + } + + @Test func bothOverridesCombineParentAndLeaf() { + let resolved = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: "leaf", + pathOverride: "/other/parent", + branchName: "mybranch" + ) + #expect(path(resolved) == "/other/parent/leaf") + } + + @Test func whitespaceOnlyOverridesResolveToNil() { + let resolved = SupacodePaths.resolvedWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: " ", + pathOverride: " ", + branchName: "feature/x" + ) + #expect(resolved == nil) + } + + // MARK: - previewWorktreeDirectory + + @Test func previewFallsBackToDefaultBaseAndBranchWhenEmpty() { + let preview = SupacodePaths.previewWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: "", + pathOverride: "", + branchName: "feature-x" + ) + #expect(path(preview) == "/tmp/base/feature-x") + } + + @Test func previewReflectsOverrides() { + let preview = SupacodePaths.previewWorktreeDirectory( + defaultBaseDirectory: defaultBase, + repositoryRootURL: repoRoot, + nameOverride: "leaf", + pathOverride: "/p", + branchName: "feature-x" + ) + #expect(path(preview) == "/p/leaf") + } +} + +@MainActor +struct WorktreeCreationPromptPlacementTests { + private func makeState( + nameOverride: String = "", + pathOverride: String = "" + ) -> WorktreeCreationPromptFeature.State { + WorktreeCreationPromptFeature.State( + repositoryID: "/tmp/repo", + repositoryRootURL: URL(filePath: "/tmp/repo", directoryHint: .isDirectory), + repositoryName: "repo", + automaticBaseRef: "origin/main", + baseRefOptions: ["origin/main"], + branchName: "feature/x", + selectedBaseRef: nil, + fetchRemote: false, + defaultWorktreeBaseDirectory: "/tmp/base", + worktreeNameOverride: nameOverride, + worktreePathOverride: pathOverride, + validationMessage: nil + ) + } + + @Test func createCarriesPlacementOverridesInSubmit() async { + let store = TestStore(initialState: makeState(nameOverride: "leaf", pathOverride: "/parent")) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + repositoryID: "/tmp/repo", + branchName: "feature/x", + baseRef: nil, + placement: WorktreePlacementOverride(name: "leaf", path: "/parent") + ) + ) + ) + } + + @Test func createWithoutOverridesSubmitsEmptyPlacement() async { + let store = TestStore(initialState: makeState()) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + repositoryID: "/tmp/repo", + branchName: "feature/x", + baseRef: nil, + placement: WorktreePlacementOverride(name: nil, path: nil) + ) + ) + ) + } + + @Test func invalidNameOverrideBlocksSubmit() async { + let store = TestStore(initialState: makeState(nameOverride: "bad/leaf")) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) { + $0.validationMessage = "Worktree name can't contain slashes." + } + // No delegate.submit is emitted. + } +} -- 2.51.2 From 0b00ac62ad03b91b65e847e6238e7e3ca06a9938 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 23:42:29 +0900 Subject: [PATCH 372/528] Show GitHub merge queue state in the PR popover, sidebar, and badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For repos that use GitHub merge queues, a PR waiting to merge previously read as a plain open PR — you couldn't tell it was queued, at what position, or how long was left. - Add `mergeQueueEntry` (position, estimated time, state) to the batch PR GraphQL query (both the single-repo and cross-repo paths) and decode it onto `GithubPullRequest`. - `PullRequestMergeQueueStatus` summarizes queue membership for the UI: a PR counts as queued only when it's open, non-draft, and carries a live entry (a stale entry on a merged / draft PR is ignored). Position renders 1-based; estimate renders "<1 min left" under a minute, "Cannot merge from queue" when unmergeable, etc. - Surfaces: the PR checks popover gains an "In merge queue · Position N · ~X left" row; the sidebar summary shows a brown "Queued" (taking priority over the merge-readiness label); the worktree PR accessory badge tints brown. Ported from upstream supacode #352, adapted to the fork (sidebar uses `WorktreeRow`'s text summary, not upstream's icon system; the popover uses an SF Symbol rather than a bundled asset). The field is requested unconditionally — the GHES < 3.8 "retry without the field" fallback isn't ported (github.com has supported `mergeQueueEntry` since 2023); the toolbar status button keeps its existing color (sidebar/popover/accessory cover the surfaces). --- docs/components/github-pull-requests.md | 4 + supacode/Clients/Github/GithubCLIClient.swift | 10 ++ .../GithubGraphQLPullRequestResponse.swift | 4 +- .../Github/GithubMergeQueueEntry.swift | 12 ++ .../Clients/Github/GithubPullRequest.swift | 4 + .../Github/PullRequestMergeQueueStatus.swift | 69 +++++++++++ .../Views/PullRequestBadgeView.swift | 5 +- .../Views/PullRequestChecksPopoverView.swift | 27 +++++ .../WorktreePullRequestAccessoryView.swift | 4 +- .../Repositories/Views/WorktreeRow.swift | 10 ++ .../PullRequestMergeQueueStatusTests.swift | 107 ++++++++++++++++++ 11 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 supacode/Clients/Github/GithubMergeQueueEntry.swift create mode 100644 supacode/Clients/Github/PullRequestMergeQueueStatus.swift create mode 100644 supacodeTests/PullRequestMergeQueueStatusTests.swift diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index c20d2148..b643bfed 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -23,6 +23,10 @@ itself. expected / skipped) with failing/success counts and per-check detail URLs. - **Merge readiness:** Prowl evaluates blockers in order — merge conflicts, changes requested, failed checks, other non-mergeable states. +- **Merge queue:** for repos that use GitHub merge queues, an open PR waiting in + the queue shows a brown **Queued** state in the sidebar and badges, and the PR + checks popover adds an "In merge queue" row with its position and estimated + time remaining. PR status can surface as a badge on the worktree and as a summary in the command palette. diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index ad036c3a..913aad59 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -537,6 +537,11 @@ nonisolated private func makeCrossRepoBatchQuery( name owner { login } } + mergeQueueEntry { + position + estimatedTimeToMerge + state + } statusCheckRollup { contexts(first: 100) { nodes { @@ -955,6 +960,11 @@ nonisolated private func makeBatchPullRequestsQuery( name owner { login } } + mergeQueueEntry { + position + estimatedTimeToMerge + state + } statusCheckRollup { contexts(first: 100) { nodes { diff --git a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift index 8bd776f1..e2337a91 100644 --- a/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift +++ b/supacode/Clients/Github/GithubGraphQLPullRequestResponse.swift @@ -106,6 +106,7 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { let commits: CommitConnection? let author: PullRequestAuthor? let statusCheckRollup: GithubPullRequestStatusCheckRollup? + let mergeQueueEntry: GithubMergeQueueEntry? let headRepository: HeadRepository? var pullRequest: GithubPullRequest { @@ -125,7 +126,8 @@ nonisolated struct GithubGraphQLPullRequestResponse: Decodable { baseRefName: baseRefName, commitsCount: commits?.totalCount, authorLogin: author?.login, - statusCheckRollup: statusCheckRollup + statusCheckRollup: statusCheckRollup, + mergeQueueEntry: mergeQueueEntry ) } diff --git a/supacode/Clients/Github/GithubMergeQueueEntry.swift b/supacode/Clients/Github/GithubMergeQueueEntry.swift new file mode 100644 index 00000000..25585ac8 --- /dev/null +++ b/supacode/Clients/Github/GithubMergeQueueEntry.swift @@ -0,0 +1,12 @@ +import Foundation + +nonisolated struct GithubMergeQueueEntry: Decodable, Equatable, Hashable { + // GitHub's queue `position` is observed to be 0-based; `displayPosition` renders it 1-based. + let position: Int + let estimatedTimeToMerge: Int? + let state: String? + + var displayPosition: Int { + position + 1 + } +} diff --git a/supacode/Clients/Github/GithubPullRequest.swift b/supacode/Clients/Github/GithubPullRequest.swift index b773ab37..67b9282e 100644 --- a/supacode/Clients/Github/GithubPullRequest.swift +++ b/supacode/Clients/Github/GithubPullRequest.swift @@ -17,4 +17,8 @@ nonisolated struct GithubPullRequest: Decodable, Equatable, Hashable { let commitsCount: Int? let authorLogin: String? let statusCheckRollup: GithubPullRequestStatusCheckRollup? + // `var` (rather than `let`) keeps it decodable and gives the memberwise init an + // implicit `nil` default, so existing construction sites (tests, caches) compile + // unchanged; only the GraphQL decode path populates it. + var mergeQueueEntry: GithubMergeQueueEntry? } diff --git a/supacode/Clients/Github/PullRequestMergeQueueStatus.swift b/supacode/Clients/Github/PullRequestMergeQueueStatus.swift new file mode 100644 index 00000000..7879323a --- /dev/null +++ b/supacode/Clients/Github/PullRequestMergeQueueStatus.swift @@ -0,0 +1,69 @@ +import Foundation + +nonisolated struct PullRequestMergeQueueStatus: Equatable, Hashable { + enum State: Equatable, Hashable { + case queued + case awaitingChecks + case mergeable + case unmergeable + case locked + case unknown + + init(rawValue: String?) { + switch rawValue?.uppercased() { + case "QUEUED": self = .queued + case "AWAITING_CHECKS": self = .awaitingChecks + case "MERGEABLE": self = .mergeable + case "UNMERGEABLE": self = .unmergeable + case "LOCKED": self = .locked + default: self = .unknown + } + } + } + + let position: Int + let estimatedTimeToMerge: Int? + let state: State + + // Only an open, non-draft PR with a live queue entry is in the merge queue, so a stale entry + // on a merged / draft PR never surfaces as queued across the sidebar and popover. + init?(pullRequest: GithubPullRequest) { + guard pullRequest.state.uppercased() == "OPEN", + !pullRequest.isDraft, + let entry = pullRequest.mergeQueueEntry + else { return nil } + self.position = entry.displayPosition + self.estimatedTimeToMerge = entry.estimatedTimeToMerge + self.state = State(rawValue: entry.state) + } + + var summary: String { + switch state { + case .awaitingChecks: + return "Awaiting checks in merge queue" + case .unmergeable: + return "Cannot merge from queue" + case .locked: + return "Merge queue locked" + case .queued, .mergeable, .unknown: + return "In merge queue" + } + } + + var positionLabel: String { + "Position \(position)" + } + + var estimatedTimeLabel: String? { + guard let estimatedTimeToMerge, estimatedTimeToMerge > 0 else { return nil } + guard estimatedTimeToMerge >= 60 else { return "<1 min left" } + let formatted = Duration.seconds(estimatedTimeToMerge) + .formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated, maximumUnitCount: 2)) + return "~\(formatted) left" + } + + var detail: String? { + let parts = [positionLabel, estimatedTimeLabel].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} diff --git a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift index f9073b71..db1821ee 100644 --- a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift +++ b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift @@ -3,8 +3,9 @@ import SwiftUI enum PullRequestBadgeStyle { static let mergedColor = Color.purple static let openColor = Color.green + static let queuedColor = Color.brown - static func style(state: String?, number: Int?) -> (text: String, color: Color)? { + static func style(state: String?, number: Int?, isQueued: Bool = false) -> (text: String, color: Color)? { guard let state = state?.uppercased() else { return nil } @@ -12,7 +13,7 @@ enum PullRequestBadgeStyle { case "MERGED": return (text: number.map { "#\($0)" } ?? "MERGED", color: mergedColor) case "OPEN": - return (text: number.map { "#\($0)" } ?? "OPEN", color: openColor) + return (text: number.map { "#\($0)" } ?? "OPEN", color: isQueued ? queuedColor : openColor) default: return nil } diff --git a/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift b/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift index 374324b7..49c39e4a 100644 --- a/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift +++ b/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift @@ -84,6 +84,10 @@ struct PullRequestChecksPopoverView: View { } .font(.subheadline) + if let mergeQueueStatus = PullRequestMergeQueueStatus(pullRequest: pullRequest) { + PullRequestMergeQueueRow(status: mergeQueueStatus) + } + if breakdown.total > 0 { HStack { PullRequestChecksRingView(breakdown: breakdown) @@ -131,6 +135,29 @@ struct PullRequestChecksPopoverView: View { .frame(minWidth: 260, maxWidth: 840, maxHeight: 720) } + private struct PullRequestMergeQueueRow: View { + let status: PullRequestMergeQueueStatus + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.merge") + .foregroundStyle(.brown) + .accessibilityHidden(true) + Text(status.summary) + .foregroundStyle(.brown) + } + .font(.subheadline) + if let detail = status.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + } + } + private static func sortRank(for state: GithubPullRequestCheckState) -> Int { switch state { case .failure: diff --git a/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift b/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift index d687ecf0..ba68269d 100644 --- a/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift +++ b/supacode/Features/Repositories/Views/WorktreePullRequestAccessoryView.swift @@ -15,11 +15,13 @@ struct WorktreePullRequestDisplay { let displayPullRequest = matchesWorktree ? pullRequest : nil let pullRequestState = displayPullRequest?.state.uppercased() let pullRequestNumber = displayPullRequest?.number + let isQueued = displayPullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil self.pullRequest = displayPullRequest self.pullRequestState = pullRequestState self.pullRequestBadgeStyle = PullRequestBadgeStyle.style( state: pullRequestState, - number: pullRequestNumber + number: pullRequestNumber, + isQueued: isQueued ) } } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 4e9fb6b8..df0dcb7d 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -35,6 +35,7 @@ struct WorktreeRow: View { let displayAddedLines = info?.addedLines let displayRemovedLines = info?.removedLines let mergeReadiness = pullRequestMergeReadiness(for: display.pullRequest) + let isQueued = display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil let hasChangeCounts = displayAddedLines != nil && displayRemovedLines != nil let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil let nameColor = colorScheme == .dark ? Color.white : Color.primary @@ -129,6 +130,7 @@ struct WorktreeRow: View { pullRequestNumber: display.pullRequest?.number, pullRequestState: display.pullRequestState, mergeReadiness: mergeReadiness, + isQueued: isQueued, shortcutHint: shortcutHint, showsShortcutHint: showsShortcutHint ) @@ -179,6 +181,7 @@ private struct WorktreeRowInfoView: View { let pullRequestNumber: Int? let pullRequestState: String? let mergeReadiness: PullRequestMergeReadiness? + let isQueued: Bool let shortcutHint: String? let showsShortcutHint: Bool @@ -225,6 +228,13 @@ private struct WorktreeRowInfoView: View { var segment = AttributedString("Merged") segment.foregroundColor = PullRequestBadgeStyle.mergedColor result.append(segment) + } else if isQueued { + // A queued PR is mid-merge, so the queue state takes priority over the + // merge-readiness label. + appendSeparator() + var segment = AttributedString("Queued") + segment.foregroundColor = PullRequestBadgeStyle.queuedColor + result.append(segment) } else if let mergeReadiness { appendSeparator() var segment = AttributedString(mergeReadiness.label) diff --git a/supacodeTests/PullRequestMergeQueueStatusTests.swift b/supacodeTests/PullRequestMergeQueueStatusTests.swift new file mode 100644 index 00000000..0f304f22 --- /dev/null +++ b/supacodeTests/PullRequestMergeQueueStatusTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import supacode + +struct PullRequestMergeQueueStatusTests { + private func makePullRequest( + state: String = "OPEN", + isDraft: Bool = false, + mergeQueueEntry: GithubMergeQueueEntry? = nil + ) -> GithubPullRequest { + GithubPullRequest( + number: 1, + title: "PR", + state: state, + additions: 0, + deletions: 0, + isDraft: isDraft, + reviewDecision: nil, + mergeable: nil, + mergeStateStatus: nil, + updatedAt: nil, + url: "https://example.com/pr/1", + headRefName: "feature", + baseRefName: "main", + commitsCount: nil, + authorLogin: nil, + statusCheckRollup: nil, + mergeQueueEntry: mergeQueueEntry + ) + } + + private func entry(position: Int = 0, estimate: Int? = nil, state: String? = nil) -> GithubMergeQueueEntry { + GithubMergeQueueEntry(position: position, estimatedTimeToMerge: estimate, state: state) + } + + // MARK: - Membership + + @Test func nilWhenNoEntry() { + #expect(PullRequestMergeQueueStatus(pullRequest: makePullRequest()) == nil) + } + + @Test func nilWhenNotOpenEvenWithEntry() { + let pullRequest = makePullRequest(state: "MERGED", mergeQueueEntry: entry()) + #expect(PullRequestMergeQueueStatus(pullRequest: pullRequest) == nil) + } + + @Test func nilWhenDraftEvenWithEntry() { + let pullRequest = makePullRequest(isDraft: true, mergeQueueEntry: entry()) + #expect(PullRequestMergeQueueStatus(pullRequest: pullRequest) == nil) + } + + @Test func presentForOpenNonDraftWithEntry() { + let pullRequest = makePullRequest(mergeQueueEntry: entry(position: 2, estimate: 120, state: "QUEUED")) + let status = PullRequestMergeQueueStatus(pullRequest: pullRequest) + #expect(status != nil) + // position is rendered 1-based. + #expect(status?.position == 3) + #expect(status?.state == .queued) + } + + // MARK: - Summary + + @Test func summaryByState() { + func summary(_ state: String?) -> String? { + PullRequestMergeQueueStatus(pullRequest: makePullRequest(mergeQueueEntry: entry(state: state)))?.summary + } + #expect(summary("QUEUED") == "In merge queue") + #expect(summary("MERGEABLE") == "In merge queue") + #expect(summary(nil) == "In merge queue") + #expect(summary("AWAITING_CHECKS") == "Awaiting checks in merge queue") + #expect(summary("UNMERGEABLE") == "Cannot merge from queue") + #expect(summary("LOCKED") == "Merge queue locked") + } + + // MARK: - Labels + + @Test func positionLabelIsOneBased() { + let status = PullRequestMergeQueueStatus(pullRequest: makePullRequest(mergeQueueEntry: entry(position: 0))) + #expect(status?.positionLabel == "Position 1") + } + + @Test func estimatedTimeLabelEdges() { + func label(_ estimate: Int?) -> String? { + PullRequestMergeQueueStatus(pullRequest: makePullRequest(mergeQueueEntry: entry(estimate: estimate)))? + .estimatedTimeLabel + } + #expect(label(nil) == nil) + #expect(label(0) == nil) + #expect(label(30) == "<1 min left") + #expect(label(120)?.hasPrefix("~") == true) + #expect(label(120)?.hasSuffix("left") == true) + } + + @Test func detailJoinsPositionAndTime() { + let withTime = PullRequestMergeQueueStatus( + pullRequest: makePullRequest(mergeQueueEntry: entry(position: 1, estimate: 120)) + ) + #expect(withTime?.detail?.contains("Position 2") == true) + #expect(withTime?.detail?.contains("·") == true) + + let withoutTime = PullRequestMergeQueueStatus( + pullRequest: makePullRequest(mergeQueueEntry: entry(position: 1, estimate: nil)) + ) + #expect(withoutTime?.detail == "Position 2") + } +} -- 2.51.2 From 7e15146194ac782c0a1e5343cf925d89ab2cb94e Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 8 Jun 2026 23:57:01 +0900 Subject: [PATCH 373/528] Refactor worktree stream request API --- supacode/Clients/Git/GitClient.swift | 37 ++++++------ supacode/Clients/Git/GitClientTypes.swift | 30 ++++++++++ .../Repositories/GitClientDependency.swift | 22 +------ ...RepositoriesFeature+WorktreeCreation.swift | 15 ++--- .../GitClientCreateWorktreeStreamTests.swift | 60 +++++++++++-------- supacodeTests/RepositoriesFeatureTests.swift | 28 ++++----- 6 files changed, 107 insertions(+), 85 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index f7ec223c..98b735e6 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -209,11 +209,13 @@ struct GitClient { ) async throws -> Worktree { var createdWorktree: Worktree? for try await event in createWorktreeStream( - named: name, - in: repoRoot, - baseDirectory: baseDirectory, - copyFiles: copyFiles, - baseRef: baseRef + GitWorktreeCreateRequest( + name: name, + repoRoot: repoRoot, + baseDirectory: baseDirectory, + copyFiles: GitWorktreeCreateRequest.CopyFiles(ignored: copyFiles.ignored, untracked: copyFiles.untracked), + baseRef: baseRef + ) ) { if case .finished(let worktree) = event { createdWorktree = worktree @@ -236,25 +238,20 @@ struct GitClient { } nonisolated func createWorktreeStream( - named name: String, - in repoRoot: URL, - baseDirectory: URL, - copyFiles: (ignored: Bool, untracked: Bool), - baseRef: String, - directoryOverride: URL? = nil + _ request: GitWorktreeCreateRequest ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { - let repositoryRootURL = repoRoot.standardizedFileURL + let repositoryRootURL = request.repoRoot.standardizedFileURL do { let wtURL = try wtScriptURL() let arguments = createWorktreeArguments( - baseDirectory: baseDirectory, - name: name, - copyIgnored: copyFiles.ignored, - copyUntracked: copyFiles.untracked, - baseRef: baseRef, - directoryOverride: directoryOverride + baseDirectory: request.baseDirectory, + name: request.name, + copyIgnored: request.copyFiles.ignored, + copyUntracked: request.copyFiles.untracked, + baseRef: request.baseRef, + directoryOverride: request.directoryOverride ) let envURL = URL(fileURLWithPath: "/usr/bin/env") let localeArguments = ["LANG=C", "LC_ALL=C", "LC_MESSAGES=C"] @@ -265,7 +262,7 @@ struct GitClient { for try await streamEvent in shell.runLoginStream( envURL, invocationArguments, - repoRoot + request.repoRoot ) { switch streamEvent { case .line(let line): @@ -292,7 +289,7 @@ struct GitClient { let createdAt = resourceValues?.creationDate ?? resourceValues?.contentModificationDate let worktree = Worktree( id: id, - name: name, + name: request.name, detail: detail, workingDirectory: worktreeURL, repositoryRootURL: repositoryRootURL, diff --git a/supacode/Clients/Git/GitClientTypes.swift b/supacode/Clients/Git/GitClientTypes.swift index d8b629c6..d016f3a2 100644 --- a/supacode/Clients/Git/GitClientTypes.swift +++ b/supacode/Clients/Git/GitClientTypes.swift @@ -39,6 +39,36 @@ enum GitClientError: LocalizedError { } } +nonisolated struct GitWorktreeCreateRequest: Equatable, Sendable { + struct CopyFiles: Equatable, Sendable { + var ignored: Bool + var untracked: Bool + } + + var name: String + var repoRoot: URL + var baseDirectory: URL + var copyFiles: CopyFiles + var baseRef: String + var directoryOverride: URL? + + init( + name: String, + repoRoot: URL, + baseDirectory: URL, + copyFiles: CopyFiles, + baseRef: String, + directoryOverride: URL? = nil + ) { + self.name = name + self.repoRoot = repoRoot + self.baseDirectory = baseDirectory + self.copyFiles = copyFiles + self.baseRef = baseRef + self.directoryOverride = directoryOverride + } +} + enum GitWorktreeCreateEvent: Equatable, Sendable { case outputLine(ShellStreamLine) case finished(Worktree) diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index 0d8b8584..e52ab0ff 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -22,16 +22,7 @@ struct GitClientDependency: Sendable { _ baseRef: String ) async throws -> Worktree - var createWorktreeStream: - @Sendable ( - _ name: String, - _ repoRoot: URL, - _ baseDirectory: URL, - _ copyIgnored: Bool, - _ copyUntracked: Bool, - _ baseRef: String, - _ directoryOverride: URL? - ) -> AsyncThrowingStream + var createWorktreeStream: @Sendable (GitWorktreeCreateRequest) -> AsyncThrowingStream var removeWorktree: @Sendable (_ worktree: Worktree, _ deleteBranch: Bool) async throws -> URL var deleteLocalBranch: @Sendable (_ branchName: String, _ repoRoot: URL, _ force: Bool) async throws @@ -69,15 +60,8 @@ extension GitClientDependency: DependencyKey { baseRef: baseRef ) }, - createWorktreeStream: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef, directoryOverride in - GitClient().createWorktreeStream( - named: name, - in: repoRoot, - baseDirectory: baseDirectory, - copyFiles: (ignored: copyIgnored, untracked: copyUntracked), - baseRef: baseRef, - directoryOverride: directoryOverride - ) + createWorktreeStream: { request in + GitClient().createWorktreeStream(request) }, removeWorktree: { worktree, deleteBranch in try await GitClient().removeWorktree(worktree, deleteBranch: deleteBranch) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift index bf4df3b4..b3eca70d 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift @@ -487,13 +487,14 @@ extension RepositoriesFeature { ) ) let stream = createWorktreeStream( - name, - repository.rootURL, - worktreeBaseDirectory, - copyIgnored, - copyUntracked, - resolvedBaseRef, - directoryOverride + GitWorktreeCreateRequest( + name: name, + repoRoot: repository.rootURL, + baseDirectory: worktreeBaseDirectory, + copyFiles: GitWorktreeCreateRequest.CopyFiles(ignored: copyIgnored, untracked: copyUntracked), + baseRef: resolvedBaseRef, + directoryOverride: directoryOverride + ) ) for try await event in stream { switch event { diff --git a/supacodeTests/GitClientCreateWorktreeStreamTests.swift b/supacodeTests/GitClientCreateWorktreeStreamTests.swift index c5fa5880..73923cea 100644 --- a/supacodeTests/GitClientCreateWorktreeStreamTests.swift +++ b/supacodeTests/GitClientCreateWorktreeStreamTests.swift @@ -36,6 +36,27 @@ nonisolated final class GitShellInvocationRecorder: @unchecked Sendable { } struct GitClientCreateWorktreeStreamTests { + private func makeRequest( + name: String = "new-wt", + repoRoot: URL, + baseDirectory: URL? = nil, + copyFiles: GitWorktreeCreateRequest.CopyFiles = GitWorktreeCreateRequest.CopyFiles( + ignored: false, + untracked: false + ), + baseRef: String = "", + directoryOverride: URL? = nil + ) -> GitWorktreeCreateRequest { + GitWorktreeCreateRequest( + name: name, + repoRoot: repoRoot, + baseDirectory: baseDirectory ?? URL(fileURLWithPath: "/tmp/repo/.worktrees"), + copyFiles: copyFiles, + baseRef: baseRef, + directoryOverride: directoryOverride + ) + } + @Test func createWorktreeStreamAddsVerboseWhenCopyingFiles() async throws { let recorder = GitShellInvocationRecorder() let shell = ShellClient( @@ -64,11 +85,12 @@ struct GitClientCreateWorktreeStreamTests { let repoRoot = URL(fileURLWithPath: "/tmp/repo") for try await _ in client.createWorktreeStream( - named: "swift-otter", - in: repoRoot, - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), - copyFiles: (ignored: true, untracked: false), - baseRef: "origin/main" + makeRequest( + name: "swift-otter", + repoRoot: repoRoot, + copyFiles: GitWorktreeCreateRequest.CopyFiles(ignored: true, untracked: false), + baseRef: "origin/main" + ) ) {} let snapshot = recorder.snapshot() @@ -112,11 +134,11 @@ struct GitClientCreateWorktreeStreamTests { var outputLines: [ShellStreamLine] = [] var finishedWorktree: Worktree? for try await event in client.createWorktreeStream( - named: "swift-otter", - in: repoRoot, - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), - copyFiles: (ignored: true, untracked: true), - baseRef: "" + makeRequest( + name: "swift-otter", + repoRoot: repoRoot, + copyFiles: GitWorktreeCreateRequest.CopyFiles(ignored: true, untracked: true) + ) ) { switch event { case .outputLine(let line): @@ -157,11 +179,7 @@ struct GitClientCreateWorktreeStreamTests { let repoRoot = URL(fileURLWithPath: "/tmp/repo") var finishedWorktree: Worktree? for try await event in client.createWorktreeStream( - named: "new-wt", - in: repoRoot, - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), - copyFiles: (ignored: false, untracked: false), - baseRef: "" + makeRequest(repoRoot: repoRoot) ) { if case .finished(let worktree) = event { finishedWorktree = worktree @@ -188,11 +206,7 @@ struct GitClientCreateWorktreeStreamTests { var outputLines: [ShellStreamLine] = [] var finishedWorktree: Worktree? for try await event in client.createWorktreeStream( - named: "new-wt", - in: repoRoot, - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), - copyFiles: (ignored: false, untracked: false), - baseRef: "" + makeRequest(repoRoot: repoRoot) ) { switch event { case .outputLine(let line): @@ -230,11 +244,7 @@ struct GitClientCreateWorktreeStreamTests { do { for try await _ in client.createWorktreeStream( - named: "new-wt", - in: repoRoot, - baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), - copyFiles: (ignored: false, untracked: false), - baseRef: "" + makeRequest(repoRoot: repoRoot) ) {} Issue.record("Expected createWorktreeStream to throw when stdout path is missing") } catch let error as GitClientError { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 71afe289..80643766 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1916,7 +1916,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) @@ -1964,7 +1964,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in events.withValue { $0.append("create") } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) @@ -2015,7 +2015,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2060,7 +2060,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2105,7 +2105,7 @@ struct RepositoriesFeatureTests { } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2157,8 +2157,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _, _ in - observedBaseDirectory.withValue { $0 = baseDirectory } + $0.gitClient.createWorktreeStream = { request in + observedBaseDirectory.withValue { $0 = request.baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2209,8 +2209,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _, _ in - observedBaseDirectory.withValue { $0 = baseDirectory } + $0.gitClient.createWorktreeStream = { request in + observedBaseDirectory.withValue { $0 = request.baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2262,8 +2262,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _, _ in - observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } + $0.gitClient.createWorktreeStream = { request in + observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2311,8 +2311,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } - $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _, _ in - observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } + $0.gitClient.createWorktreeStream = { request in + observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -2348,7 +2348,7 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } - $0.gitClient.createWorktreeStream = { _, _, _, _, _, _, _ in + $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) -- 2.51.2 From 2948302ef96b03c17be1ff929e3753743ac39e7a Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 01:08:35 +0900 Subject: [PATCH 374/528] docs(change-list): advance upstream baseline to post-v0.10.2 Record the upstream alignment round: baseline moves from 5e88ec5d (post-v0.8.5) to 1d888dbc (post-v0.10.2, 2026-06-05). Add a dated review entry with the ported/skipped breakdown and a full 49-commit inventory for reference so the next check only diffs against the new baseline. --- doc-onevcat/change-list.md | 120 ++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/doc-onevcat/change-list.md b/doc-onevcat/change-list.md index a7210e25..35b946a8 100644 --- a/doc-onevcat/change-list.md +++ b/doc-onevcat/change-list.md @@ -4,15 +4,129 @@ | Key | Value | | --- | --- | -| Commit | `5e88ec5dfc74af165781e8d5a41505867b23dc06` | -| Tag | post-v0.8.5 | -| Date | 2026-05-05 | +| Commit | `1d888dbc30f564ed9e8ed26b55c13da961b0887f` | +| Tag | post-v0.10.2 | +| Date | 2026-06-05 | All upstream changes up to and including this commit have been reviewed. Future upstream checks should only inspect commits **after** this baseline. --- +## 2026-06-09 — Review through post-v0.10.2 + +### Upstream changes reviewed + +Reviewed 55 commits on `supabitapp/supacode` from `5e88ec5d` (post-v0.8.5, 2026-05-05) through +`1d888dbc` (post-v0.10.2, 2026-06-05), spanning upstream releases v0.9.0 → v0.10.2. The fork is now +aligned to this tip; the next `/check-upstream-changes` run only needs to diff against `1d888dbc`. + +### Ported into the fork + +Equivalent behavior landed via dedicated fork PRs (fork implementations may differ to fit Prowl's +architecture): + +| Upstream | Fork | +| --- | --- | +| `b1b65bf7` #378 — Tolerate login-shell noise in `gh` JSON output | PR #418 (`24027a91`) | +| `b1ecdf3d` #376 — Cap and coalesce terminal event streams | PR #416 (`2800fd0b`) | +| `974455b1` #347 — Coalesce OSC-9 progress to cut tab-bar lag | PR #415 (`7e0c9c76`) | +| `c0c1c2ac` #332 — Per-surface `@Observable` notification dot | PR #417 (`6fab2d28`) | +| `955c1943` #329 / `be322039` #336 — Detail/menu-bar + split-tree perf (FocusedAction wrapper, drop AnyView erasure) | PR #414 (`c9f5100e`, `db2f39d0`) | +| `65c87e30` #371 — Focus the terminal after worktree navigation | PR #419 (`e02c76f0`) | +| `fc4e4b0b` #353 — Selected command-palette row legibility | PR #420 (`3b499662`) | +| `66b300d1` #313 — Persist main window position and size | (`02e13192`, PR #420) | +| `3073482c` #352 — GitHub merge-queue state in PR popover/sidebar | PR #425 (`0b00ac62`) | +| `4abbe946` #351 — Override new worktree name and parent directory | PR #424 (`2acb948b`) | +| `813f44e3` #344 — Refresh hyperlink highlight on modifier press | PR #423 (`da8cfb39`) | +| `3ccd25a3` #310 — Sheet dismiss flash via TCA view-side APIs | PR #422 (`5f5393e2`) | + +### Reviewed and skipped (architectural / not applicable) + +- **zmx terminal-session persistence track** (`90b61140` #334, `4d50d48d` #360, `96392760` #368, + `a536175f` #369, `f645bbf7` #356/#357, `b1e275a6` #361) — upstream moved terminal persistence onto a + bundled `zmx` multiplexer. The fork keeps its own terminal-layout persistence and has no `zmx` + dependency, so the whole track is skipped. +- **Hook-driven coding-agent integration** (`b47fee03` #307, `dfc5d6f6` #311, `7e3ddb98` #374 Kiro, + `ac5d84c6` #317, `6fdfb3c8` #330) — relies on upstream's settings/hook modules that this fork does + not carry. +- **Naming / appearance already diverged** (`a4a4457d` #312 capitalize "Supacode" → fork rebrands to + Prowl; `4d07b0a5` #308 / `563e6913` #367 per-worktree title+color → fork uses its richer repo-level + appearance model, consistent with the earlier #276 decision; `662b6eee` #321 terminal theme, + `98b4cf1b` #320 repo·branch·worktree title, `c7891f9c` #305 custom-script color picker, `e6fd77ee` + #358 Rename Branch → fork already ships equivalents). +- **Release housekeeping** — `bump v0.9.0`…`v0.10.2` tags do not apply (date-based fork releases). + +### Not yet ported — re-evaluate next round + +Reviewed but not carried over this round; candidates for future sync depending on need: +`1d888dbc` #381 (cross-fade shortcut hints), `4be71a58` #350 (worktree base-ref picker), +`0f02f7cb` #349 (stop sidebar shimmer when idle), `b69ce38e` #346 (re-surface archived worktrees +during delete), `b1b4c4f4` #345 (focus tab surface on tab-bar click), `9959f956` #340 (terminal flash +/ stuck pending worktrees), `7f1b2bb7` #339 (lock worktrees against prune), `19491fe1` #337 +(split-zoom indicator), `861d70b7` #333 (freeze blocking-script tabs), `28e47c04` #331 (repo tag +truncation priority), `0b66caf5` #328 (Pinned/Active sidebar sections), `0a2548ca` #324 (nest sidebar +by branch + onboarding), `0a1ed578` #323 (sidebar perf/refresh), `9b62f0d5` #322 (folder/disabled-slot +hotkeys), `54cda551` #318 (split terminal File menu), `288d2f3f` #301 (window-layer terminal tint), +`31804471` #289/#306 (configurable Window-menu shortcut), `7700b841` #314 (`.inMemory` UserDefaults in +tests). + +### Full upstream inventory (post-v0.8.5 → post-v0.10.2) + +Flat list of all 49 non-release commits in range, for cross-reference: + +- `1d888dbc` Cross-fade shortcut hints and drop the hold-to-reveal delay (#381) +- `b1b65bf7` Tolerate login-shell noise in GitHub CLI JSON output (#378) +- `b1ecdf3d` Cap and coalesce terminal event streams to reduce memory growth (#376) +- `7e3ddb98` Fix Kiro CLI version detection to use kiro-cli instead of kiro (#374) +- `96392760` Run surfaces under zmx via a Ghostty command-wrapper so shells keep full integration (#368) +- `65c87e30` Always focus the terminal after worktree navigation (#371) +- `a536175f` Persist terminal layouts incrementally and reap zmx sessions by attach state (#369) +- `563e6913` Collapse the new-worktree appearance section and mirror the title placeholder (#367) +- `4d07b0a5` Add per-worktree title and color customization (#308) +- `b1e275a6` Hydrate the Active sidebar section from persisted layouts on relaunch (#361) +- `4d50d48d` Build zmx as a universal macOS binary so Intel Macs can launch terminals (#360) +- `e6fd77ee` Add a Rename Branch sheet to the sidebar context menu and command palette (#358) +- `f645bbf7` Disable Ghostty auto shell-integration to fix terminal launch crash (#356) (#357) +- `fc4e4b0b` Improve the readability of the selected command palette row (#353) +- `3073482c` Show GitHub merge queue state in the PR popover and sidebar (#352) +- `4abbe946` Let users override the new worktree's name and parent directory (#351) +- `4be71a58` Let users pick the worktree base ref from local and remote branches (#350) +- `0f02f7cb` Stop the sidebar shimmer when an agent is awaiting input or idle (#349) +- `974455b1` Fix tab-bar progress lag and cut animation/re-render cost during agent activity (#347) +- `b69ce38e` Re-surface archived worktrees in the sidebar while their delete script runs (#346) +- `b1b4c4f4` Focus tab's focused surface on tab bar click (#345) +- `813f44e3` Refresh hyperlink highlight on modifier press without mouse move (#344) +- `90b61140` Persist terminal sessions across app launches with bundled zmx (#334) +- `9959f956` Fix terminal flash and stuck pending worktrees on sidebar selection (#340) +- `7f1b2bb7` Lock Supacode worktrees so prune can't drop them (#339) +- `19491fe1` Add split-zoom indicator button to tab bar (#337) +- `be322039` Observe trees dict so split-tree view re-renders on structural changes (#336) +- `861d70b7` Freeze blocking-script tabs after completion (#333) +- `c0c1c2ac` Move surface notification dot to per-surface @Observable state (#332) +- `28e47c04` Give repo tag truncation priority over trail in sidebar highlight subtitle (#331) +- `6fdfb3c8` Drop `ghostty +list-themes` reference from terminal theme toggle (#330) +- `955c1943` Detail-view + menu-bar performance: per-tab observation, snapshot caches, FocusedAction wrapper (#329) +- `0b66caf5` Highlight relevant sidebar rows with Pinned / Active sections (#328) +- `0a2548ca` Nest sidebar worktrees by branch with onboarding card (#324) +- `0a1ed578` Improve sidebar performance and refresh reliability (#323) +- `9b62f0d5` Fix worktree-selection hotkeys for folders and disabled slots (#322) +- `662b6eee` Add Supacode Terminal Theme toggle with glass background (#321) +- `98b4cf1b` Replace toolbar branch button with repo · branch · worktree title (#320) +- `54cda551` Add split terminal File menu, drop hover popover, mimic Ghostty's terminal commands (#318) +- `ac5d84c6` Fix `supacode settings repo` and add a per-repo Scripts subsection (#317) +- `7700b841` Use `.inMemory` UserDefaults in SidebarPersistenceMigratorTests (#314) +- `66b300d1` Persist window position and size across sessions (#313) +- `a4a4457d` Capitalize app name to Supacode in user-facing strings (#312) +- `dfc5d6f6` Auto-update agent integrations and collapse hooks to one per slot (#311) +- `3ccd25a3` Fix sheet dismiss flash by migrating to TCA 1.7 view-side APIs (#310) +- `b47fee03` Add hook-driven coding-agent presence + sidebar setup card (#307) +- `31804471` Make the Window-menu Supacode entry shortcut configurable (#289) (#306) +- `c7891f9c` Add global scripts and color picker for custom scripts (#305) +- `288d2f3f` Paint terminal tint at the window layer; keep chrome transparent (#301) + +--- + ## 2026-05-09 — Ghostty fork patch - Created `onevcat/ghostty` fork branch `release/v1.3.1-patched` from upstream tag `v1.3.1`. -- 2.51.2 From ed806b92214a525ba9950f03722f6022938ab4d2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 08:56:23 +0900 Subject: [PATCH 375/528] Sync docs for 2026.6.9 --- docs/.sync-meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 1e60e100..412259e1 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "42180405d77d03d288b182465d286f73c810be16", - "last_synced_date": "2026-06-07", - "note": "Added 'Ask Agent About Prowl' in-app help (Help menu + sidebar footer) that copies a localized prompt pointing the user's agent at the bundled docs; documented it in overview.md's 'How a human typically uses Prowl with this doc set' section. Other changes in range (SupacodePaths bundled-docs path, app wiring) are internal." + "last_synced_commit": "2948302ef96b03c17be1ff929e3753743ac39e7a", + "last_synced_date": "2026-06-09", + "note": "No doc edits needed: the two user-facing features in range — GitHub merge-queue status in the PR popover/sidebar/badges (#425) and per-worktree custom name/parent-folder override (#424) — were already documented in their feature PRs (github-pull-requests.md merge-queue note, repositories-and-worktrees.md Advanced section), and verified against PullRequestMergeQueueStatus.swift. Remaining changes are perf/polish/bug-fixes (event coalescing, sheet/palette flicker fixes, window-position persistence, focus-after-nav) that don't change documented behavior. Baseline bump only." } -- 2.51.2 From 565e0211c5ac60811bc94a8e832de700cadb1972 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 08:59:52 +0900 Subject: [PATCH 376/528] Label the worktree creation Advanced fields --- .../Views/WorktreeCreationPromptView.swift | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 03cf56a1..8fdac6e8 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -46,19 +46,30 @@ struct WorktreeCreationPromptView: View { } DisclosureGroup("Advanced", isExpanded: $store.showAdvancedOptions) { - VStack(alignment: .leading, spacing: 8) { - TextField( - "Worktree name", - text: $store.worktreeNameOverride, - prompt: Text(store.worktreeNamePlaceholder) - ) - .textFieldStyle(.roundedBorder) - TextField( - "Parent folder", - text: $store.worktreePathOverride, - prompt: Text(store.defaultWorktreeBaseDirectory) - ) - .textFieldStyle(.roundedBorder) + VStack(alignment: .leading, spacing: 12) { + Text("Override where the new worktree folder is created. Leave a field blank to use its default.") + .font(.footnote) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + Text("Worktree name") + .foregroundStyle(.secondary) + TextField( + "Worktree name", + text: $store.worktreeNameOverride, + prompt: Text(store.worktreeNamePlaceholder) + ) + .textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 8) { + Text("Parent folder") + .foregroundStyle(.secondary) + TextField( + "Parent folder", + text: $store.worktreePathOverride, + prompt: Text(store.defaultWorktreeBaseDirectory) + ) + .textFieldStyle(.roundedBorder) + } } .padding(.top, 4) } -- 2.51.2 From c0f3f20e09b5ebc5edb333a5a9ab1b1ebf938589 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 09:12:17 +0900 Subject: [PATCH 377/528] bump v2026.6.9 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 09752471..6dfc4b10 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260607; + CURRENT_PROJECT_VERSION = 20260609; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.7; + MARKETING_VERSION = 2026.6.9; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260607; + CURRENT_PROJECT_VERSION = 20260609; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.7; + MARKETING_VERSION = 2026.6.9; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260607; + CURRENT_PROJECT_VERSION = 20260609; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.7; + MARKETING_VERSION = 2026.6.9; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260607; + CURRENT_PROJECT_VERSION = 20260609; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.7; + MARKETING_VERSION = 2026.6.9; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 349e9d88..1b7d30a8 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.7" + public static let current = "2026.6.9" } -- 2.51.2 From daf8bab913c5a5eaa8d502a81b58fcb97b0c53d0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 09:12:18 +0900 Subject: [PATCH 378/528] Update CHANGELOG for 2026.6.9 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d47c857..6546f9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [2026.6.9](https://github.com/onevcat/Prowl/releases/tag/v2026.6.9) + +### New + +- The New Worktree dialog now has an **Advanced** section where you can override the worktree's folder name and parent directory for that creation. Both fields are optional; leaving them blank keeps the default `/` placement. +- PRs waiting in a GitHub merge queue now appear as **Queued** (brown label) in the worktree sidebar, with position and estimated time shown in the PR checks popover and a tinted badge. + +### Improved + +- App menus no longer flicker or collapse while agents are actively streaming output. Progress bar updates are coalesced to reduce CPU usage, and the terminal event buffer is capped to keep memory stable across long multi-agent sessions. + +### Fixed + +- The terminal now receives keyboard focus automatically after navigating with Select Next/Previous Worktree or Back/Forward in Worktree History. +- GitHub CLI output is now parsed correctly when your login shell (`.zprofile`, `.zlogin`) prints a banner before the JSON — previously this caused a "data couldn't be read" error in GitHub settings. +- The main window now restores its position and size across launches. +- Command palette: the selected row is legible in light mode, and opening the palette in light mode no longer briefly flashes dark. +- The New Worktree dialog no longer flashes empty content while sliding closed. +- Holding Cmd over a hyperlink in a non-focused terminal split now highlights the link immediately, without requiring a mouse move. +- Find Next and Find Previous in the terminal menu now display their keyboard shortcut correctly. + ## [2026.6.7](https://github.com/onevcat/Prowl/releases/tag/v2026.6.7) This release focuses on Canvas improvements, CLI automation additions, and a new in-app help feature for agent-assisted onboarding. -- 2.51.2 From 4c0614d7024b3dc7e10b392c10a730b77a51e79d Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 18:17:57 +0900 Subject: [PATCH 379/528] Narrow windowless stall diagnostics --- supacode/App/WindowSurfacing.swift | 44 +++++++++++++++++++++--- supacodeTests/WindowSurfacingTests.swift | 22 ++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index 4d6644a4..fd75f31a 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -54,26 +54,30 @@ enum MainWindowSurface { let isVisible: Bool } + static func snapshots(in windows: [NSWindow]) -> [Snapshot] { + windows.map(snapshot(for:)) + } + static func mainWindowCandidate(in windows: [NSWindow]) -> NSWindow? { - let snapshots = windows.map(snapshot(for:)) + let snapshots = snapshots(in: windows) guard let index = mainWindowIndex(in: snapshots) else { return nil } return windows[index] } static func hasVisibleMainWindow(in windows: [NSWindow]) -> Bool { - hasVisibleMainWindow(in: windows.map(snapshot(for:))) + hasVisibleMainWindow(in: snapshots(in: windows)) } static func mainWindowCount(in windows: [NSWindow]) -> Int { - mainWindowCount(in: windows.map(snapshot(for:))) + mainWindowCount(in: snapshots(in: windows)) } static func visibleMainWindowCount(in windows: [NSWindow]) -> Int { - visibleMainWindowCount(in: windows.map(snapshot(for:))) + visibleMainWindowCount(in: snapshots(in: windows)) } static func visibleWindowCount(in windows: [NSWindow]) -> Int { - visibleWindowCount(in: windows.map(snapshot(for:))) + visibleWindowCount(in: snapshots(in: windows)) } static func mainWindowIndex(in snapshots: [Snapshot]) -> Int? { @@ -107,6 +111,12 @@ enum MainWindowSurface { @MainActor enum WindowLifecycleDiagnostics { + enum WindowlessStallReportDecision: Equatable { + case report + case suppress + case resolveVisibleMainWindow + } + private static let logger = SupaLogger("WindowLifecycle") private static let heartbeatInterval: TimeInterval = 1.0 private static let stallThreshold: TimeInterval = 0.3 @@ -237,11 +247,35 @@ enum WindowLifecycleDiagnostics { private static func reportWindowlessStallIfNeeded(lag: TimeInterval) { guard !didReportWindowlessStall else { return } + let windows = NSApplication.shared.windows + switch Self.windowlessStallReportDecision( + appIsActive: NSApp.isActive, + snapshots: MainWindowSurface.snapshots(in: windows) + ) { + case .report: + break + case .suppress: + return + case .resolveVisibleMainWindow: + noteMainWindowAppeared() + return + } didReportWindowlessStall = true let elapsed = windowlessSince.map { Date.now.timeIntervalSince($0) } ?? 0 captureSentryEvent(kind: "windowless_main_thread_stall", elapsed: elapsed, lag: lag) } + static func windowlessStallReportDecision( + appIsActive: Bool, + snapshots: [MainWindowSurface.Snapshot] + ) -> WindowlessStallReportDecision { + if MainWindowSurface.hasVisibleMainWindow(in: snapshots) { + return .resolveVisibleMainWindow + } + guard appIsActive else { return .suppress } + return .report + } + private static func captureSentryEvent(kind: String, elapsed: TimeInterval, lag: TimeInterval?) { let context = windowlessContext ?? "unknown" log( diff --git a/supacodeTests/WindowSurfacingTests.swift b/supacodeTests/WindowSurfacingTests.swift index f2a2ce4f..33ad5cca 100644 --- a/supacodeTests/WindowSurfacingTests.swift +++ b/supacodeTests/WindowSurfacingTests.swift @@ -51,4 +51,26 @@ struct WindowSurfacingTests { #expect(MainWindowSurface.visibleMainWindowCount(in: snapshots) == 1) #expect(MainWindowSurface.visibleWindowCount(in: snapshots) == 3) } + + @MainActor + @Test func windowlessStallReportRequiresActiveAppWithoutVisibleMainWindow() { + #expect( + WindowLifecycleDiagnostics.windowlessStallReportDecision( + appIsActive: false, + snapshots: [] + ) == .suppress + ) + #expect( + WindowLifecycleDiagnostics.windowlessStallReportDecision( + appIsActive: true, + snapshots: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: true)] + ) == .resolveVisibleMainWindow + ) + #expect( + WindowLifecycleDiagnostics.windowlessStallReportDecision( + appIsActive: true, + snapshots: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: false)] + ) == .report + ) + } } -- 2.51.2 From 444131c21e1eedc119c5186a60b49b9c5f2735cb Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 18:25:10 +0900 Subject: [PATCH 380/528] Stop reporting failed HTTP requests to Sentry Sentry's URLSession swizzling turns any 5xx response into an HTTPClientError event. Every request we make goes to servers we don't own (e.g. Sparkle fetching the appcast from GitHub), so their 502s flood Sentry as noise rather than surfacing our own bugs. Disable enableCaptureFailedRequests so these are no longer captured. --- supacode/App/supacodeApp.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index ac782bd4..142cd49e 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -141,6 +141,11 @@ struct SupacodeApp: App { if let releaseName { options.releaseName = releaseName } options.tracesSampleRate = 0.05 options.enableAppHangTracking = false + // Don't report failed HTTP requests. The SDK swizzles URLSession to + // turn any 5xx response into an HTTPClientError, but every request we + // make goes to servers we don't own (e.g. Sparkle fetching the + // appcast from GitHub), so their 502s are noise, not our bugs. + options.enableCaptureFailedRequests = false } // Match the Sentry user id to the PostHog distinct id so an event in // one system can be traced to the same install in the other. -- 2.51.2 From 8c563e159bd39a3d2037c417fc17092fd49a9192 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 18:28:58 +0900 Subject: [PATCH 381/528] Stabilize progress clock tests --- supacodeTests/GhosttySurfaceBridgeTests.swift | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index 8a471983..c6683108 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -77,7 +77,7 @@ struct GhosttySurfaceBridgeTests { #expect(callbackCount == 1) // One throttle tick flushes only the latest coalesced value. - await clock.advance(by: .milliseconds(50)) + await advanceProgressClock(clock, by: .milliseconds(50)) #expect(bridge.state.progressValue == 50) #expect(callbackCount == 2) } @@ -97,7 +97,7 @@ struct GhosttySurfaceBridgeTests { #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) // No further reports: the driver synthesizes a REMOVE once the window lapses. - await clock.advance(by: .milliseconds(200)) + await advanceProgressClock(clock, by: .milliseconds(200)) #expect(bridge.state.progressState == nil) #expect(lastState == GHOSTTY_PROGRESS_STATE_REMOVE) } @@ -118,7 +118,7 @@ struct GhosttySurfaceBridgeTests { // keep resetting even though the value never changes. for _ in 0..<6 { bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) - await clock.advance(by: .milliseconds(50)) + await advanceProgressClock(clock, by: .milliseconds(50)) } #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) #expect(lastState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) @@ -137,14 +137,14 @@ struct GhosttySurfaceBridgeTests { bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) // No further reports: the stale window synthesizes a REMOVE and tears down // the driver. - await clock.advance(by: .milliseconds(100)) + await advanceProgressClock(clock, by: .milliseconds(100)) #expect(bridge.state.progressState == nil) // A report after the stale REMOVE must re-arm the driver, not freeze. bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 30) #expect(bridge.state.progressValue == 30) bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 60) - await clock.advance(by: .milliseconds(50)) + await advanceProgressClock(clock, by: .milliseconds(50)) #expect(bridge.state.progressValue == 60) } @@ -163,7 +163,7 @@ struct GhosttySurfaceBridgeTests { // Sit idle well past the throttle window, then a fresh value must paint on // its leading edge instead of waiting for a slow idle tick. - await clock.advance(by: .seconds(1)) + await advanceProgressClock(clock, by: .seconds(1)) bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_SET, value: 80) #expect(bridge.state.progressValue == 80) } @@ -186,7 +186,7 @@ struct GhosttySurfaceBridgeTests { // the downstream callback fires exactly once across the whole stream. for _ in 0..<10 { bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) - await clock.advance(by: .milliseconds(50)) + await advanceProgressClock(clock, by: .milliseconds(50)) } #expect(callbackCount == 1) #expect(bridge.state.progressState == GHOSTTY_PROGRESS_STATE_INDETERMINATE) @@ -212,4 +212,11 @@ struct GhosttySurfaceBridgeTests { #expect(bridge.state.progressValue == nil) #expect(states == [GHOSTTY_PROGRESS_STATE_SET, GHOSTTY_PROGRESS_STATE_REMOVE]) } + + private func advanceProgressClock(_ clock: TestClock, by duration: Duration) async { + // Let background progress tasks register and wake around TestClock advancement. + await Task.yield() + await clock.advance(by: duration) + await Task.yield() + } } -- 2.51.2 From a968ff95a51341fc718c37f4e97b99bc27dd29bb Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 9 Jun 2026 18:41:04 +0900 Subject: [PATCH 382/528] Recheck window visibility before timeout diagnostics --- supacode/App/WindowSurfacing.swift | 33 +++++++++++++++++++++--- supacodeTests/WindowSurfacingTests.swift | 25 ++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index fd75f31a..185335e1 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -111,7 +111,7 @@ enum MainWindowSurface { @MainActor enum WindowLifecycleDiagnostics { - enum WindowlessStallReportDecision: Equatable { + enum WindowlessReportDecision: Equatable { case report case suppress case resolveVisibleMainWindow @@ -240,7 +240,20 @@ enum WindowLifecycleDiagnostics { private static func reportWindowlessTimeoutIfNeeded(elapsed: TimeInterval) { guard !didReportWindowlessTimeout else { return } - guard windowlessContext != "launch" || NSApp.isActive else { return } + let windows = NSApplication.shared.windows + switch Self.windowlessTimeoutReportDecision( + appIsActive: NSApp.isActive, + windowlessContext: windowlessContext, + snapshots: MainWindowSurface.snapshots(in: windows) + ) { + case .report: + break + case .suppress: + return + case .resolveVisibleMainWindow: + noteMainWindowAppeared() + return + } didReportWindowlessTimeout = true captureSentryEvent(kind: "main_window_timeout", elapsed: elapsed, lag: nil) } @@ -268,7 +281,7 @@ enum WindowLifecycleDiagnostics { static func windowlessStallReportDecision( appIsActive: Bool, snapshots: [MainWindowSurface.Snapshot] - ) -> WindowlessStallReportDecision { + ) -> WindowlessReportDecision { if MainWindowSurface.hasVisibleMainWindow(in: snapshots) { return .resolveVisibleMainWindow } @@ -276,6 +289,20 @@ enum WindowLifecycleDiagnostics { return .report } + static func windowlessTimeoutReportDecision( + appIsActive: Bool, + windowlessContext: String?, + snapshots: [MainWindowSurface.Snapshot] + ) -> WindowlessReportDecision { + if MainWindowSurface.hasVisibleMainWindow(in: snapshots) { + return .resolveVisibleMainWindow + } + guard windowlessContext != "launch" || appIsActive else { + return .suppress + } + return .report + } + private static func captureSentryEvent(kind: String, elapsed: TimeInterval, lag: TimeInterval?) { let context = windowlessContext ?? "unknown" log( diff --git a/supacodeTests/WindowSurfacingTests.swift b/supacodeTests/WindowSurfacingTests.swift index 33ad5cca..b66f061f 100644 --- a/supacodeTests/WindowSurfacingTests.swift +++ b/supacodeTests/WindowSurfacingTests.swift @@ -73,4 +73,29 @@ struct WindowSurfacingTests { ) == .report ) } + + @MainActor + @Test func windowlessTimeoutReportResolvesVisibleMainWindowBeforeReporting() { + #expect( + WindowLifecycleDiagnostics.windowlessTimeoutReportDecision( + appIsActive: true, + windowlessContext: "surfaceMainWindow(openWindowRequested)", + snapshots: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: true)] + ) == .resolveVisibleMainWindow + ) + #expect( + WindowLifecycleDiagnostics.windowlessTimeoutReportDecision( + appIsActive: false, + windowlessContext: "launch", + snapshots: [] + ) == .suppress + ) + #expect( + WindowLifecycleDiagnostics.windowlessTimeoutReportDecision( + appIsActive: true, + windowlessContext: "surfaceMainWindow(openWindowRequested)", + snapshots: [MainWindowSurface.Snapshot(identifier: WindowID.main, isVisible: false)] + ) == .report + ) + } } -- 2.51.2 From 4f5ee6486cfa1244cd7b53b9eb58249086ade1a8 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 12:02:53 +0800 Subject: [PATCH 383/528] Add Shelf agent status badges --- docs/components/active-agents.md | 3 + docs/components/shelf.md | 13 +- .../Reducer/ActiveAgentsFeature.swift | 8 +- .../ActiveAgents/Views/ActiveAgentRow.swift | 4 +- .../Features/App/Reducer/AppFeature.swift | 45 +++- .../Features/Shelf/Views/ShelfSpineView.swift | 236 +++++++++++++++--- supacode/Features/Shelf/Views/ShelfView.swift | 21 +- supacodeTests/ActiveAgentsFeatureTests.swift | 31 +++ .../AppFeatureSettingsChangedTests.swift | 43 ++++ 9 files changed, 355 insertions(+), 49 deletions(-) diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index 279dbe6f..3fc96c1d 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -66,6 +66,9 @@ When nothing is running: "New agents will appear here". events — which usually coincides with, but is not the same as, a detected finish. - **Canvas** ([canvas](canvas.md)) is the spatial counterpart — cards light up on that same notification/unread signal, not on the detected status itself. +- **Shelf** ([shelf](shelf.md)) mirrors detected agents as compact status badges + at the bottom of each owning spine; clicking a badge uses the same jump-to-agent + behavior as clicking a panel row. ## Gotchas for agents diff --git a/docs/components/shelf.md b/docs/components/shelf.md index fec1d92a..012bfc0d 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -5,13 +5,15 @@ **Keywords:** shelf, book, spine, vertical tabs, flip, cycle books, cycle tabs, triage, proximity, stack -**Related:** [view-modes](view-modes.md) · [canvas](canvas.md) · [terminal](terminal.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) +**Related:** [view-modes](view-modes.md) · [canvas](canvas.md) · [terminal](terminal.md) · [active-agents](active-agents.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) ## What it is Shelf lays your worktrees out as thin vertical **spines** ("books") on the sides, with the currently open book's terminal filling the center. Each spine shows the project icon/name (rotated) and a column of its **tabs** as small icon slots. +When Prowl detects agents in that book, the bottom of the spine also shows compact +agent badges with their current status. Books near the open one are brighter; distant ones fade — so you always know where you are in the stack. @@ -38,6 +40,8 @@ tabs** — triage six in-flight agents one keystroke at a time. - **Select a tab:** click a tab slot on the open book's spine. - **New tab:** the **+** at the bottom of a spine. On a closed book, **+** opens the book and creates the tab. +- **Jump to an agent:** click an agent badge at the bottom of a spine. It focuses + that agent's worktree, tab, and pane. - **Split:** the open book's spine has **split-vertical** and **split-horizontal** buttons (Ghostty `new_split:right` / `new_split:down`). - **Close a tab:** hover a tab slot → its **×**. @@ -55,6 +59,10 @@ selection. progressively — a proximity ladder that keeps the stack readable. - **Unread notification:** a tab slot highlights orange; the spine header shows an orange dot. +- **Detected agents:** compact badges appear at the bottom of the owning spine. + Badge color/status follows the Active Agents states (Working, Blocked, Done, + Idle); blocked agents are prioritized first. Shelf keeps agent detection active + while visible, even if the Active Agents panel is hidden. - Spine tint follows the **repository color** when set (toggle `shelfSpineTintFollowsRepositoryColor`); otherwise a fallback (`shelfSpineTintFallback`: neutral or system tint) is used. @@ -84,6 +92,9 @@ See [view-modes](view-modes.md). ## Gotchas for agents - A "book" is a **worktree**; a tab slot is a **tab** within it. +- Agent badges belong to the terminal surface's owning worktree. If an agent is + running from a different working directory, the full Active Agents panel still + shows the resolved repository/branch context. - Closing the last tab of a book removes the book from the shelf. - The book-cycling keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only by arrow direction — be precise when describing them. diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 7afd4fd6..21cf8b55 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -128,7 +128,11 @@ struct ActiveAgentsFeature { max(minimumPanelHeight, min(maximumPanelHeight, height - reservedSidebarListHeight)) } - static func detectionEnabled(isPanelHidden: Bool, autoShowPanel: Bool) -> Bool { - !isPanelHidden || autoShowPanel + static func detectionEnabled( + isPanelHidden: Bool, + autoShowPanel: Bool, + isShelfVisible: Bool + ) -> Bool { + isShelfVisible || !isPanelHidden || autoShowPanel } } diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index eb12d7ce..b2776621 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -116,7 +116,7 @@ struct BaguaWorkingIndicator: View { } extension AgentDisplayState { - fileprivate var label: String { + var label: String { switch self { case .working: return "Working" @@ -129,7 +129,7 @@ extension AgentDisplayState { } } - fileprivate var foregroundStyle: Color { + var foregroundStyle: Color { switch self { case .working: return .orange diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 7674c999..140e717d 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -76,6 +76,7 @@ struct AppFeature { case endSearch case systemNotificationsPermissionFailed(errorMessage: String?) case systemNotificationTapped(worktreeID: Worktree.ID, surfaceID: UUID) + case syncAgentDetectionEnabled case alert(PresentationAction) case terminalEvent(TerminalClient.Event) } @@ -98,6 +99,27 @@ struct AppFeature { @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient + func agentDetectionEnabled( + state: State, + autoShowPanel: Bool? = nil + ) -> Bool { + ActiveAgentsFeature.detectionEnabled( + isPanelHidden: state.repositories.activeAgents.isPanelHidden, + autoShowPanel: autoShowPanel ?? state.settings.autoShowActiveAgentsPanel, + isShelfVisible: state.repositories.isShowingShelf + ) + } + + static func repositoriesActionMayChangeShelfVisibility(_ action: RepositoriesFeature.Action) -> Bool { + switch action { + case .selectArchivedWorktrees, .selectCanvas, .selectShelf, .selectTabbed, .setTopSegment, .toggleCanvas, + .toggleShelf: + return true + default: + return false + } + } + var body: some Reducer { let core = Reduce { state, action in switch action { @@ -106,10 +128,7 @@ struct AppFeature { appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") state.launchedAt = now state.repositories.launchRestoreMode = state.launchRestoreMode - let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( - isPanelHidden: state.repositories.activeAgents.isPanelHidden, - autoShowPanel: state.settings.autoShowActiveAgentsPanel - ) + let agentDetectionEnabled = agentDetectionEnabled(state: state) analyticsClient.capture("app_launched", nil) return .merge( .send(.repositories(.task)), @@ -382,10 +401,8 @@ struct AppFeature { state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles - let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( - isPanelHidden: state.repositories.activeAgents.isPanelHidden, - autoShowPanel: settings.autoShowActiveAgentsPanel - ) + let agentDetectionEnabled = agentDetectionEnabled( + state: state, autoShowPanel: settings.autoShowActiveAgentsPanel) if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -871,6 +888,12 @@ struct AppFeature { } ) + case .syncAgentDetectionEnabled: + let agentDetectionEnabled = agentDetectionEnabled(state: state) + return .run { _ in + await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) + } + case .alert(.dismiss): state.alert = nil return .none @@ -889,12 +912,16 @@ struct AppFeature { let nextIsPanelHidden = !state.repositories.activeAgents.isPanelHidden let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( isPanelHidden: nextIsPanelHidden, - autoShowPanel: state.settings.autoShowActiveAgentsPanel + autoShowPanel: state.settings.autoShowActiveAgentsPanel, + isShelfVisible: state.repositories.isShowingShelf ) return .run { _ in await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) } + case .repositories(let action) where Self.repositoriesActionMayChangeShelfVisibility(action): + return .send(.syncAgentDetectionEnabled) + case .repositories: return .none diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index bc5da216..d6f8a540 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -20,12 +20,14 @@ struct ShelfSpineView: View { /// glance instead of every non-open spine looking identical. let distanceFromOpen: Int? let terminalState: WorktreeTerminalState? + let activeAgentEntries: [ActiveAgentEntry] let tintFallback: ShelfSpineTintFallback let followsRepositoryColor: Bool let onOpenBook: () -> Void let onSelectTab: (TerminalTabID) -> Void - /// Bottom controls — provided only for the open book's spine. `nil` - /// suppresses the trio entirely. + let onSelectAgent: (ActiveAgentEntry.ID) -> Void + /// Bottom controls. New Tab is available on every spine; split actions + /// are only provided for the open book's spine. let onNewTab: (() -> Void)? let onSplitVertical: (() -> Void)? let onSplitHorizontal: (() -> Void)? @@ -55,7 +57,7 @@ struct ShelfSpineView: View { VStack(spacing: 0) { headerButton tabList - bottomControls + footer } .frame(width: ShelfMetrics.spineWidth) // `maxHeight: .infinity` binds the spine to the parent Shelf's @@ -189,40 +191,44 @@ struct ShelfSpineView: View { } } + private var prioritizedActiveAgentEntries: [ActiveAgentEntry] { + activeAgentEntries.sorted { lhs, rhs in + let lhsPriority = lhs.displayState.shelfSpinePriority + let rhsPriority = rhs.displayState.shelfSpinePriority + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs.lastChangedAt > rhs.lastChangedAt + } + } + + private var visibleActiveAgentEntries: [ActiveAgentEntry] { + let entries = prioritizedActiveAgentEntries + guard entries.count > ShelfMetrics.maximumVisibleAgentBadges else { return entries } + return Array(entries.prefix(ShelfMetrics.maximumVisibleAgentBadges - 1)) + } + + private var hiddenActiveAgentCount: Int { + max(0, activeAgentEntries.count - visibleActiveAgentEntries.count) + } + + private var hasBottomControls: Bool { + onNewTab != nil || onSplitVertical != nil || onSplitHorizontal != nil + } + @ViewBuilder - private var bottomControls: some View { - // `+` is shown on every spine, not just the open one: clicking it on a - // closed book opens that book and creates a tab in one motion (the - // caller sequences `selectWorktree` → `newTerminal`). Splits only - // make sense against a focused surface, so they stay scoped to the - // open book. - if onNewTab != nil || onSplitVertical != nil || onSplitHorizontal != nil { + private var footer: some View { + // The footer stays pinned under the scrollable tab list. Active agents + // share the same compact slot language as tabs/controls so each spine + // remains a quick vertical scan instead of becoming a second panel. + if !activeAgentEntries.isEmpty || hasBottomControls { VStack(spacing: ShelfMetrics.slotSpacing) { Divider().opacity(0.3) - if let onNewTab { - ShelfSpineControlButton( - systemImage: "plus", - label: "New Tab", - shortcut: ghosttyShortcuts.display(for: "new_tab"), - action: onNewTab - ) - } - if let onSplitVertical { - ShelfSpineControlButton( - systemImage: "square.split.2x1", - label: "Split Vertically", - shortcut: ghosttyShortcuts.display(for: "new_split:right"), - action: onSplitVertical - ) - } - if let onSplitHorizontal { - ShelfSpineControlButton( - systemImage: "square.split.1x2", - label: "Split Horizontally", - shortcut: ghosttyShortcuts.display(for: "new_split:down"), - action: onSplitHorizontal - ) + activeAgentBadges + if !activeAgentEntries.isEmpty && hasBottomControls { + Divider().opacity(0.3) } + bottomControls } .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) .padding(.top, ShelfMetrics.sectionGap) @@ -230,6 +236,56 @@ struct ShelfSpineView: View { } } + @ViewBuilder + private var activeAgentBadges: some View { + if !activeAgentEntries.isEmpty { + ForEach(visibleActiveAgentEntries) { entry in + ShelfSpineAgentBadge( + entry: entry, + action: { onSelectAgent(entry.id) } + ) + } + if hiddenActiveAgentCount > 0 { + ShelfSpineAgentOverflowBadge(count: hiddenActiveAgentCount) + } + } + } + + @ViewBuilder + private var bottomControls: some View { + // `+` is shown on every spine, not just the open one: clicking it on a + // closed book opens that book and creates a tab in one motion (the + // caller sequences `selectWorktree` → `newTerminal`). Splits only + // make sense against a focused surface, so they stay scoped to the + // open book. + if hasBottomControls { + if let onNewTab { + ShelfSpineControlButton( + systemImage: "plus", + label: "New Tab", + shortcut: ghosttyShortcuts.display(for: "new_tab"), + action: onNewTab + ) + } + if let onSplitVertical { + ShelfSpineControlButton( + systemImage: "square.split.2x1", + label: "Split Vertically", + shortcut: ghosttyShortcuts.display(for: "new_split:right"), + action: onSplitVertical + ) + } + if let onSplitHorizontal { + ShelfSpineControlButton( + systemImage: "square.split.1x2", + label: "Split Horizontally", + shortcut: ghosttyShortcuts.display(for: "new_split:down"), + action: onSplitHorizontal + ) + } + } + } + @ViewBuilder private var headerButton: some View { Button(action: onOpenBook) { @@ -542,6 +598,97 @@ private struct ShelfSpineTabSlot: View { } } +private struct ShelfSpineAgentBadge: View { + let entry: ActiveAgentEntry + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) + .fill(entry.displayState.foregroundStyle.opacity(0.12)) + .overlay { + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) + .stroke(entry.displayState.foregroundStyle.opacity(0.45), lineWidth: 1) + } + + agentIcon + .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) + + statusMarker + .offset(x: 2, y: 2) + } + .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) + .contentShape(.rect) + } + .buttonStyle(.plain) + .help(helpText) + .accessibilityLabel(Text(accessibilityLabel)) + } + + @ViewBuilder + private var agentIcon: some View { + if let icon = CommandIconMap.iconForFirstToken(entry.agent.iconLookupToken) { + TabIconImage( + rawName: icon.storageString, + pointSize: ShelfMetrics.agentBadgeIconPointSize + ) + .foregroundStyle(.primary) + } else { + Image(systemName: "sparkle") + .imageScale(.small) + .foregroundStyle(.primary) + .accessibilityHidden(true) + } + } + + private var statusMarker: some View { + Image(systemName: entry.displayState.shelfSpineStatusSymbol) + .font(.caption2.weight(.bold)) + .foregroundStyle(entry.displayState.foregroundStyle) + .frame( + width: ShelfMetrics.agentStatusMarkerSize, + height: ShelfMetrics.agentStatusMarkerSize + ) + .background { + Circle().fill(.background) + } + .accessibilityHidden(true) + } + + private var helpText: String { + let tabTitle = ActiveAgentsPanel.tabTitle(for: entry) + return "Jump to \(entry.agent.displayName): \(entry.displayState.label) - \(tabTitle)" + } + + private var accessibilityLabel: String { + "Jump to \(entry.agent.displayName), \(entry.displayState.label)" + } +} + +private struct ShelfSpineAgentOverflowBadge: View { + let count: Int + + var body: some View { + Text("+\(count)") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) + .background { + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.06)) + } + .help(helpText) + .accessibilityLabel(Text(helpText)) + } + + private var helpText: String { + "\(count) more active agents" + } +} + private struct ShelfSpineControlButton: View { let systemImage: String let label: String @@ -582,6 +729,9 @@ enum ShelfMetrics { /// doesn't crowd into the divider above the `+` button. static let sectionGap: CGFloat = 10 static let aggregatedDotSize: CGFloat = 6 + static let maximumVisibleAgentBadges = 4 + static let agentBadgeIconPointSize: CGFloat = 15 + static let agentStatusMarkerSize: CGFloat = 11 /// Max pre-rotation width (i.e. visual height after 90° rotation) of the /// spine header title. Texts longer than this get middle-truncated. static let headerMaxLength: CGFloat = 160 @@ -592,3 +742,23 @@ enum ShelfMetrics { /// branded artwork visually matches the SF-Symbol fallback. static let tabIconPointSize: CGFloat = 18 } + +extension AgentDisplayState { + fileprivate var shelfSpinePriority: Int { + switch self { + case .blocked: return 0 + case .working: return 1 + case .done: return 2 + case .idle: return 3 + } + } + + fileprivate var shelfSpineStatusSymbol: String { + switch self { + case .working: return "bolt.fill" + case .blocked: return "exclamationmark" + case .done: return "checkmark" + case .idle: return "circle.fill" + } + } +} diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 897ee1b5..0e9df57e 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -42,6 +42,9 @@ struct ShelfView: View { let openIndex = openBook.flatMap { book in books.firstIndex(where: { $0.id == book.id }) } + let activeAgentEntriesByWorktreeID = Dictionary(grouping: state.activeAgents.entries) { entry in + entry.worktreeID + } // Color identity of the open book's repo (nil ⇒ neutral surface). Shared // by the spine fill and the toolbar band so they read as one "L". let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } @@ -59,7 +62,12 @@ struct ShelfView: View { HStack(spacing: 0) { ForEach(Array(books.enumerated()), id: \.element.id) { index, book in - spine(book: book, index: index, openIndex: openIndex) + spine( + book: book, + index: index, + openIndex: openIndex, + activeAgentEntries: activeAgentEntriesByWorktreeID[book.id] ?? [] + ) if book.id == openBookID { openBookArea(for: book, state: state) } @@ -83,7 +91,12 @@ struct ShelfView: View { } @ViewBuilder - private func spine(book: ShelfBook, index: Int, openIndex: Int?) -> some View { + private func spine( + book: ShelfBook, + index: Int, + openIndex: Int?, + activeAgentEntries: [ActiveAgentEntry] + ) -> some View { let distance = openIndex.map { abs(index - $0) } let open = index == openIndex ShelfSpineView( @@ -91,10 +104,14 @@ struct ShelfView: View { isOpen: open, distanceFromOpen: distance, terminalState: terminalManager.stateIfExists(for: book.id), + activeAgentEntries: activeAgentEntries, tintFallback: settingsFile.global.shelfSpineTintFallback, followsRepositoryColor: settingsFile.global.shelfSpineTintFollowsRepositoryColor, onOpenBook: { openBook(book, selectingTab: nil) }, onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, + onSelectAgent: { agentID in + store.send(.activeAgents(.entryTapped(agentID))) + }, onNewTab: { // On a closed spine, `+` doubles as "pull this book out and // start a fresh tab". Sequencing is fine because TCA runs diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 697b01b9..a5f0553f 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -69,6 +69,37 @@ struct ActiveAgentsFeatureTests { #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) } + @Test func detectionStaysEnabledForVisiblePanelAutoShowOrShelf() { + #expect( + ActiveAgentsFeature.detectionEnabled( + isPanelHidden: false, + autoShowPanel: false, + isShelfVisible: false + ) + ) + #expect( + ActiveAgentsFeature.detectionEnabled( + isPanelHidden: true, + autoShowPanel: true, + isShelfVisible: false + ) + ) + #expect( + ActiveAgentsFeature.detectionEnabled( + isPanelHidden: true, + autoShowPanel: false, + isShelfVisible: true + ) + ) + #expect( + !ActiveAgentsFeature.detectionEnabled( + isPanelHidden: true, + autoShowPanel: false, + isShelfVisible: false + ) + ) + } + @Test func navigationReturnsNilForEmptyList() { let entries: IdentifiedArrayOf = [] #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .next, in: entries) == nil) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 270d8107..3815e708 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -102,6 +102,30 @@ struct AppFeatureSettingsChangedTests { } } + @Test(.dependencies) func syncAgentDetectionKeepsDetectionEnabledWhenShelfVisible() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) + var settings = SettingsFeature.State() + settings.autoShowActiveAgentsPanel = false + var state = AppFeature.State(settings: settings) + state.repositories.repositories = [repository] + state.repositories.isShelfActive = true + state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentTerminalCommands.withValue { $0.append(command) } + } + } + + await store.send(.syncAgentDetectionEnabled) + await store.finish() + + #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(true)]) + } + @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { var settings = SettingsFeature.State() settings.showActiveAgentTabTitles = true @@ -185,6 +209,25 @@ struct AppFeatureSettingsChangedTests { ) } + private func makeWorktree() -> Worktree { + Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeRepository(worktrees: [Worktree]) -> Repository { + Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } + private func fixedUUID(_ value: UInt8) -> UUID { UUID(uuid: (value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) } -- 2.51.2 From cc22fb1d67c1aabc4cdda70b7e1eb7e3d997f25f Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 17:53:37 +0800 Subject: [PATCH 384/528] Add Shelf trackpad book switching --- docs/components/shelf.md | 4 + supacode/Features/Shelf/Views/ShelfView.swift | 117 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 012bfc0d..95ed5dbf 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -33,6 +33,10 @@ otherwise it's a no-op. So `⌘⌃←/→` moves **between agents**, and `⌘⌃↑/↓` moves **between that agent's tabs** — triage six in-flight agents one keystroke at a time. +Two-finger horizontal swipes on the trackpad also flip between books. Vertical +scrolling inside the terminal keeps working normally; Shelf only consumes a swipe +after the horizontal movement clearly dominates. + ## Interacting with spines - **Open a book:** click anywhere on a closed spine (it activates and shows its diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 0e9df57e..780e6e34 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import Sharing import SwiftUI @@ -83,6 +84,19 @@ struct ShelfView: View { // the glass blends the leading band in, the nav panel, the open spine, // and the toolbar all read as one continuous color. .windowChromeTint(chromeFill, edges: [.top, .leading]) + .overlay { + ShelfSwipeEventMonitor(isEnabled: books.count > 1) { direction in + switch direction { + case .next: + store.send(.selectNextShelfBook) + case .previous: + store.send(.selectPreviousShelfBook) + } + } + .accessibilityHidden(true) + .allowsHitTesting(false) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } // Animate on every openBookID change — covers both Shelf-originated // book switches (which also set their own TCA animation) and // left-nav-originated switches, so the spine flow is consistent @@ -246,3 +260,106 @@ struct ShelfView: View { } } } + +private enum ShelfSwipeDirection { + case next + case previous +} + +private struct ShelfSwipeEventMonitor: NSViewRepresentable { + let isEnabled: Bool + let onSwipe: (ShelfSwipeDirection) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.postsFrameChangedNotifications = true + context.coordinator.view = view + context.coordinator.installIfNeeded() + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.view = nsView + context.coordinator.isEnabled = isEnabled + context.coordinator.onSwipe = onSwipe + context.coordinator.installIfNeeded() + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.removeMonitor() + } + + final class Coordinator { + weak var view: NSView? + var isEnabled = false + var onSwipe: (ShelfSwipeDirection) -> Void = { _ in } + + private var monitor: Any? + private var accumulatedDeltaX: CGFloat = 0 + private var accumulatedDeltaY: CGFloat = 0 + private var lastEventTimestamp: TimeInterval = 0 + private var cooldownUntil: TimeInterval = 0 + + private let swipeThreshold: CGFloat = 80 + private let horizontalDominanceRatio: CGFloat = 1.6 + private let eventGapResetInterval: TimeInterval = 0.25 + private let triggerCooldown: TimeInterval = 0.35 + + func installIfNeeded() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + self?.handle(event) ?? event + } + } + + func removeMonitor() { + guard let monitor else { return } + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + + private func handle(_ event: NSEvent) -> NSEvent? { + guard isEnabled, + let view, + let window = view.window, + event.window === window, + view.bounds.contains(view.convert(event.locationInWindow, from: nil)) + else { + resetAccumulatedDeltas() + return event + } + + if event.phase.contains(.began) + || event.timestamp - lastEventTimestamp > eventGapResetInterval + { + resetAccumulatedDeltas() + } + lastEventTimestamp = event.timestamp + + accumulatedDeltaX += event.scrollingDeltaX + accumulatedDeltaY += event.scrollingDeltaY + + guard event.timestamp >= cooldownUntil, + abs(accumulatedDeltaX) >= swipeThreshold, + abs(accumulatedDeltaX) > abs(accumulatedDeltaY) * horizontalDominanceRatio + else { + return event + } + + let direction: ShelfSwipeDirection = accumulatedDeltaX > 0 ? .next : .previous + resetAccumulatedDeltas() + cooldownUntil = event.timestamp + triggerCooldown + onSwipe(direction) + return nil + } + + private func resetAccumulatedDeltas() { + accumulatedDeltaX = 0 + accumulatedDeltaY = 0 + } + } +} -- 2.51.2 From cfc6055b017e4894194a058c6e0be7d175824056 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 18:09:42 +0800 Subject: [PATCH 385/528] Limit Shelf swipe to one book --- supacode/Features/Shelf/Views/ShelfView.swift | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 780e6e34..0d3fefd6 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -302,12 +302,11 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { private var accumulatedDeltaX: CGFloat = 0 private var accumulatedDeltaY: CGFloat = 0 private var lastEventTimestamp: TimeInterval = 0 - private var cooldownUntil: TimeInterval = 0 + private var didTriggerCurrentGesture = false private let swipeThreshold: CGFloat = 80 private let horizontalDominanceRatio: CGFloat = 1.6 private let eventGapResetInterval: TimeInterval = 0.25 - private let triggerCooldown: TimeInterval = 0.35 func installIfNeeded() { guard monitor == nil else { return } @@ -329,22 +328,34 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { event.window === window, view.bounds.contains(view.convert(event.locationInWindow, from: nil)) else { - resetAccumulatedDeltas() + resetGesture() return event } if event.phase.contains(.began) || event.timestamp - lastEventTimestamp > eventGapResetInterval { - resetAccumulatedDeltas() + resetGesture() } lastEventTimestamp = event.timestamp + if didTriggerCurrentGesture { + if event.phase.contains(.ended) || event.phase.contains(.cancelled) + || event.momentumPhase.contains(.ended) || event.momentumPhase.contains(.cancelled) + { + resetGesture() + } + return isHorizontallyDominant(event) ? nil : event + } + + guard event.momentumPhase.isEmpty else { + return event + } + accumulatedDeltaX += event.scrollingDeltaX accumulatedDeltaY += event.scrollingDeltaY - guard event.timestamp >= cooldownUntil, - abs(accumulatedDeltaX) >= swipeThreshold, + guard abs(accumulatedDeltaX) >= swipeThreshold, abs(accumulatedDeltaX) > abs(accumulatedDeltaY) * horizontalDominanceRatio else { return event @@ -352,11 +363,22 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { let direction: ShelfSwipeDirection = accumulatedDeltaX > 0 ? .next : .previous resetAccumulatedDeltas() - cooldownUntil = event.timestamp + triggerCooldown + didTriggerCurrentGesture = true onSwipe(direction) return nil } + private func isHorizontallyDominant(_ event: NSEvent) -> Bool { + let absDeltaX = abs(event.scrollingDeltaX) + let absDeltaY = abs(event.scrollingDeltaY) + return absDeltaX > 0 && absDeltaX > absDeltaY * horizontalDominanceRatio + } + + private func resetGesture() { + resetAccumulatedDeltas() + didTriggerCurrentGesture = false + } + private func resetAccumulatedDeltas() { accumulatedDeltaX = 0 accumulatedDeltaY = 0 -- 2.51.2 From 991fcafaa06918ed16a8a36f13ef49c1252a7df7 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 18:19:16 +0800 Subject: [PATCH 386/528] Bound Shelf swipe navigation --- docs/components/shelf.md | 12 +++--- .../RepositoriesFeature+Selection.swift | 10 +++-- supacode/Features/Shelf/Views/ShelfView.swift | 2 +- supacodeTests/ShelfFeatureTests.swift | 42 +++++++++++++++---- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 95ed5dbf..09b70e3d 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -33,9 +33,11 @@ otherwise it's a no-op. So `⌘⌃←/→` moves **between agents**, and `⌘⌃↑/↓` moves **between that agent's tabs** — triage six in-flight agents one keystroke at a time. -Two-finger horizontal swipes on the trackpad also flip between books. Vertical -scrolling inside the terminal keeps working normally; Shelf only consumes a swipe -after the horizontal movement clearly dominates. +Two-finger horizontal swipes on the trackpad also flip between books: swipe left +to move to the book on the right, and swipe right to move to the book on the +left. Book switching is bounded at the shelf edges; it does not wrap around. +Vertical scrolling inside the terminal keeps working normally; Shelf only +consumes a swipe after the horizontal movement clearly dominates. ## Interacting with spines @@ -100,5 +102,5 @@ See [view-modes](view-modes.md). running from a different working directory, the full Active Agents panel still shows the resolved repository/branch context. - Closing the last tab of a book removes the book from the shelf. -- The book-cycling keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only by - arrow direction — be precise when describing them. +- The book-switching keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only + by arrow direction — be precise when describing them. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift index 47bf243e..91c0b2e1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -84,9 +84,10 @@ func replacementBookAfterClosing( } /// Returns the Shelf book at `offset` positions from the currently open -/// book (wrapping around the book list). Returns nil if there are no -/// books. When there is no open book, offset > 0 picks the first book -/// and offset < 0 picks the last. +/// book. Returns nil at the list edges instead of wrapping around, so +/// Shelf navigation behaves like bounded horizontal movement. When +/// there is no open book, offset > 0 picks the first book and offset < 0 +/// picks the last. func shelfBook( atOffset offset: Int, state: RepositoriesFeature.State @@ -96,7 +97,8 @@ func shelfBook( if let currentID = state.openShelfBookID, let currentIndex = books.firstIndex(where: { $0.id == currentID }) { - let nextIndex = (currentIndex + offset + books.count) % books.count + let nextIndex = currentIndex + offset + guard books.indices.contains(nextIndex) else { return nil } return books[nextIndex] } return offset > 0 ? books.first : books.last diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 0d3fefd6..a3663b0b 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -361,7 +361,7 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { return event } - let direction: ShelfSwipeDirection = accumulatedDeltaX > 0 ? .next : .previous + let direction: ShelfSwipeDirection = accumulatedDeltaX > 0 ? .previous : .next resetAccumulatedDeltas() didTriggerCurrentGesture = true onSwipe(direction) diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index cc2700a9..64b53bed 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -336,7 +336,7 @@ struct ShelfFeatureTests { await store.finish() } - @Test(.dependencies) func selectNextShelfBookWrapsAround() async { + @Test(.dependencies) func selectNextShelfBookAtEndIsNoOp() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let wt1 = Worktree( id: "/tmp/repo", @@ -368,13 +368,41 @@ struct ShelfFeatureTests { } await store.send(.selectNextShelfBook) - // Wrapping: next-after-last lands back on the first book. - await store.receive(\.selectWorktree) { - $0.selection = .worktree(wt1.id) - $0.sidebarSelectedWorktreeIDs = [wt1.id] - $0.pendingTerminalFocusWorktreeIDs = [wt1.id] + await store.finish() + } + + @Test(.dependencies) func selectPreviousShelfBookAtStartIsNoOp() async { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let wt1 = Worktree( + id: "/tmp/repo", + name: "main", + detail: "", + workingDirectory: rootURL, + repositoryRootURL: rootURL + ) + let wt2 = Worktree( + id: "/tmp/repo/feature", + name: "feature", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"), + repositoryRootURL: rootURL + ) + let repo = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [wt1, wt2]) + ) + var state = RepositoriesFeature.State(repositories: [repo]) + state.repositoryRoots = [rootURL] + state.repositoryOrderIDs = [repo.id] + state.selection = .worktree(wt1.id) // Currently on the first book. + state.openedWorktreeIDs = [wt1.id, wt2.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() } - await store.receive(\.delegate.selectedWorktreeChanged) + + await store.send(.selectPreviousShelfBook) await store.finish() } -- 2.51.2 From 2393648927f9e9136ac7fcf1bc38d78374f67b20 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 15:54:45 +0800 Subject: [PATCH 387/528] Address Shelf review feedback --- docs/components/active-agents.md | 9 +- docs/components/shelf.md | 33 +-- docs/reference/settings-fields.md | 1 + .../Features/App/Reducer/AppFeature.swift | 16 +- .../Settings/Models/GlobalSettings.swift | 9 + .../Settings/Reducer/SettingsFeature.swift | 4 + .../Views/AppearanceSettingsView.swift | 5 + .../Features/Shelf/Views/ShelfSpineView.swift | 209 +++++++----------- supacode/Features/Shelf/Views/ShelfView.swift | 80 ++++--- .../AppFeatureSettingsChangedTests.swift | 25 +++ supacodeTests/SettingsFeatureTests.swift | 18 ++ .../SettingsFilePersistenceTests.swift | 3 + supacodeTests/ShelfFeatureTests.swift | 21 ++ 13 files changed, 253 insertions(+), 180 deletions(-) diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index 3fc96c1d..c72ab17c 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -56,6 +56,8 @@ When nothing is running: "New agents will appear here". - `autoShowActiveAgentsPanel` — pop the panel open when an agent appears. - `showActiveAgentTabTitles` — show each agent's tab title instead of its branch. +- `showActiveAgentStatusInShelf` — show detected agent status markers on Shelf + tab icons. - Panel height and hidden/shown state are persisted automatically. ## Relationship to other features @@ -66,9 +68,10 @@ When nothing is running: "New agents will appear here". events — which usually coincides with, but is not the same as, a detected finish. - **Canvas** ([canvas](canvas.md)) is the spatial counterpart — cards light up on that same notification/unread signal, not on the detected status itself. -- **Shelf** ([shelf](shelf.md)) mirrors detected agents as compact status badges - at the bottom of each owning spine; clicking a badge uses the same jump-to-agent - behavior as clicking a panel row. +- **Shelf** ([shelf](shelf.md)) mirrors detected agents as status markers on + each owning tab icon; clicking a marked tab uses the same jump-to-agent behavior + as clicking a panel row. This can be turned off with + `showActiveAgentStatusInShelf`. ## Gotchas for agents diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 09b70e3d..3f2eae0d 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -12,8 +12,8 @@ Shelf lays your worktrees out as thin vertical **spines** ("books") on the sides, with the currently open book's terminal filling the center. Each spine shows the project icon/name (rotated) and a column of its **tabs** as small icon slots. -When Prowl detects agents in that book, the bottom of the spine also shows compact -agent badges with their current status. +When Prowl detects an agent in a tab, that tab slot shows the agent status as a +small colored marker. Books near the open one are brighter; distant ones fade — so you always know where you are in the stack. @@ -33,11 +33,11 @@ otherwise it's a no-op. So `⌘⌃←/→` moves **between agents**, and `⌘⌃↑/↓` moves **between that agent's tabs** — triage six in-flight agents one keystroke at a time. -Two-finger horizontal swipes on the trackpad also flip between books: swipe left -to move to the book on the right, and swipe right to move to the book on the -left. Book switching is bounded at the shelf edges; it does not wrap around. -Vertical scrolling inside the terminal keeps working normally; Shelf only -consumes a swipe after the horizontal movement clearly dominates. +Hold `⌘` and use a two-finger trackpad swipe for directional navigation: +horizontal swipes flip between books, and vertical swipes cycle tabs in the open +book. Book switching is bounded at the shelf edges; it does not wrap around. +Plain scrolling inside the terminal keeps working normally because Shelf only +consumes the gesture while `⌘` is held. ## Interacting with spines @@ -46,7 +46,7 @@ consumes a swipe after the horizontal movement clearly dominates. - **Select a tab:** click a tab slot on the open book's spine. - **New tab:** the **+** at the bottom of a spine. On a closed book, **+** opens the book and creates the tab. -- **Jump to an agent:** click an agent badge at the bottom of a spine. It focuses +- **Jump to an agent:** click a tab slot with an agent status marker. It focuses that agent's worktree, tab, and pane. - **Split:** the open book's spine has **split-vertical** and **split-horizontal** buttons (Ghostty `new_split:right` / `new_split:down`). @@ -65,10 +65,11 @@ selection. progressively — a proximity ladder that keeps the stack readable. - **Unread notification:** a tab slot highlights orange; the spine header shows an orange dot. -- **Detected agents:** compact badges appear at the bottom of the owning spine. - Badge color/status follows the Active Agents states (Working, Blocked, Done, - Idle); blocked agents are prioritized first. Shelf keeps agent detection active - while visible, even if the Active Agents panel is hidden. +- **Detected agents:** status markers appear on the owning tab slot. Marker + color/status follows the Active Agents states (Working, Blocked, Done, Idle); + blocked agents are prioritized first when a split tab contains multiple agents. + Shelf keeps agent detection active while visible, even if the Active Agents + panel is hidden. - Spine tint follows the **repository color** when set (toggle `shelfSpineTintFollowsRepositoryColor`); otherwise a fallback (`shelfSpineTintFallback`: neutral or system tint) is used. @@ -83,6 +84,8 @@ spines stay visible so you can pick one. ## Settings that affect Shelf - `defaultViewMode` — launch directly into Shelf. +- `showActiveAgentStatusInShelf` — overlay detected agent status on Shelf tab + icons. - `shelfSpineTintFollowsRepositoryColor` — color spines by repo color. - `shelfSpineTintFallback` — `neutral` or `systemTint` when a repo has no color. - `windowTintMode` / repository colors — chrome tinting. @@ -98,9 +101,9 @@ See [view-modes](view-modes.md). ## Gotchas for agents - A "book" is a **worktree**; a tab slot is a **tab** within it. -- Agent badges belong to the terminal surface's owning worktree. If an agent is - running from a different working directory, the full Active Agents panel still - shows the resolved repository/branch context. +- Agent status markers belong to the terminal surface's owning tab/worktree. If + an agent is running from a different working directory, the full Active Agents + panel still shows the resolved repository/branch context. - Closing the last tab of a book removes the book from the shelf. - The book-switching keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only by arrow direction — be precise when describing them. diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 2186e9f3..34b0125a 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -54,6 +54,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `dimUnfocusedSplits` | Bool | `true` | Dim panes that aren't focused. | | `autoShowActiveAgentsPanel` | Bool | `false` | Auto-open the Active Agents panel on a new agent. | | `showActiveAgentTabTitles` | Bool | `false` | Show tab titles (vs. branch) in the agents panel. | +| `showActiveAgentStatusInShelf` | Bool | `true` | Show agent status markers on Shelf tab icons. | | `windowTintMode` | enum (`none`/`repositoryColor`/`custom`) | `repositoryColor` | How the window chrome is tinted. | | `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | | `showRunButtonInToolbar` | Bool | `true` | Show the Run Script button in the toolbar. | diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 140e717d..f1751f39 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -101,12 +101,15 @@ struct AppFeature { func agentDetectionEnabled( state: State, - autoShowPanel: Bool? = nil + autoShowPanel: Bool? = nil, + showShelfStatus: Bool? = nil ) -> Bool { - ActiveAgentsFeature.detectionEnabled( + let shelfStatusVisible = + state.repositories.isShowingShelf && (showShelfStatus ?? state.settings.showActiveAgentStatusInShelf) + return ActiveAgentsFeature.detectionEnabled( isPanelHidden: state.repositories.activeAgents.isPanelHidden, autoShowPanel: autoShowPanel ?? state.settings.autoShowActiveAgentsPanel, - isShelfVisible: state.repositories.isShowingShelf + isShelfVisible: shelfStatusVisible ) } @@ -402,7 +405,10 @@ struct AppFeature { state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles let agentDetectionEnabled = agentDetectionEnabled( - state: state, autoShowPanel: settings.autoShowActiveAgentsPanel) + state: state, + autoShowPanel: settings.autoShowActiveAgentsPanel, + showShelfStatus: settings.showActiveAgentStatusInShelf + ) if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -913,7 +919,7 @@ struct AppFeature { let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( isPanelHidden: nextIsPanelHidden, autoShowPanel: state.settings.autoShowActiveAgentsPanel, - isShelfVisible: state.repositories.isShowingShelf + isShelfVisible: state.repositories.isShowingShelf && state.settings.showActiveAgentStatusInShelf ) return .run { _ in await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 61bb624c..820e3d81 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -30,6 +30,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool var showActiveAgentTabTitles: Bool + var showActiveAgentStatusInShelf: Bool var windowTintMode: WindowTintMode var windowTintCustomColor: TintColor var showRunButtonInToolbar: Bool @@ -71,6 +72,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: true, autoShowActiveAgentsPanel: false, showActiveAgentTabTitles: false, + showActiveAgentStatusInShelf: true, windowTintMode: .repositoryColor, windowTintCustomColor: .default, showRunButtonInToolbar: true, @@ -113,6 +115,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: Bool = true, autoShowActiveAgentsPanel: Bool = false, showActiveAgentTabTitles: Bool = false, + showActiveAgentStatusInShelf: Bool = true, windowTintMode: WindowTintMode = .repositoryColor, windowTintCustomColor: TintColor = .default, showRunButtonInToolbar: Bool = true, @@ -153,6 +156,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.dimUnfocusedSplits = dimUnfocusedSplits self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel self.showActiveAgentTabTitles = showActiveAgentTabTitles + self.showActiveAgentStatusInShelf = showActiveAgentStatusInShelf self.windowTintMode = windowTintMode self.windowTintCustomColor = windowTintCustomColor self.showRunButtonInToolbar = showRunButtonInToolbar @@ -196,6 +200,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(dimUnfocusedSplits, forKey: .dimUnfocusedSplits) try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) try container.encode(showActiveAgentTabTitles, forKey: .showActiveAgentTabTitles) + try container.encode(showActiveAgentStatusInShelf, forKey: .showActiveAgentStatusInShelf) try container.encode(windowTintMode, forKey: .windowTintMode) try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) try container.encode(showRunButtonInToolbar, forKey: .showRunButtonInToolbar) @@ -238,6 +243,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case dimUnfocusedSplits case autoShowActiveAgentsPanel case showActiveAgentTabTitles + case showActiveAgentStatusInShelf case windowTintMode case windowTintCustomColor case showRunButtonInToolbar @@ -339,6 +345,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { showActiveAgentTabTitles = try container.decodeIfPresent(Bool.self, forKey: .showActiveAgentTabTitles) ?? Self.default.showActiveAgentTabTitles + showActiveAgentStatusInShelf = + try container.decodeIfPresent(Bool.self, forKey: .showActiveAgentStatusInShelf) + ?? Self.default.showActiveAgentStatusInShelf (windowTintMode, windowTintCustomColor) = try Self.decodeWindowTint(from: container) (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = try Self.decodeShelfSpineTint(from: container) let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 7c712209..ad7e291f 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -37,6 +37,7 @@ struct SettingsFeature { var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool var showActiveAgentTabTitles: Bool + var showActiveAgentStatusInShelf: Bool var windowTintMode: WindowTintMode var shelfSpineTintFallback: ShelfSpineTintFallback var shelfSpineTintFollowsRepositoryColor: Bool @@ -92,6 +93,7 @@ struct SettingsFeature { dimUnfocusedSplits = settings.dimUnfocusedSplits autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel showActiveAgentTabTitles = settings.showActiveAgentTabTitles + showActiveAgentStatusInShelf = settings.showActiveAgentStatusInShelf windowTintMode = settings.windowTintMode shelfSpineTintFallback = settings.shelfSpineTintFallback shelfSpineTintFollowsRepositoryColor = settings.shelfSpineTintFollowsRepositoryColor @@ -137,6 +139,7 @@ struct SettingsFeature { dimUnfocusedSplits: dimUnfocusedSplits, autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, showActiveAgentTabTitles: showActiveAgentTabTitles, + showActiveAgentStatusInShelf: showActiveAgentStatusInShelf, windowTintMode: windowTintMode, windowTintCustomColor: TintColor(windowTintCustomColor), showRunButtonInToolbar: showRunButtonInToolbar, @@ -250,6 +253,7 @@ struct SettingsFeature { state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel state.showActiveAgentTabTitles = normalizedSettings.showActiveAgentTabTitles + state.showActiveAgentStatusInShelf = normalizedSettings.showActiveAgentStatusInShelf state.windowTintMode = normalizedSettings.windowTintMode state.shelfSpineTintFallback = normalizedSettings.shelfSpineTintFallback state.shelfSpineTintFollowsRepositoryColor = normalizedSettings.shelfSpineTintFollowsRepositoryColor diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 63042755..f429128a 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -101,6 +101,11 @@ struct AppearanceSettingsView: View { isOn: $store.showActiveAgentTabTitles ) .help("Display each agent's tab title in the row and show the branch name on hover.") + Toggle( + "Show agent status in Shelf tabs", + isOn: $store.showActiveAgentStatusInShelf + ) + .help("Overlay detected agent status on the owning tab icon in Shelf View.") } Section("Default View") { Picker("Launch in", selection: $store.defaultViewMode) { diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index d6f8a540..26e8fef8 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -160,6 +160,20 @@ struct ShelfSpineView: View { repositoryAppearances[book.repositoryID] ?? .empty } + private var activeAgentEntryByTabID: [TerminalTabID: ActiveAgentEntry] { + var result: [TerminalTabID: ActiveAgentEntry] = [:] + for entry in activeAgentEntries { + guard let existing = result[entry.tabID] else { + result[entry.tabID] = entry + continue + } + if entry.hasHigherShelfSpinePriority(than: existing) { + result[entry.tabID] = entry + } + } + return result + } + /// Active-tab highlight fades more gently than the spine background — /// the tab-selection indicator has to stay legible even on far-away /// books so users can still see which tab would open. Uses absolute @@ -191,43 +205,17 @@ struct ShelfSpineView: View { } } - private var prioritizedActiveAgentEntries: [ActiveAgentEntry] { - activeAgentEntries.sorted { lhs, rhs in - let lhsPriority = lhs.displayState.shelfSpinePriority - let rhsPriority = rhs.displayState.shelfSpinePriority - if lhsPriority != rhsPriority { - return lhsPriority < rhsPriority - } - return lhs.lastChangedAt > rhs.lastChangedAt - } - } - - private var visibleActiveAgentEntries: [ActiveAgentEntry] { - let entries = prioritizedActiveAgentEntries - guard entries.count > ShelfMetrics.maximumVisibleAgentBadges else { return entries } - return Array(entries.prefix(ShelfMetrics.maximumVisibleAgentBadges - 1)) - } - - private var hiddenActiveAgentCount: Int { - max(0, activeAgentEntries.count - visibleActiveAgentEntries.count) - } - private var hasBottomControls: Bool { onNewTab != nil || onSplitVertical != nil || onSplitHorizontal != nil } @ViewBuilder private var footer: some View { - // The footer stays pinned under the scrollable tab list. Active agents - // share the same compact slot language as tabs/controls so each spine - // remains a quick vertical scan instead of becoming a second panel. - if !activeAgentEntries.isEmpty || hasBottomControls { + // The footer stays pinned under the scrollable tab list so the spine's + // tab column can scroll independently from the per-book tab/split controls. + if hasBottomControls { VStack(spacing: ShelfMetrics.slotSpacing) { Divider().opacity(0.3) - activeAgentBadges - if !activeAgentEntries.isEmpty && hasBottomControls { - Divider().opacity(0.3) - } bottomControls } .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) @@ -236,21 +224,6 @@ struct ShelfSpineView: View { } } - @ViewBuilder - private var activeAgentBadges: some View { - if !activeAgentEntries.isEmpty { - ForEach(visibleActiveAgentEntries) { entry in - ShelfSpineAgentBadge( - entry: entry, - action: { onSelectAgent(entry.id) } - ) - } - if hiddenActiveAgentCount > 0 { - ShelfSpineAgentOverflowBadge(count: hiddenActiveAgentCount) - } - } - } - @ViewBuilder private var bottomControls: some View { // `+` is shown on every spine, not just the open one: clicking it on a @@ -328,6 +301,7 @@ struct ShelfSpineView: View { @ViewBuilder private func tabListContent(state terminalState: WorktreeTerminalState) -> some View { + let agentByTabID = activeAgentEntryByTabID VStack(spacing: ShelfMetrics.slotSpacing) { ForEach(Array(terminalState.tabManager.tabs.enumerated()), id: \.element.id) { index, tab in // 1-based hotkey number that matches Cmd+1..9. Tabs at @@ -345,7 +319,9 @@ struct ShelfSpineView: View { hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id), activeHighlightTint: effectiveTintColor, activeHighlightAlpha: activeTabHighlightAlpha, + activeAgentEntry: agentByTabID[tab.id], onTap: { onSelectTab(tab.id) }, + onSelectAgent: { onSelectAgent($0) }, onClose: { terminalState.closeTab(tab.id) } ) .terminalTabContextMenu( @@ -502,14 +478,20 @@ private struct ShelfSpineTabSlot: View { /// notification tint is left untouched so unread signals remain /// attention-grabbing regardless of distance. let activeHighlightAlpha: Double + /// Highest-priority agent currently detected in this tab, when the Shelf + /// status overlay is enabled. Split tabs can contain more than one agent; + /// the spine surfaces the most actionable one and the full Active Agents + /// panel remains the detailed roster. + let activeAgentEntry: ActiveAgentEntry? let onTap: () -> Void + let onSelectAgent: (ActiveAgentEntry.ID) -> Void let onClose: () -> Void @Environment(CommandKeyObserver.self) private var commandKeyObserver @State private var isHovering = false var body: some View { - Button(action: onTap) { + Button(action: primaryAction) { ZStack { backgroundFill slotContent @@ -539,7 +521,16 @@ private struct ShelfSpineTabSlot: View { .onHover { hovering in isHovering = hovering } - .help(tab.displayTitle) + .help(helpText) + .accessibilityLabel(Text(accessibilityLabel)) + } + + private func primaryAction() { + if let activeAgentEntry { + onSelectAgent(activeAgentEntry.id) + } else { + onTap() + } } /// When ⌘ is held AND this tab has a `Cmd+N` hotkey AND this slot is @@ -561,17 +552,24 @@ private struct ShelfSpineTabSlot: View { } .accessibilityHidden(true) } else { - TabIconImage( - rawName: tab.icon ?? ShelfMetrics.defaultTabIcon, - pointSize: ShelfMetrics.tabIconPointSize - ) - .foregroundStyle(foregroundTint) - // Dim tabs without a hotkey when ⌘ is held — but only on the open - // book, where the dimming pairs with the visible ⌘N glyphs on its - // siblings. On closed books no glyph appears, so dimming would be - // a stray visual change with no story behind it. - .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil && isOpenBook ? 0.45 : 1) - .accessibilityHidden(true) + ZStack(alignment: .bottomTrailing) { + TabIconImage( + rawName: tab.icon ?? ShelfMetrics.defaultTabIcon, + pointSize: ShelfMetrics.tabIconPointSize + ) + .foregroundStyle(foregroundTint) + // Dim tabs without a hotkey when ⌘ is held — but only on the open + // book, where the dimming pairs with the visible ⌘N glyphs on its + // siblings. On closed books no glyph appears, so dimming would be + // a stray visual change with no story behind it. + .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil && isOpenBook ? 0.45 : 1) + .accessibilityHidden(true) + + if let activeAgentEntry { + statusMarker(for: activeAgentEntry) + .offset(x: 3, y: 3) + } + } } } @@ -586,6 +584,13 @@ private struct ShelfSpineTabSlot: View { } else if isActive { RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) .fill(activeHighlightTint.opacity(activeHighlightAlpha)) + } else if let activeAgentEntry { + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) + .fill(activeAgentEntry.displayState.foregroundStyle.opacity(0.12)) + .overlay { + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) + .stroke(activeAgentEntry.displayState.foregroundStyle.opacity(0.45), lineWidth: 1) + } } else { Color.clear } @@ -596,53 +601,9 @@ private struct ShelfSpineTabSlot: View { if isActive { return .primary } return .secondary } -} - -private struct ShelfSpineAgentBadge: View { - let entry: ActiveAgentEntry - let action: () -> Void - - var body: some View { - Button(action: action) { - ZStack(alignment: .bottomTrailing) { - RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) - .fill(entry.displayState.foregroundStyle.opacity(0.12)) - .overlay { - RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) - .stroke(entry.displayState.foregroundStyle.opacity(0.45), lineWidth: 1) - } - - agentIcon - .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) - - statusMarker - .offset(x: 2, y: 2) - } - .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) - .contentShape(.rect) - } - .buttonStyle(.plain) - .help(helpText) - .accessibilityLabel(Text(accessibilityLabel)) - } @ViewBuilder - private var agentIcon: some View { - if let icon = CommandIconMap.iconForFirstToken(entry.agent.iconLookupToken) { - TabIconImage( - rawName: icon.storageString, - pointSize: ShelfMetrics.agentBadgeIconPointSize - ) - .foregroundStyle(.primary) - } else { - Image(systemName: "sparkle") - .imageScale(.small) - .foregroundStyle(.primary) - .accessibilityHidden(true) - } - } - - private var statusMarker: some View { + private func statusMarker(for entry: ActiveAgentEntry) -> some View { Image(systemName: entry.displayState.shelfSpineStatusSymbol) .font(.caption2.weight(.bold)) .foregroundStyle(entry.displayState.foregroundStyle) @@ -657,35 +618,14 @@ private struct ShelfSpineAgentBadge: View { } private var helpText: String { - let tabTitle = ActiveAgentsPanel.tabTitle(for: entry) - return "Jump to \(entry.agent.displayName): \(entry.displayState.label) - \(tabTitle)" + guard let activeAgentEntry else { return tab.displayTitle } + let tabTitle = ActiveAgentsPanel.tabTitle(for: activeAgentEntry) + return "Jump to \(activeAgentEntry.agent.displayName): \(activeAgentEntry.displayState.label) - \(tabTitle)" } private var accessibilityLabel: String { - "Jump to \(entry.agent.displayName), \(entry.displayState.label)" - } -} - -private struct ShelfSpineAgentOverflowBadge: View { - let count: Int - - var body: some View { - Text("+\(count)") - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) - .background { - RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) - .fill(Color.primary.opacity(0.06)) - } - .help(helpText) - .accessibilityLabel(Text(helpText)) - } - - private var helpText: String { - "\(count) more active agents" + guard let activeAgentEntry else { return tab.displayTitle } + return "Jump to \(activeAgentEntry.agent.displayName), \(activeAgentEntry.displayState.label)" } } @@ -729,8 +669,6 @@ enum ShelfMetrics { /// doesn't crowd into the divider above the `+` button. static let sectionGap: CGFloat = 10 static let aggregatedDotSize: CGFloat = 6 - static let maximumVisibleAgentBadges = 4 - static let agentBadgeIconPointSize: CGFloat = 15 static let agentStatusMarkerSize: CGFloat = 11 /// Max pre-rotation width (i.e. visual height after 90° rotation) of the /// spine header title. Texts longer than this get middle-truncated. @@ -762,3 +700,14 @@ extension AgentDisplayState { } } } + +extension ActiveAgentEntry { + fileprivate func hasHigherShelfSpinePriority(than other: ActiveAgentEntry) -> Bool { + let priority = displayState.shelfSpinePriority + let otherPriority = other.displayState.shelfSpinePriority + if priority != otherPriority { + return priority < otherPriority + } + return lastChangedAt > other.lastChangedAt + } +} diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index a3663b0b..b68cdbf7 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -43,9 +43,10 @@ struct ShelfView: View { let openIndex = openBook.flatMap { book in books.firstIndex(where: { $0.id == book.id }) } - let activeAgentEntriesByWorktreeID = Dictionary(grouping: state.activeAgents.entries) { entry in - entry.worktreeID - } + let activeAgentEntriesByWorktreeID = + settingsFile.global.showActiveAgentStatusInShelf + ? Dictionary(grouping: state.activeAgents.entries) { entry in entry.worktreeID } + : [:] // Color identity of the open book's repo (nil ⇒ neutral surface). Shared // by the spine fill and the toolbar band so they read as one "L". let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } @@ -85,12 +86,16 @@ struct ShelfView: View { // and the toolbar all read as one continuous color. .windowChromeTint(chromeFill, edges: [.top, .leading]) .overlay { - ShelfSwipeEventMonitor(isEnabled: books.count > 1) { direction in - switch direction { - case .next: + ShelfSwipeEventMonitor(isEnabled: !books.isEmpty) { action in + switch action { + case .nextBook: store.send(.selectNextShelfBook) - case .previous: + case .previousBook: store.send(.selectPreviousShelfBook) + case .nextTab: + store.send(.selectNextWorktree) + case .previousTab: + store.send(.selectPreviousWorktree) } } .accessibilityHidden(true) @@ -261,14 +266,36 @@ struct ShelfView: View { } } -private enum ShelfSwipeDirection { - case next - case previous +enum ShelfSwipeNavigationAction: Equatable { + case nextBook + case previousBook + case nextTab + case previousTab +} + +struct ShelfSwipeGestureClassifier { + static let swipeThreshold: CGFloat = 80 + static let axisDominanceRatio: CGFloat = 1.6 + + static func action( + accumulatedDeltaX: CGFloat, + accumulatedDeltaY: CGFloat + ) -> ShelfSwipeNavigationAction? { + let absX = abs(accumulatedDeltaX) + let absY = abs(accumulatedDeltaY) + if absX >= swipeThreshold, absX > absY * axisDominanceRatio { + return accumulatedDeltaX > 0 ? .previousBook : .nextBook + } + if absY >= swipeThreshold, absY > absX * axisDominanceRatio { + return accumulatedDeltaY > 0 ? .previousTab : .nextTab + } + return nil + } } private struct ShelfSwipeEventMonitor: NSViewRepresentable { let isEnabled: Bool - let onSwipe: (ShelfSwipeDirection) -> Void + let onSwipe: (ShelfSwipeNavigationAction) -> Void func makeCoordinator() -> Coordinator { Coordinator() @@ -296,7 +323,7 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { final class Coordinator { weak var view: NSView? var isEnabled = false - var onSwipe: (ShelfSwipeDirection) -> Void = { _ in } + var onSwipe: (ShelfSwipeNavigationAction) -> Void = { _ in } private var monitor: Any? private var accumulatedDeltaX: CGFloat = 0 @@ -304,8 +331,6 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { private var lastEventTimestamp: TimeInterval = 0 private var didTriggerCurrentGesture = false - private let swipeThreshold: CGFloat = 80 - private let horizontalDominanceRatio: CGFloat = 1.6 private let eventGapResetInterval: TimeInterval = 0.25 func installIfNeeded() { @@ -332,6 +357,11 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { return event } + guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) else { + resetGesture() + return event + } + if event.phase.contains(.began) || event.timestamp - lastEventTimestamp > eventGapResetInterval { @@ -345,35 +375,31 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { { resetGesture() } - return isHorizontallyDominant(event) ? nil : event + return nil } guard event.momentumPhase.isEmpty else { - return event + return nil } accumulatedDeltaX += event.scrollingDeltaX accumulatedDeltaY += event.scrollingDeltaY - guard abs(accumulatedDeltaX) >= swipeThreshold, - abs(accumulatedDeltaX) > abs(accumulatedDeltaY) * horizontalDominanceRatio + guard + let action = ShelfSwipeGestureClassifier.action( + accumulatedDeltaX: accumulatedDeltaX, + accumulatedDeltaY: accumulatedDeltaY + ) else { - return event + return nil } - let direction: ShelfSwipeDirection = accumulatedDeltaX > 0 ? .previous : .next resetAccumulatedDeltas() didTriggerCurrentGesture = true - onSwipe(direction) + onSwipe(action) return nil } - private func isHorizontallyDominant(_ event: NSEvent) -> Bool { - let absDeltaX = abs(event.scrollingDeltaX) - let absDeltaY = abs(event.scrollingDeltaY) - return absDeltaX > 0 && absDeltaX > absDeltaY * horizontalDominanceRatio - } - private func resetGesture() { resetAccumulatedDeltas() didTriggerCurrentGesture = false diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 3815e708..18eb21a9 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -126,6 +126,31 @@ struct AppFeatureSettingsChangedTests { #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(true)]) } + @Test(.dependencies) func syncAgentDetectionDisablesShelfOnlyDetectionWhenShelfStatusOptedOut() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) + var settings = SettingsFeature.State() + settings.autoShowActiveAgentsPanel = false + settings.showActiveAgentStatusInShelf = false + var state = AppFeature.State(settings: settings) + state.repositories.repositories = [repository] + state.repositories.isShelfActive = true + state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentTerminalCommands.withValue { $0.append(command) } + } + } + + await store.send(.syncAgentDetectionEnabled) + await store.finish() + + #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(false)]) + } + @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { var settings = SettingsFeature.State() settings.showActiveAgentTabTitles = true diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 6c736761..81449c47 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -413,6 +413,24 @@ struct SettingsFeatureTests { #expect(settingsFile.global.showActiveAgentTabTitles == true) } + @Test(.dependencies) func showActiveAgentStatusInShelfPersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.showActiveAgentStatusInShelf = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.showActiveAgentStatusInShelf, false))) { + $0.showActiveAgentStatusInShelf = false + } + await store.receive(\.delegate.settingsChanged) + + #expect(settingsFile.global.showActiveAgentStatusInShelf == false) + } + @Test(.dependencies) func disablingAnalyticsResetsClient() async { var initialSettings = GlobalSettings.default initialSettings.analyticsEnabled = true diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 378313b6..10ba0b2e 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -93,6 +93,7 @@ struct SettingsFilePersistenceTests { $settings.withLock { $0.global.shelfSpineTintFallback = .systemTint $0.global.shelfSpineTintFollowsRepositoryColor = false + $0.global.showActiveAgentStatusInShelf = false } } @@ -105,6 +106,7 @@ struct SettingsFilePersistenceTests { #expect(reloaded.global.shelfSpineTintFallback == .systemTint) #expect(reloaded.global.shelfSpineTintFollowsRepositoryColor == false) + #expect(reloaded.global.showActiveAgentStatusInShelf == false) } @Test(.dependencies) func invalidJSONResetsToDefaults() throws { @@ -169,6 +171,7 @@ struct SettingsFilePersistenceTests { #expect(settings.global.showDefaultEditorInToolbar == true) #expect(settings.global.dockBounceMode == .off) #expect(settings.global.showNotificationDotOnDock == false) + #expect(settings.global.showActiveAgentStatusInShelf == true) #expect(settings.global.shelfSpineTintFallback == .neutral) #expect(settings.global.shelfSpineTintFollowsRepositoryColor == true) #expect(settings.repositoryRoots.isEmpty) diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index 64b53bed..fdae791e 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -470,6 +470,27 @@ struct ShelfFeatureTests { #expect(sentCommands.value == [.performBindingAction(wt1, action: "previous_tab")]) } + @Test func shelfSwipeGestureClassifierMapsAxesToShelfNavigation() { + #expect( + ShelfSwipeGestureClassifier.action(accumulatedDeltaX: -90, accumulatedDeltaY: 10) == .nextBook + ) + #expect( + ShelfSwipeGestureClassifier.action(accumulatedDeltaX: 90, accumulatedDeltaY: 10) == .previousBook + ) + #expect( + ShelfSwipeGestureClassifier.action(accumulatedDeltaX: 10, accumulatedDeltaY: -90) == .nextTab + ) + #expect( + ShelfSwipeGestureClassifier.action(accumulatedDeltaX: 10, accumulatedDeltaY: 90) == .previousTab + ) + } + + @Test func shelfSwipeGestureClassifierIgnoresSmallOrAmbiguousScrolls() { + #expect(ShelfSwipeGestureClassifier.action(accumulatedDeltaX: -79, accumulatedDeltaY: 0) == nil) + #expect(ShelfSwipeGestureClassifier.action(accumulatedDeltaX: 60, accumulatedDeltaY: 60) == nil) + #expect(ShelfSwipeGestureClassifier.action(accumulatedDeltaX: 90, accumulatedDeltaY: 70) == nil) + } + @Test(.dependencies) func worktreeHistoryIsUnavailableWhileShelfIsActive() async { let fixture = threeWorktreeFixture() var state = RepositoriesFeature.State(repositories: [fixture.repo]) -- 2.51.2 From fb56f8e9106a63dbb97f35d94f6a072372a309f5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:19:16 +0900 Subject: [PATCH 388/528] Restore wrap-around for Shelf book navigation Keyboard cycling (cmd-ctrl-left/right) and the new cmd+swipe gesture share shelfBook(atOffset:), so the bounded-edge change in the swipe work also removed the long-standing keyboard wrap. Bring the wrap back for both input paths and restore the wrap-around tests. --- docs/components/shelf.md | 6 +++--- .../RepositoriesFeature+Selection.swift | 10 ++++------ supacodeTests/ShelfFeatureTests.swift | 18 ++++++++++++++++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 3f2eae0d..8bc22f5a 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -35,7 +35,7 @@ tabs** — triage six in-flight agents one keystroke at a time. Hold `⌘` and use a two-finger trackpad swipe for directional navigation: horizontal swipes flip between books, and vertical swipes cycle tabs in the open -book. Book switching is bounded at the shelf edges; it does not wrap around. +book. Both axes wrap around, matching the `⌘⌃` arrow keys. Plain scrolling inside the terminal keeps working normally because Shelf only consumes the gesture while `⌘` is held. @@ -105,5 +105,5 @@ See [view-modes](view-modes.md). an agent is running from a different working directory, the full Active Agents panel still shows the resolved repository/branch context. - Closing the last tab of a book removes the book from the shelf. -- The book-switching keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only - by arrow direction — be precise when describing them. +- The book-cycling keys (`⌘⌃←/→`) and tab-cycling keys (`⌘⌃↑/↓`) differ only by + arrow direction — be precise when describing them. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift index 91c0b2e1..47bf243e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -84,10 +84,9 @@ func replacementBookAfterClosing( } /// Returns the Shelf book at `offset` positions from the currently open -/// book. Returns nil at the list edges instead of wrapping around, so -/// Shelf navigation behaves like bounded horizontal movement. When -/// there is no open book, offset > 0 picks the first book and offset < 0 -/// picks the last. +/// book (wrapping around the book list). Returns nil if there are no +/// books. When there is no open book, offset > 0 picks the first book +/// and offset < 0 picks the last. func shelfBook( atOffset offset: Int, state: RepositoriesFeature.State @@ -97,8 +96,7 @@ func shelfBook( if let currentID = state.openShelfBookID, let currentIndex = books.firstIndex(where: { $0.id == currentID }) { - let nextIndex = currentIndex + offset - guard books.indices.contains(nextIndex) else { return nil } + let nextIndex = (currentIndex + offset + books.count) % books.count return books[nextIndex] } return offset > 0 ? books.first : books.last diff --git a/supacodeTests/ShelfFeatureTests.swift b/supacodeTests/ShelfFeatureTests.swift index fdae791e..5492b20e 100644 --- a/supacodeTests/ShelfFeatureTests.swift +++ b/supacodeTests/ShelfFeatureTests.swift @@ -336,7 +336,7 @@ struct ShelfFeatureTests { await store.finish() } - @Test(.dependencies) func selectNextShelfBookAtEndIsNoOp() async { + @Test(.dependencies) func selectNextShelfBookWrapsAround() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let wt1 = Worktree( id: "/tmp/repo", @@ -368,10 +368,17 @@ struct ShelfFeatureTests { } await store.send(.selectNextShelfBook) + // Wrapping: next-after-last lands back on the first book. + await store.receive(\.selectWorktree) { + $0.selection = .worktree(wt1.id) + $0.sidebarSelectedWorktreeIDs = [wt1.id] + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() } - @Test(.dependencies) func selectPreviousShelfBookAtStartIsNoOp() async { + @Test(.dependencies) func selectPreviousShelfBookWrapsAround() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let wt1 = Worktree( id: "/tmp/repo", @@ -403,6 +410,13 @@ struct ShelfFeatureTests { } await store.send(.selectPreviousShelfBook) + // Wrapping: previous-before-first lands on the last book. + await store.receive(\.selectWorktree) { + $0.selection = .worktree(wt2.id) + $0.sidebarSelectedWorktreeIDs = [wt2.id] + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() } -- 2.51.2 From 42aee4409e3234c78f3876fdfa9312ad2e4a7e7c Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:19:25 +0900 Subject: [PATCH 389/528] Resync agent detection when the repository list changes isShowingShelf requires a non-empty repository list, so removing the last repository (or loading repositories) can flip Shelf visibility without any isShelfActive mutation. Include those actions in the detection resync predicate so shelf-driven detection doesn't linger after the shelf disappears. --- .../Features/App/Reducer/AppFeature.swift | 5 +++++ .../AppFeatureSettingsChangedTests.swift | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index f1751f39..725d31c0 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -118,6 +118,11 @@ struct AppFeature { case .selectArchivedWorktrees, .selectCanvas, .selectShelf, .selectTabbed, .setTopSegment, .toggleCanvas, .toggleShelf: return true + // `isShowingShelf` also requires a non-empty repository list, so actions + // that add or remove repositories can flip it without touching `isShelfActive`. + case .repositoriesLoaded, .repositoryManagement(.repositoryRemoved), + .repositoryManagement(.openRepositoriesFinished): + return true default: return false } diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 18eb21a9..bd6a31af 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -151,6 +151,25 @@ struct AppFeatureSettingsChangedTests { #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(false)]) } + @Test func repositoryListChangesTriggerShelfVisibilityResync() { + // `isShowingShelf` requires a non-empty repository list, so adding or + // removing repositories must resync detection even though `isShelfActive` + // is untouched. + #expect( + AppFeature.repositoriesActionMayChangeShelfVisibility( + .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) + ) + ) + #expect( + AppFeature.repositoriesActionMayChangeShelfVisibility( + .repositoriesLoaded([], failures: [], roots: [], animated: false) + ) + ) + #expect( + !AppFeature.repositoriesActionMayChangeShelfVisibility(.selectNextShelfBook) + ) + } + @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { var settings = SettingsFeature.State() settings.showActiveAgentTabTitles = true -- 2.51.2 From 20ff746124165492a4c60b6eb8bbd6260c5b657f Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:19:25 +0900 Subject: [PATCH 390/528] Drop leftover monitor flag and redundant footer guard postsFrameChangedNotifications had no observer, and bottomControls is only reached through footer, which already checks hasBottomControls. --- .../Features/Shelf/Views/ShelfSpineView.swift | 48 +++++++++---------- supacode/Features/Shelf/Views/ShelfView.swift | 1 - 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 26e8fef8..81f2ac49 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -231,31 +231,29 @@ struct ShelfSpineView: View { // caller sequences `selectWorktree` → `newTerminal`). Splits only // make sense against a focused surface, so they stay scoped to the // open book. - if hasBottomControls { - if let onNewTab { - ShelfSpineControlButton( - systemImage: "plus", - label: "New Tab", - shortcut: ghosttyShortcuts.display(for: "new_tab"), - action: onNewTab - ) - } - if let onSplitVertical { - ShelfSpineControlButton( - systemImage: "square.split.2x1", - label: "Split Vertically", - shortcut: ghosttyShortcuts.display(for: "new_split:right"), - action: onSplitVertical - ) - } - if let onSplitHorizontal { - ShelfSpineControlButton( - systemImage: "square.split.1x2", - label: "Split Horizontally", - shortcut: ghosttyShortcuts.display(for: "new_split:down"), - action: onSplitHorizontal - ) - } + if let onNewTab { + ShelfSpineControlButton( + systemImage: "plus", + label: "New Tab", + shortcut: ghosttyShortcuts.display(for: "new_tab"), + action: onNewTab + ) + } + if let onSplitVertical { + ShelfSpineControlButton( + systemImage: "square.split.2x1", + label: "Split Vertically", + shortcut: ghosttyShortcuts.display(for: "new_split:right"), + action: onSplitVertical + ) + } + if let onSplitHorizontal { + ShelfSpineControlButton( + systemImage: "square.split.1x2", + label: "Split Horizontally", + shortcut: ghosttyShortcuts.display(for: "new_split:down"), + action: onSplitHorizontal + ) } } diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index b68cdbf7..a41cb2ee 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -303,7 +303,6 @@ private struct ShelfSwipeEventMonitor: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let view = NSView() - view.postsFrameChangedNotifications = true context.coordinator.view = view context.coordinator.installIfNeeded() return view -- 2.51.2 From def6eeed676528f4c7dfa174051c26efa6a50536 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:32:26 +0900 Subject: [PATCH 391/528] Drop the circular backdrop behind spine status markers The status glyph already sits on the agent-tinted slot background, so the opaque circle read as visual noise rather than separation. --- supacode/Features/Shelf/Views/ShelfSpineView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 81f2ac49..1a92f1e8 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -609,9 +609,6 @@ private struct ShelfSpineTabSlot: View { width: ShelfMetrics.agentStatusMarkerSize, height: ShelfMetrics.agentStatusMarkerSize ) - .background { - Circle().fill(.background) - } .accessibilityHidden(true) } -- 2.51.2 From 8cc180fccc4f7034a8373411e2c45fe637e06471 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:40:05 +0900 Subject: [PATCH 392/528] Hide the idle marker and halo the remaining status glyphs Idle is the steady state, so badging it kept a permanent dot on every agent tab and diluted the actionable states. The bare glyphs that remain get a thin dark shadow for legibility over the tab icon's strokes. --- docs/components/shelf.md | 8 +++--- .../Features/Shelf/Views/ShelfSpineView.swift | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/components/shelf.md b/docs/components/shelf.md index 8bc22f5a..04c91c81 100644 --- a/docs/components/shelf.md +++ b/docs/components/shelf.md @@ -66,10 +66,10 @@ selection. - **Unread notification:** a tab slot highlights orange; the spine header shows an orange dot. - **Detected agents:** status markers appear on the owning tab slot. Marker - color/status follows the Active Agents states (Working, Blocked, Done, Idle); - blocked agents are prioritized first when a split tab contains multiple agents. - Shelf keeps agent detection active while visible, even if the Active Agents - panel is hidden. + color/status follows the Active Agents states (Working, Blocked, Done); idle + agents tint the slot but show no corner glyph. Blocked agents are prioritized + first when a split tab contains multiple agents. Shelf keeps agent detection + active while visible, even if the Active Agents panel is hidden. - Spine tint follows the **repository color** when set (toggle `shelfSpineTintFollowsRepositoryColor`); otherwise a fallback (`shelfSpineTintFallback`: neutral or system tint) is used. diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 1a92f1e8..243f95a7 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -602,14 +602,18 @@ private struct ShelfSpineTabSlot: View { @ViewBuilder private func statusMarker(for entry: ActiveAgentEntry) -> some View { - Image(systemName: entry.displayState.shelfSpineStatusSymbol) - .font(.caption2.weight(.bold)) - .foregroundStyle(entry.displayState.foregroundStyle) - .frame( - width: ShelfMetrics.agentStatusMarkerSize, - height: ShelfMetrics.agentStatusMarkerSize - ) - .accessibilityHidden(true) + if let symbol = entry.displayState.shelfSpineStatusSymbol { + Image(systemName: symbol) + .font(.caption2.weight(.bold)) + .foregroundStyle(entry.displayState.foregroundStyle) + // Thin halo so the bare glyph stays legible over the tab icon's strokes. + .shadow(color: .black.opacity(0.4), radius: 1) + .frame( + width: ShelfMetrics.agentStatusMarkerSize, + height: ShelfMetrics.agentStatusMarkerSize + ) + .accessibilityHidden(true) + } } private var helpText: String { @@ -686,12 +690,14 @@ extension AgentDisplayState { } } - fileprivate var shelfSpineStatusSymbol: String { + /// `nil` hides the marker: idle is the steady state, so badging it would + /// keep a permanent glyph on every agent tab and dilute the actionable ones. + fileprivate var shelfSpineStatusSymbol: String? { switch self { case .working: return "bolt.fill" case .blocked: return "exclamationmark" case .done: return "checkmark" - case .idle: return "circle.fill" + case .idle: return nil } } } -- 2.51.2 From 4887c163c3b3b52bf56415afe5ac5a8e9d0c73e5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 21:59:44 +0900 Subject: [PATCH 393/528] Expect detection resync when selecting archived worktrees selectArchivedWorktrees is in the shelf-visibility predicate, so AppFeature now emits syncAgentDetectionEnabled alongside the selection change. The exhaustive TestStore needs to receive it. --- supacodeTests/AppFeatureArchivedSelectionTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index fc55813d..f3e13560 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -45,6 +45,9 @@ struct AppFeatureArchivedSelectionTests { $0.repositories.worktreeHistoryBackStack = [worktree.id] $0.repositories.selection = .archivedWorktrees } + // Leaving the worktree view can flip Shelf visibility, so AppFeature + // resyncs agent detection alongside the selection change. + await store.receive(\.syncAgentDetectionEnabled) await store.receive(\.repositories.delegate.selectedWorktreeChanged) await store.finish() #expect(saved.value.isEmpty) -- 2.51.2 From 4c7e5b05907610e15b8cb9acf765ef8927169ad7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 22:45:57 +0900 Subject: [PATCH 394/528] Capture selected surface synchronously when dispatching palette Ghostty commands The palette dispatches binding actions via an async effect, so by the time the action ran AppKit could have handed first responder to a different pane (usually the leftmost) and the action hit the wrong surface. Capture the target surface synchronously in the reducer and route through a new surface-targeted command. Ported from upstream supabitapp/supacode#337 (palette focus-race part). --- supacode/App/supacodeApp.swift | 6 ++ .../Clients/Terminal/TerminalClient.swift | 7 ++ .../Reducer/AppFeature+CommandPalette.swift | 11 ++- .../WorktreeTerminalManager.swift | 2 + .../Models/WorktreeTerminalState.swift | 7 ++ .../AppFeatureCommandPaletteTests.swift | 88 ++++++++++++++++++- 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 142cd49e..6f50fbcb 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -324,6 +324,12 @@ struct SupacodeApp: App { canvasFocusedWorktreeID: { terminalManager.canvasFocusedWorktreeID }, + selectedSurfaceID: { worktreeID in + guard let state = terminalManager.stateIfExists(for: worktreeID), + let tabID = state.tabManager.selectedTabId + else { return nil } + return state.activeSurfaceID(for: tabID) + }, latestUnreadNotification: { terminalManager.latestUnreadNotificationLocation() }, diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index e7b86806..cd2136ff 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -5,6 +5,10 @@ struct TerminalClient { var send: @MainActor @Sendable (Command) -> Void var events: @MainActor @Sendable () -> AsyncStream var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? + /// Active surface in the selected tab. Lets the reducer capture the target + /// synchronously before an async dispatch races against AppKit focus reshuffle + /// (e.g. when a palette dismisses and the leftmost pane reclaims first responder). + var selectedSurfaceID: @MainActor @Sendable (Worktree.ID) -> UUID? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void @@ -36,6 +40,7 @@ struct TerminalClient { case closeFocusedTab(Worktree) case closeFocusedSurface(Worktree) case performBindingAction(Worktree, action: String) + case performBindingActionOnSurface(Worktree, surfaceID: UUID, action: String) case startSearch(Worktree) case searchSelection(Worktree) case navigateSearchNext(Worktree) @@ -77,6 +82,7 @@ extension TerminalClient: DependencyKey { send: { _ in fatalError("TerminalClient.send not configured") }, events: { fatalError("TerminalClient.events not configured") }, canvasFocusedWorktreeID: { nil }, + selectedSurfaceID: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, @@ -87,6 +93,7 @@ extension TerminalClient: DependencyKey { send: { _ in }, events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, + selectedSurfaceID: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 2523a08d..c7fc339a 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -218,8 +218,17 @@ extension AppFeature { guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } + // Capture the target surface synchronously: the async effect below races + // AppKit's post-dismiss focus reshuffle, which can hand first responder + // to a different pane before the binding action runs. + let command: TerminalClient.Command + if let surfaceID = terminalClient.selectedSurfaceID(worktree.id) { + command = .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: action) + } else { + command = .performBindingAction(worktree, action: action) + } return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: action)) + await terminalClient.send(command) } case .changeFocusedTabIcon(let worktreeID): diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 0a6c0683..264f76ac 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -140,6 +140,8 @@ final class WorktreeTerminalManager { switch command { case .performBindingAction(let worktree, let action): state(for: worktree).performBindingActionOnFocusedSurface(action) + case .performBindingActionOnSurface(let worktree, let surfaceID, let action): + state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) default: return false } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 16bd116a..2385a55c 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -525,6 +525,13 @@ final class WorktreeTerminalState { return true } + @discardableResult + func performBindingAction(_ action: String, onSurfaceID surfaceID: UUID) -> Bool { + guard let surface = surfaces[surfaceID] else { return false } + surface.performBindingAction(action) + return true + } + @discardableResult func navigateSearchOnFocusedSurface(_ direction: GhosttySearchDirection) -> Bool { guard let tabId = tabManager.selectedTabId, diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 3053fcf4..bb10cd56 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -425,12 +425,98 @@ struct AppFeatureCommandPaletteTests { await store.finish() // Two effects run in parallel (.merge) — assert both fire without - // depending on dispatch order. + // depending on dispatch order. With no selected surface available the + // dispatch falls back to the focused-surface command. #expect(sent.value.count == 2) #expect(sent.value.contains(.performBindingAction(worktree, action: "goto_split:right"))) #expect(sent.value.contains(.focusSelectedTab(worktree))) } + @Test(.dependencies) func ghosttyCommandTargetsSelectedSurfaceWhenAvailable() async { + let worktree = makeWorktree( + id: "/tmp/repo-ghostty/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-ghostty" + ) + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let surfaceID = UUID() + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + $0.terminalClient.selectedSurfaceID = { _ in surfaceID } + } + + await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) + await store.finish() + + #expect(sent.value.count == 2) + #expect( + sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: "goto_split:right") + ) + ) + #expect(sent.value.contains(.focusSelectedTab(worktree))) + } + + @Test(.dependencies) func ghosttyCommandCapturesSelectedSurfaceBeforeAsyncDispatch() async { + let worktree = makeWorktree( + id: "/tmp/repo-ghostty/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-ghostty" + ) + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let firstSurface = UUID() + let secondSurface = UUID() + let currentSurface = LockIsolated(firstSurface) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + $0.terminalClient.selectedSurfaceID = { _ in currentSurface.value } + } + + let task = await store.send(.commandPalette(.delegate(.ghosttyCommand("toggle_split_zoom")))) + // Simulates the palette-dismiss focus drift: by the time the async dispatch + // resolves, `selectedSurfaceID` would already point at the leftmost surface. + currentSurface.setValue(secondSurface) + await task.finish() + await store.finish() + + #expect( + sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: firstSurface, action: "toggle_split_zoom") + ) + ) + #expect( + !sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: secondSurface, action: "toggle_split_zoom") + ) + ) + } + @Test(.dependencies) func viewToggleDelegateRestoresTerminalFocusByDefault() async { let worktree = makeWorktree( id: "/tmp/repo-view-toggle/wt-1", -- 2.51.2 From c1877d2c88b2bb847dca1200b1b6d3c52dac0686 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 22:46:09 +0900 Subject: [PATCH 395/528] Bind split zoom to cmd+opt+shift+F Ghostty's default toggle_split_zoom key (cmd+shift+enter) is claimed by the Shelf toggle, whose unbind argument also removed the Ghostty binding, leaving zoom unreachable from the keyboard. Register a managed toggle_split_zoom binding on cmd+opt+shift+F instead: cmd+ctrl+F stays the system fullscreen toggle and cmd+shift+F remains reserved for a future focus mode. Refs onevcat/Prowl#369. --- docs/reference/keyboard-shortcuts.md | 1 + supacode/App/AppShortcuts.swift | 13 +++++++++++++ .../Settings/Views/ShortcutsSettingsView.swift | 5 ++++- supacodeTests/AppShortcutsTests.swift | 1 + 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 94afac63..5c52ed55 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -86,6 +86,7 @@ pane has focus. The Ghostty action each maps to is shown for reference. | Select Pane Down | ⌘⌥↓ | `select_terminal_pane_down` | `goto_split:down` | | Select Pane Left | ⌘⌥← | `select_terminal_pane_left` | `goto_split:left` | | Select Pane Right | ⌘⌥→ | `select_terminal_pane_right` | `goto_split:right` | +| Toggle Split Zoom | ⌘⌥⇧F | `toggle_split_zoom` | `toggle_split_zoom` | ## Terminal engine (Ghostty-managed) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 77bb1665..8dd149fd 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -151,6 +151,7 @@ enum AppShortcuts { static let selectTerminalPaneDown = "select_terminal_pane_down" static let selectTerminalPaneLeft = "select_terminal_pane_left" static let selectTerminalPaneRight = "select_terminal_pane_right" + static let toggleSplitZoom = "toggle_split_zoom" } enum Scope: String { @@ -287,6 +288,9 @@ enum AppShortcuts { static let selectTerminalPaneRight = AppShortcut( keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .option] ) + // ⌘⌃F is the system fullscreen toggle and ⌘⇧F is reserved for a future + // focus mode, so split zoom takes the heavier chord on purpose. + static let toggleSplitZoom = AppShortcut(key: "f", modifiers: [.command, .option, .shift]) static let renameBranch = AppShortcut(key: "m", modifiers: [.command, .shift]) static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) static let arrangeCanvasCards = AppShortcut(key: "r", modifiers: [.command, .option]) @@ -370,6 +374,7 @@ enum AppShortcuts { .init(actionTitle: "Select Pane Down", shortcut: selectTerminalPaneDown), .init(actionTitle: "Select Pane Left", shortcut: selectTerminalPaneLeft), .init(actionTitle: "Select Pane Right", shortcut: selectTerminalPaneRight), + .init(actionTitle: "Toggle Split Zoom", shortcut: toggleSplitZoom), ] static let bindings: [Binding] = [ @@ -733,6 +738,12 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: selectTerminalPaneRight ), + .init( + id: CommandID.toggleSplitZoom, + title: "Toggle Split Zoom", + scope: .configurableAppAction, + shortcut: toggleSplitZoom + ), .init( id: CommandID.commandPalette, title: "Command Palette", @@ -884,6 +895,7 @@ enum AppShortcuts { (CommandID.selectTerminalPaneDown, "goto_split:down"), (CommandID.selectTerminalPaneLeft, "goto_split:left"), (CommandID.selectTerminalPaneRight, "goto_split:right"), + (CommandID.toggleSplitZoom, "toggle_split_zoom"), ] static func ghosttyCLIKeybindArguments(from resolvedKeybindings: ResolvedKeybindingMap) -> [String] { @@ -981,6 +993,7 @@ enum AppShortcuts { selectTerminalPaneDown, selectTerminalPaneLeft, selectTerminalPaneRight, + toggleSplitZoom, ] } diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 319e89f6..37e236e9 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -407,6 +407,8 @@ struct ShortcutsSettingsView: View { return 4 case AppShortcuts.CommandID.selectTerminalPaneRight: return 5 + case AppShortcuts.CommandID.toggleSplitZoom: + return 6 default: return nil } @@ -958,7 +960,8 @@ private enum ShortcutGroup: String, CaseIterable, Identifiable { AppShortcuts.CommandID.selectTerminalPaneUp, AppShortcuts.CommandID.selectTerminalPaneDown, AppShortcuts.CommandID.selectTerminalPaneLeft, - AppShortcuts.CommandID.selectTerminalPaneRight: + AppShortcuts.CommandID.selectTerminalPaneRight, + AppShortcuts.CommandID.toggleSplitZoom: return .terminal default: diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 750b7e98..ee79b157 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -282,6 +282,7 @@ struct AppShortcutsTests { "--keybind=alt+super+arrow_down=goto_split:down", "--keybind=alt+super+arrow_left=goto_split:left", "--keybind=alt+super+arrow_right=goto_split:right", + "--keybind=alt+shift+super+f=toggle_split_zoom", ] { #expect(arguments.contains(argument)) } -- 2.51.2 From 3a6b1b72581e4ea484fd9c4057a3920240d0263c Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 22:46:10 +0900 Subject: [PATCH 396/528] Add per-pane zoom buttons to split panes Hovering a split's drag handle reveals a zoom button in that pane's top-right corner; a zoomed pane keeps a persistent exit-zoom button in the same spot so it's always clear the pane is zoomed and how to leave. The button routes through the existing toggleSplitZoom split action, which also restores focus to the target surface. Closes onevcat/Prowl#369 (pane-zoom part; focus mode is tracked separately). --- docs/components/terminal.md | 11 +++- .../WorktreeTerminalState+Surfaces.swift | 3 + .../Views/TerminalSplitTreeView.swift | 55 ++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 8c920355..86a9f3a2 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -49,12 +49,17 @@ Splitting is handled by Ghostty actions (bind/keys in your Ghostty config): | Focus adjacent pane | `goto_split:left/right/up/down`, or the app shortcuts below | | Resize split | `resize_split::` | | Equalize splits | `equalize_splits` | -| Zoom / maximize a pane | `toggle_split_zoom` | +| Zoom / maximize a pane | `toggle_split_zoom` (app default `⌘⌥⇧F`) | | Close a pane | `close_surface` (Terminal menu → **Close Terminal**) | App-level pane navigation (works inside the terminal too): `⌘[` / `⌘]` previous / -next pane, `⌘⌥↑/↓/←/→` for directional pane focus. Shelf spines also expose -**split vertical / split horizontal** buttons on the open book. +next pane, `⌘⌥↑/↓/←/→` for directional pane focus, `⌘⌥⇧F` to zoom / unzoom the +focused pane. Shelf spines also expose **split vertical / split horizontal** +buttons on the open book. + +Zoom also has a mouse affordance: hovering a pane's top drag handle reveals a +zoom button in the pane's top-right corner, and a zoomed pane keeps a persistent +exit-zoom button in the same spot so it's always clear how to leave zoom. ## Tab titles — important caveat diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 7644ac2a..9107c58f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -298,6 +298,9 @@ extension WorktreeTerminalState { case .equalize: updateTree(tree.equalized(), for: tabId) + + case .toggleZoom(let surfaceId): + _ = performSplitAction(.toggleSplitZoom, for: surfaceId) } } diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 9bed4550..ac9b1e51 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -31,6 +31,7 @@ struct TerminalSplitTreeView: View { SubtreeView( node: node, isRoot: node == tree.root, + zoomedNode: tree.zoomed, pinnedSize: pinnedSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -46,11 +47,13 @@ struct TerminalSplitTreeView: View { case resize(node: SplitTree.Node, ratio: Double) case drop(payloadId: UUID, destinationId: UUID, zone: DropZone) case equalize + case toggleZoom(surfaceId: UUID) } struct SubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false + var zoomedNode: SplitTree.Node? var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) @@ -64,6 +67,7 @@ struct TerminalSplitTreeView: View { LeafView( surfaceView: leafView, isSplit: !isRoot, + isZoomed: zoomedNode == node, isFocused: leafView.id == activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, hasNotification: hasNotification(leafView.id), @@ -95,6 +99,7 @@ struct TerminalSplitTreeView: View { left: { SubtreeView( node: split.left, + zoomedNode: zoomedNode, pinnedSize: leftPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -106,6 +111,7 @@ struct TerminalSplitTreeView: View { right: { SubtreeView( node: split.right, + zoomedNode: zoomedNode, pinnedSize: rightPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -136,6 +142,7 @@ struct TerminalSplitTreeView: View { struct LeafView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool + var isZoomed: Bool = false var isFocused: Bool = true var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let hasNotification: Bool @@ -143,6 +150,8 @@ struct TerminalSplitTreeView: View { let action: (Operation) -> Void @State private var dropState: DropState = .idle + @State private var isHandleHovering = false + @State private var isZoomButtonHovering = false @Shared(.settingsFile) private var settingsFile: SettingsFile private var shouldDim: Bool { @@ -180,7 +189,20 @@ struct TerminalSplitTreeView: View { } .overlay(alignment: .top) { if isSplit { - DragHandle(surfaceView: surfaceView) + DragHandle(surfaceView: surfaceView, isHovering: $isHandleHovering) + } + } + .overlay(alignment: .topTrailing) { + // The zoomed pane keeps a persistent exit button; other panes only + // reveal the zoom affordance while the drag handle (or the button + // itself, to survive the cursor hand-off) is hovered. + if isSplit, isZoomed || isHandleHovering || isZoomButtonHovering { + SplitZoomButton(isZoomed: isZoomed) { + action(.toggleZoom(surfaceId: surfaceView.id)) + } + .onHover { isZoomButtonHovering = $0 } + .onDisappear { isZoomButtonHovering = false } + .padding(6) } } .background { @@ -206,10 +228,39 @@ struct TerminalSplitTreeView: View { } + struct SplitZoomButton: View { + let isZoomed: Bool + let action: () -> Void + @Environment(\.resolvedKeybindings) private var resolvedKeybindings + + var body: some View { + Button(action: action) { + Image( + systemName: isZoomed + ? "arrow.down.right.and.arrow.up.left" + : "arrow.up.left.and.arrow.down.right" + ) + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + .padding(5) + .background(.regularMaterial, in: .circle) + } + .buttonStyle(.plain) + .help( + AppShortcuts.helpText( + title: isZoomed ? "Exit Split Zoom" : "Zoom Split", + commandID: AppShortcuts.CommandID.toggleSplitZoom, + in: resolvedKeybindings + ) + ) + .accessibilityLabel(isZoomed ? "Exit split zoom" : "Zoom split") + } + } + struct DragHandle: View { let surfaceView: GhosttySurfaceView + @Binding var isHovering: Bool private let handleHeight: CGFloat = 10 - @State private var isHovering = false var body: some View { Rectangle() -- 2.51.2 From 958be6bec687347e2e3f3005443658fded2558fa Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 23:17:14 +0900 Subject: [PATCH 397/528] Use a rounded-rect background for the pane zoom button --- supacode/Features/Terminal/Views/TerminalSplitTreeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index ac9b1e51..7a0b0be7 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -243,7 +243,7 @@ struct TerminalSplitTreeView: View { .font(.callout.weight(.semibold)) .foregroundStyle(.primary) .padding(5) - .background(.regularMaterial, in: .circle) + .background(.regularMaterial, in: .rect(cornerRadius: 6)) } .buttonStyle(.plain) .help( -- 2.51.2 From a12c094bcf4376f0169175d9a6bc464871fd64da Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 11 Jun 2026 00:03:01 +0900 Subject: [PATCH 398/528] Sync docs for 2026.6.11 --- docs/.sync-meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 412259e1..e074ec70 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "2948302ef96b03c17be1ff929e3753743ac39e7a", - "last_synced_date": "2026-06-09", - "note": "No doc edits needed: the two user-facing features in range — GitHub merge-queue status in the PR popover/sidebar/badges (#425) and per-worktree custom name/parent-folder override (#424) — were already documented in their feature PRs (github-pull-requests.md merge-queue note, repositories-and-worktrees.md Advanced section), and verified against PullRequestMergeQueueStatus.swift. Remaining changes are perf/polish/bug-fixes (event coalescing, sheet/palette flicker fixes, window-position persistence, focus-after-nav) that don't change documented behavior. Baseline bump only." + "last_synced_commit": "6da2ba4a0b2f88923aaa1b72282b5fa41a83706d", + "last_synced_date": "2026-06-11", + "note": "No doc edits needed: the two user-facing features in range — split zoom with cmd+opt+shift+F and per-pane zoom buttons (#435), and Shelf agent status badges + cmd-held trackpad book/tab swiping (#434) — were already documented in their feature PRs (terminal.md, keyboard-shortcuts.md, shelf.md, active-agents.md, settings-fields.md showActiveAgentStatusInShelf row), verified against AppShortcuts.swift and GlobalSettings.swift. Remaining changes are internal fixes (windowless stall diagnostics, Sentry HTTP filtering, detection resync, worktree Advanced field labels). Baseline bump only." } -- 2.51.2 From 5a8e6611becbf15f58c57176dacd835937c10ab2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 11 Jun 2026 00:17:17 +0900 Subject: [PATCH 399/528] bump v2026.6.11 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 6dfc4b10..302df799 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260611; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.9; + MARKETING_VERSION = 2026.6.11; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260611; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.9; + MARKETING_VERSION = 2026.6.11; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260611; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.9; + MARKETING_VERSION = 2026.6.11; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260609; + CURRENT_PROJECT_VERSION = 20260611; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.9; + MARKETING_VERSION = 2026.6.11; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 1b7d30a8..d01a52dc 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.9" + public static let current = "2026.6.11" } -- 2.51.2 From d31f7b1acae0e5d07626f36096856c9daa5d0323 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 11 Jun 2026 00:17:18 +0900 Subject: [PATCH 400/528] Update CHANGELOG for 2026.6.11 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6546f9d4..7b0c6a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2026.6.11](https://github.com/onevcat/Prowl/releases/tag/v2026.6.11) + +This release adds split-pane zoom controls, Shelf agent status badges, and trackpad navigation for the Shelf. + +### New + +- Hovering a split pane's drag handle now reveals a zoom button in its top-right corner. Clicking it expands that pane to fill the tab; a persistent exit button remains visible while zoomed. Press `⌘⌥⇧F` to toggle zoom from the keyboard, or use "Toggle Split Zoom" from the Command Palette. +- Shelf spines now overlay agent status badges on their tab icons, so you can see every agent's state at a glance while flipping through the stack. Toggle via "Show agent status in Shelf tabs" in Settings. +- Hold `⌘` and swipe on a trackpad in the Shelf: horizontal swipes flip between books, vertical swipes cycle the open book's tabs. + +### Fixed + +- The `⌘⌃←` / `⌘⌃→` keyboard shortcuts for Shelf book navigation wrap around again as expected; this had been silently broken. +- Command Palette actions that send key bindings into a terminal pane now reliably target the pane that was focused when the palette was invoked, rather than whichever pane AppKit happened to focus by the time the action ran. +- Agent working/idle detection now resyncs correctly when repositories are added or removed. + ## [2026.6.9](https://github.com/onevcat/Prowl/releases/tag/v2026.6.9) ### New -- 2.51.2 From 4e7149f7658d1e20479da35ba1d1b59ad9844411 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 12 Jun 2026 00:47:02 +0900 Subject: [PATCH 401/528] Support per-repo GitHub CLI identities --- docs/components/github-pull-requests.md | 14 +- docs/reference/settings-fields.md | 1 + supacode/Clients/Github/GithubCLIClient.swift | 551 +++++++++++++----- supacode/Clients/Github/GithubCLIModels.swift | 98 ++++ .../PullRequestRefreshCoordinator.swift | 79 ++- ...epositoriesFeature+GithubIntegration.swift | 40 +- .../Reducer/RepositorySettingsFeature.swift | 1 + .../Settings/Models/RepositorySettings.swift | 7 + .../Settings/Views/GithubSettingsView.swift | 39 +- .../Views/RepositorySettingsView.swift | 37 ++ ...atchedPullRequestRefreshReducerTests.swift | 2 +- supacodeTests/GithubCLIClientTests.swift | 117 +++- supacodeTests/GithubCLIOutputTests.swift | 23 + .../PullRequestRefreshCoordinatorTests.swift | 39 +- supacodeTests/RepositoriesFeatureTests.swift | 18 +- .../RepositorySettingsFeatureTests.swift | 10 + 16 files changed, 842 insertions(+), 234 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index b643bfed..4387e8ae 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -53,13 +53,23 @@ Open the [Command Palette](command-palette.md) (`⌘P`) on a worktree that has a - The **`gh` CLI** must be installed and authenticated (`gh auth login`). - `githubIntegrationEnabled` (global) gates all GitHub features. - Per repo: `fetchPullRequestState` (auto-fetch PR state; on by default — turn off - for big/expensive repos), `pullRequestMergeStrategy` override. -- Settings → **GitHub** tab holds the integration options. + for big/expensive repos), `pullRequestMergeStrategy` override, and + `githubAccountOverride` for repositories that need a specific `gh` account. +- Settings → **GitHub** tab shows every authenticated `gh` host/account and which + account is active for each host. + +When a repository has `githubAccountOverride` set, Prowl temporarily runs +`gh auth switch --hostname --user ` before GitHub operations for +that repository, then switches the host back to the previously active account. +This uses `gh`'s stored authentication state; Prowl still never reads or stores +GitHub tokens. ## Gotchas for agents - No `gh` / not authenticated → no PR features. If a human expects PR actions and they're missing, check `gh auth status`. +- If a repo is pinned to a specific GitHub identity and PR actions fail, verify + that `gh auth status` lists that account on the repo's host. - PR actions appear in the palette **only when the selected worktree's branch has a PR**. No PR → no actions. - "Copy CI Failure Logs" is the high-value loop for agents: copy logs → feed to the diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 34b0125a..0336c778 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -80,6 +80,7 @@ Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state | `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | | `copyUntrackedOnWorktreeCreate` | Bool? | `nil` | Copy untracked files; `nil` = use global. | | `pullRequestMergeStrategy` | enum? | `nil` | PR merge strategy; `nil` = use global. | +| `githubAccountOverride` | object? | `nil` | Optional `{ "host": "...", "login": "..." }`; Prowl temporarily switches `gh` to this account for GitHub operations in this repo. | | `customTitle` | String? | `nil` | Display name override for the repository. | | `observeLineDiffsAutomatically` | Bool? | `nil` (= on) | Keep worktree line-change badges updated; set `false` for large repos. | | `fetchPullRequestState` | Bool? | `nil` (= on) | Background-fetch PR state; set `false` to save GitHub rate limit. | diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index 913aad59..f64ed10e 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -135,8 +135,9 @@ enum GithubAuthStatusParsing { if rhs == "github.com" { return false } return lhs < rhs } + let snapshot = GithubAuthStatusSnapshot(response: response) for host in orderedHosts { - if let active = response.hosts[host]?.first(where: { $0.active }) { + if let active = snapshot.activeAccount(on: host) { return (host, active.login) } } @@ -144,20 +145,50 @@ enum GithubAuthStatusParsing { } } +actor GithubAccountSwitchLock { + static let shared = GithubAccountSwitchLock() + + private var lockedHosts: Set = [] + private var waitersByHost: [String: [CheckedContinuation]] = [:] + + func acquire(host: String) async { + if !lockedHosts.contains(host) { + lockedHosts.insert(host) + return + } + await withCheckedContinuation { continuation in + waitersByHost[host, default: []].append(continuation) + } + } + + func release(host: String) { + guard var waiters = waitersByHost[host], !waiters.isEmpty else { + lockedHosts.remove(host) + return + } + let next = waiters.removeFirst() + waitersByHost[host] = waiters.isEmpty ? nil : waiters + next.resume() + } +} + struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var resolveRemoteInfo: @Sendable (URL) async -> GithubRemoteInfo? - var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? - var batchPullRequests: @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] + var latestRun: @Sendable (URL, String, GithubAccountOverride?) async throws -> GithubWorkflowRun? + var batchPullRequests: + @Sendable (String, String, String, [String], GithubAccountOverride?) async throws -> [String: GithubPullRequest] var batchPullRequestsAcrossRepositories: - @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> CrossRepoPullRequestResult - var mergePullRequest: @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy) async throws -> Void - var closePullRequest: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void - var markPullRequestReady: @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void - var rerunFailedJobs: @Sendable (URL, Int) async throws -> Void - var failedRunLogs: @Sendable (URL, Int) async throws -> String - var runLogs: @Sendable (URL, Int) async throws -> String + @Sendable (String, [CrossRepoPullRequestRequest], GithubAccountOverride?) async throws -> CrossRepoPullRequestResult + var mergePullRequest: + @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy, GithubAccountOverride?) async throws -> Void + var closePullRequest: @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void + var markPullRequestReady: @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void + var rerunFailedJobs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> Void + var failedRunLogs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> String + var runLogs: @Sendable (URL, Int, GithubAccountOverride?) async throws -> String var isAvailable: @Sendable () async -> Bool + var authStatusSnapshot: @Sendable () async throws -> GithubAuthStatusSnapshot var authStatus: @Sendable () async throws -> GithubAuthStatus? } @@ -179,6 +210,7 @@ extension GithubCLIClient: DependencyKey { failedRunLogs: failedRunLogsFetcher(shell: shell, resolver: resolver), runLogs: runLogsFetcher(shell: shell, resolver: resolver), isAvailable: isAvailableFetcher(shell: shell, resolver: resolver), + authStatusSnapshot: authStatusSnapshotFetcher(shell: shell, resolver: resolver), authStatus: authStatusFetcher(shell: shell, resolver: resolver) ) } @@ -186,16 +218,33 @@ extension GithubCLIClient: DependencyKey { static let testValue = GithubCLIClient( defaultBranch: { _ in "main" }, resolveRemoteInfo: { _ in nil }, - latestRun: { _, _ in nil }, - batchPullRequests: { _, _, _, _ in [:] }, - batchPullRequestsAcrossRepositories: { _, _ in CrossRepoPullRequestResult() }, - mergePullRequest: { _, _, _, _ in }, - closePullRequest: { _, _, _ in }, - markPullRequestReady: { _, _, _ in }, - rerunFailedJobs: { _, _ in }, - failedRunLogs: { _, _ in "" }, - runLogs: { _, _ in "" }, + latestRun: { _, _, _ in nil }, + batchPullRequests: { _, _, _, _, _ in [:] }, + batchPullRequestsAcrossRepositories: { _, _, _ in CrossRepoPullRequestResult() }, + mergePullRequest: { _, _, _, _, _ in }, + closePullRequest: { _, _, _, _ in }, + markPullRequestReady: { _, _, _, _ in }, + rerunFailedJobs: { _, _, _ in }, + failedRunLogs: { _, _, _ in "" }, + runLogs: { _, _, _ in "" }, isAvailable: { true }, + authStatusSnapshot: { + GithubAuthStatusSnapshot( + hosts: [ + "github.com": [ + GithubAuthAccountStatus( + host: "github.com", + login: "testuser", + active: true, + state: "success", + gitProtocol: "ssh", + scopes: nil, + tokenSource: nil + ) + ] + ] + ) + }, authStatus: { GithubAuthStatus(username: "testuser", host: "github.com") } ) } @@ -251,55 +300,64 @@ nonisolated private func resolveRemoteInfoFetcher( nonisolated private func latestRunFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, String) async throws -> GithubWorkflowRun? { - { repoRoot, branch in - let output = try await runGh( - shell: shell, - resolver: resolver, - arguments: [ - "run", - "list", - "--branch", - branch, - "--limit", - "1", - "--json", - "databaseId,workflowName,name,displayTitle,status,conclusion,createdAt,updatedAt", - ], - repoRoot: repoRoot - ) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - // nil payload means no runs; a present-but-undecodable payload still throws. - let runs = try GithubCLIOutput.decodeIfPresent([GithubWorkflowRun].self, from: output, decoder: decoder) - return runs?.first +) -> @Sendable (URL, String, GithubAccountOverride?) async throws -> GithubWorkflowRun? { + { repoRoot, branch, accountOverride in + try await withExpectedGithubAccount(shell: shell, resolver: resolver, accountOverride: accountOverride) { + let output = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "run", + "list", + "--branch", + branch, + "--limit", + "1", + "--json", + "databaseId,workflowName,name,displayTitle,status,conclusion,createdAt,updatedAt", + ], + repoRoot: repoRoot + ) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + // nil payload means no runs; a present-but-undecodable payload still throws. + let runs = try GithubCLIOutput.decodeIfPresent([GithubWorkflowRun].self, from: output, decoder: decoder) + return runs?.first + } } } nonisolated private func batchPullRequestsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (String, String, String, [String]) async throws -> [String: GithubPullRequest] { - { host, owner, repo, branches in - let dedupedBranches = deduplicatedBranches(branches) - guard !dedupedBranches.isEmpty else { - return [:] - } - let request = GithubPullRequestsRequest(host: host, owner: owner, repo: repo) - let chunks = makeBranchChunks( - dedupedBranches, - chunkSize: batchPullRequestsChunkSize - ) - let chunkResults = try await loadPullRequestChunks( +) -> @Sendable (String, String, String, [String], GithubAccountOverride?) async throws -> [String: GithubPullRequest] { + { host, owner, repo, branches, accountOverride in + try await withExpectedGithubAccount( shell: shell, resolver: resolver, - request: request, - chunks: chunks - ) - return mergePullRequestChunkResults( - chunkResults, - chunkCount: chunks.count - ) + host: host, + accountOverride: accountOverride + ) { + let dedupedBranches = deduplicatedBranches(branches) + guard !dedupedBranches.isEmpty else { + return [:] + } + let request = GithubPullRequestsRequest(host: host, owner: owner, repo: repo) + let chunks = makeBranchChunks( + dedupedBranches, + chunkSize: batchPullRequestsChunkSize + ) + let chunkResults = try await loadPullRequestChunks( + shell: shell, + resolver: resolver, + request: request, + chunks: chunks + ) + return mergePullRequestChunkResults( + chunkResults, + chunkCount: chunks.count + ) + } } } @@ -314,20 +372,30 @@ nonisolated private struct CrossRepoChunkOutcome: Sendable { nonisolated private func batchPullRequestsAcrossRepositoriesFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (String, [CrossRepoPullRequestRequest]) async throws -> CrossRepoPullRequestResult { - { host, requests in - let cleaned = sanitizeCrossRepoRequests(requests) - guard !cleaned.isEmpty else { - return CrossRepoPullRequestResult() - } - let chunks = makeCrossRepoChunks(cleaned, chunkSize: crossRepoBatchAliasLimit) - let outcomes = try await loadCrossRepoChunks( +) + -> @Sendable (String, [CrossRepoPullRequestRequest], GithubAccountOverride?) async throws -> + CrossRepoPullRequestResult +{ + { host, requests, accountOverride in + try await withExpectedGithubAccount( shell: shell, resolver: resolver, host: host, - chunks: chunks - ) - return mergeCrossRepoChunkResults(outcomes) + accountOverride: accountOverride + ) { + let cleaned = sanitizeCrossRepoRequests(requests) + guard !cleaned.isEmpty else { + return CrossRepoPullRequestResult() + } + let chunks = makeCrossRepoChunks(cleaned, chunkSize: crossRepoBatchAliasLimit) + let outcomes = try await loadCrossRepoChunks( + shell: shell, + resolver: resolver, + host: host, + chunks: chunks + ) + return mergeCrossRepoChunkResults(outcomes) + } } } @@ -643,55 +711,76 @@ nonisolated private func rankCrossRepoPullRequests( nonisolated private func mergePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy) async throws -> Void { - { repoRoot, remoteInfo, pullRequestNumber, strategy in - _ = try await runGh( +) -> @Sendable (URL, GithubRemoteInfo, Int, PullRequestMergeStrategy, GithubAccountOverride?) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber, strategy, accountOverride in + try await withExpectedGithubAccount( shell: shell, resolver: resolver, - arguments: [ - "pr", - "merge", - "\(pullRequestNumber)", - "--\(strategy.ghArgument)", - ] + repoArgument(remoteInfo), - repoRoot: repoRoot - ) + host: remoteInfo.host, + accountOverride: accountOverride + ) { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "pr", + "merge", + "\(pullRequestNumber)", + "--\(strategy.ghArgument)", + ] + repoArgument(remoteInfo), + repoRoot: repoRoot + ) + } } } nonisolated private func closePullRequestFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void { - { repoRoot, remoteInfo, pullRequestNumber in - _ = try await runGh( +) -> @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber, accountOverride in + try await withExpectedGithubAccount( shell: shell, resolver: resolver, - arguments: [ - "pr", - "close", - "\(pullRequestNumber)", - ] + repoArgument(remoteInfo), - repoRoot: repoRoot - ) + host: remoteInfo.host, + accountOverride: accountOverride + ) { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "pr", + "close", + "\(pullRequestNumber)", + ] + repoArgument(remoteInfo), + repoRoot: repoRoot + ) + } } } nonisolated private func markPullRequestReadyFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, GithubRemoteInfo, Int) async throws -> Void { - { repoRoot, remoteInfo, pullRequestNumber in - _ = try await runGh( +) -> @Sendable (URL, GithubRemoteInfo, Int, GithubAccountOverride?) async throws -> Void { + { repoRoot, remoteInfo, pullRequestNumber, accountOverride in + try await withExpectedGithubAccount( shell: shell, resolver: resolver, - arguments: [ - "pr", - "ready", - "\(pullRequestNumber)", - ] + repoArgument(remoteInfo), - repoRoot: repoRoot - ) + host: remoteInfo.host, + accountOverride: accountOverride + ) { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "pr", + "ready", + "\(pullRequestNumber)", + ] + repoArgument(remoteInfo), + repoRoot: repoRoot + ) + } } } @@ -702,57 +791,63 @@ nonisolated private func repoArgument(_ remoteInfo: GithubRemoteInfo) -> [String nonisolated private func rerunFailedJobsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> Void { - { repoRoot, runID in - _ = try await runGh( - shell: shell, - resolver: resolver, - arguments: [ - "run", - "rerun", - "\(runID)", - "--failed", - ], - repoRoot: repoRoot - ) +) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> Void { + { repoRoot, runID, accountOverride in + try await withExpectedGithubAccount(shell: shell, resolver: resolver, accountOverride: accountOverride) { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "run", + "rerun", + "\(runID)", + "--failed", + ], + repoRoot: repoRoot + ) + } } } nonisolated private func failedRunLogsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> String { - { repoRoot, runID in - try await runGh( - shell: shell, - resolver: resolver, - arguments: [ - "run", - "view", - "\(runID)", - "--log-failed", - ], - repoRoot: repoRoot - ) +) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> String { + { repoRoot, runID, accountOverride in + try await withExpectedGithubAccount(shell: shell, resolver: resolver, accountOverride: accountOverride) { + try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "run", + "view", + "\(runID)", + "--log-failed", + ], + repoRoot: repoRoot + ) + } } } nonisolated private func runLogsFetcher( shell: ShellClient, resolver: GithubCLIExecutableResolver -) -> @Sendable (URL, Int) async throws -> String { - { repoRoot, runID in - try await runGh( - shell: shell, - resolver: resolver, - arguments: [ - "run", - "view", - "\(runID)", - "--log", - ], - repoRoot: repoRoot - ) +) -> @Sendable (URL, Int, GithubAccountOverride?) async throws -> String { + { repoRoot, runID, accountOverride in + try await withExpectedGithubAccount(shell: shell, resolver: resolver, accountOverride: accountOverride) { + try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "run", + "view", + "\(runID)", + "--log", + ], + repoRoot: repoRoot + ) + } } } @@ -780,13 +875,7 @@ nonisolated private func authStatusFetcher( resolver: GithubCLIExecutableResolver ) -> @Sendable () async throws -> GithubAuthStatus? { { - let output = try await runGh( - shell: shell, - resolver: resolver, - arguments: ["auth", "status", "--json", "hosts"], - repoRoot: nil - ) - let response = try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: output) + let response = try await loadAuthStatusResponse(shell: shell, resolver: resolver) guard let active = GithubAuthStatusParsing.activeAccount(in: response) else { return nil } @@ -794,6 +883,172 @@ nonisolated private func authStatusFetcher( } } +nonisolated private func authStatusSnapshotFetcher( + shell: ShellClient, + resolver: GithubCLIExecutableResolver +) -> @Sendable () async throws -> GithubAuthStatusSnapshot { + { + let response = try await loadAuthStatusResponse(shell: shell, resolver: resolver) + return GithubAuthStatusSnapshot(response: response) + } +} + +nonisolated private func loadAuthStatusResponse( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + host: String? = nil, + activeOnly: Bool = false +) async throws -> GithubAuthStatusResponse { + var arguments = ["auth", "status"] + if activeOnly { + arguments.append("--active") + } + if let host { + arguments.append(contentsOf: ["--hostname", host]) + } + arguments.append(contentsOf: ["--json", "hosts"]) + let output = try await runGh( + shell: shell, + resolver: resolver, + arguments: arguments, + repoRoot: nil + ) + return try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: output) +} + +nonisolated private func withExpectedGithubAccount( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + host: String? = nil, + accountOverride: GithubAccountOverride?, + operation: () async throws -> Value +) async throws -> Value { + guard let accountOverride = accountOverride?.normalized else { + return try await operation() + } + if let host, host != accountOverride.host { + throw GithubCLIError.commandFailed( + "This repository uses \(host), but its GitHub account override is configured for " + + "\(accountOverride.host)/\(accountOverride.login). Update the repository's GitHub identity setting." + ) + } + await GithubAccountSwitchLock.shared.acquire(host: accountOverride.host) + let previousLogin: String? + do { + previousLogin = try await activeGithubLogin( + shell: shell, + resolver: resolver, + host: accountOverride.host + ) + if previousLogin != accountOverride.login { + try await switchGithubAccount( + shell: shell, + resolver: resolver, + accountOverride: accountOverride + ) + } + } catch { + await GithubAccountSwitchLock.shared.release(host: accountOverride.host) + throw error + } + do { + let value = try await operation() + await restoreGithubAccountIfNeeded( + shell: shell, + resolver: resolver, + host: accountOverride.host, + previousLogin: previousLogin, + targetLogin: accountOverride.login + ) + await GithubAccountSwitchLock.shared.release(host: accountOverride.host) + return value + } catch { + await restoreGithubAccountIfNeeded( + shell: shell, + resolver: resolver, + host: accountOverride.host, + previousLogin: previousLogin, + targetLogin: accountOverride.login + ) + await GithubAccountSwitchLock.shared.release(host: accountOverride.host) + throw error + } +} + +nonisolated private func activeGithubLogin( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + host: String +) async throws -> String? { + let response = try await loadAuthStatusResponse( + shell: shell, + resolver: resolver, + host: host, + activeOnly: true + ) + let snapshot = GithubAuthStatusSnapshot(response: response) + return snapshot.activeAccount(on: host)?.login +} + +nonisolated private func switchGithubAccount( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + accountOverride: GithubAccountOverride +) async throws { + do { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "auth", + "switch", + "--hostname", + accountOverride.host, + "--user", + accountOverride.login, + ], + repoRoot: nil + ) + } catch { + throw GithubCLIError.commandFailed( + "Prowl is configured to use \(accountOverride.login) on \(accountOverride.host), " + + "but gh could not switch to that account. Run " + + "`gh auth switch --hostname \(accountOverride.host) --user \(accountOverride.login)` and try again." + ) + } +} + +nonisolated private func restoreGithubAccountIfNeeded( + shell: ShellClient, + resolver: GithubCLIExecutableResolver, + host: String, + previousLogin: String?, + targetLogin: String +) async { + guard let previousLogin, previousLogin != targetLogin else { + return + } + do { + _ = try await runGh( + shell: shell, + resolver: resolver, + arguments: [ + "auth", + "switch", + "--hostname", + host, + "--user", + previousLogin, + ], + repoRoot: nil + ) + } catch { + SupaLogger("GithubCLI").warning( + "Failed to restore gh active account for \(host) to \(previousLogin): \(error.localizedDescription)" + ) + } +} + nonisolated private func deduplicatedBranches(_ branches: [String]) -> [String] { var seen = Set() return branches.filter { !$0.isEmpty && seen.insert($0).inserted } diff --git a/supacode/Clients/Github/GithubCLIModels.swift b/supacode/Clients/Github/GithubCLIModels.swift index a6ff9c25..c7b93f55 100644 --- a/supacode/Clients/Github/GithubCLIModels.swift +++ b/supacode/Clients/Github/GithubCLIModels.swift @@ -1,16 +1,104 @@ import Foundation +nonisolated struct GithubAccountOverride: Codable, Equatable, Hashable, Sendable { + let host: String + let login: String + + init(host: String, login: String) { + self.host = host + self.login = login + } + + var normalized: GithubAccountOverride? { + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedLogin = login.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty, !trimmedLogin.isEmpty else { + return nil + } + return GithubAccountOverride(host: trimmedHost, login: trimmedLogin) + } +} + struct GithubAuthStatus: Equatable, Sendable { let username: String let host: String } +nonisolated struct GithubAuthStatusSnapshot: Equatable, Sendable { + let hosts: [String: [GithubAuthAccountStatus]] + + init(hosts: [String: [GithubAuthAccountStatus]]) { + self.hosts = hosts + } + + init(response: GithubAuthStatusResponse) { + hosts = Dictionary( + uniqueKeysWithValues: response.hosts.map { host, accounts in + let statuses = accounts.map { + GithubAuthAccountStatus( + host: $0.host.isEmpty ? host : $0.host, + login: $0.login, + active: $0.active, + state: $0.state, + gitProtocol: $0.gitProtocol, + scopes: $0.scopes, + tokenSource: $0.tokenSource + ) + } + return (host, statuses) + }) + } + + var sortedHosts: [String] { + hosts.keys.sorted { lhs, rhs in + if lhs == "github.com" { return true } + if rhs == "github.com" { return false } + return lhs < rhs + } + } + + var allAccounts: [GithubAuthAccountStatus] { + sortedHosts.flatMap { accounts(on: $0) } + } + + func accounts(on host: String) -> [GithubAuthAccountStatus] { + hosts[host] ?? [] + } + + func activeAccount(on host: String) -> GithubAuthAccountStatus? { + accounts(on: host).first(where: \.active) + } +} + +nonisolated struct GithubAuthAccountStatus: Equatable, Identifiable, Sendable { + let host: String + let login: String + let active: Bool + let state: String? + let gitProtocol: String? + let scopes: String? + let tokenSource: String? + + var id: String { + "\(host)/\(login)" + } + + var override: GithubAccountOverride { + GithubAccountOverride(host: host, login: login) + } +} + struct GithubAuthStatusResponse: Sendable { let hosts: [String: [GithubAuthAccount]] struct GithubAuthAccount: Sendable { + let host: String let active: Bool let login: String + let state: String? + let gitProtocol: String? + let scopes: String? + let tokenSource: String? } } @@ -27,14 +115,24 @@ extension GithubAuthStatusResponse: Decodable { extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { private enum CodingKeys: String, CodingKey { + case host case active case login + case state + case gitProtocol + case scopes + case tokenSource } nonisolated init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" self.active = try container.decode(Bool.self, forKey: .active) self.login = try container.decode(String.self, forKey: .login) + state = try container.decodeIfPresent(String.self, forKey: .state) + gitProtocol = try container.decodeIfPresent(String.self, forKey: .gitProtocol) + scopes = try container.decodeIfPresent(String.self, forKey: .scopes) + tokenSource = try container.decodeIfPresent(String.self, forKey: .tokenSource) } } diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index ecfb0610..824463ed 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -9,6 +9,7 @@ final class PullRequestRefreshCoordinator { let host: String let owner: String let repo: String + let accountOverride: GithubAccountOverride? let branches: [String] let worktreeIDs: [Worktree.ID] } @@ -33,10 +34,15 @@ final class PullRequestRefreshCoordinator { private let softTimeout: Duration private let resultHandler: @MainActor (Outcome) -> Void - private var pendingByHost: [String: [Repository.ID: Request]] = [:] - private var flushTaskByHost: [String: Task] = [:] - private var inflightHosts: Set = [] - private var queuedByHost: [String: [Repository.ID: Request]] = [:] + private struct BatchKey: Hashable, Sendable { + let host: String + let accountOverride: GithubAccountOverride? + } + + private var pendingByHost: [BatchKey: [Repository.ID: Request]] = [:] + private var flushTaskByHost: [BatchKey: Task] = [:] + private var inflightHosts: Set = [] + private var queuedByHost: [BatchKey: [Repository.ID: Request]] = [:] init( githubCLI: GithubCLIClient, @@ -68,23 +74,28 @@ final class PullRequestRefreshCoordinator { host: request.host, owner: request.owner, repo: request.repo, + accountOverride: request.accountOverride, branches: cleanedBranches, worktreeIDs: request.worktreeIDs ) - if inflightHosts.contains(normalized.host) { + let key = BatchKey(host: normalized.host, accountOverride: normalized.accountOverride) + if inflightHosts.contains(key) { mergeRequest(normalized, into: &queuedByHost) return } mergeRequest(normalized, into: &pendingByHost) - rescheduleDebounce(forHost: normalized.host) + rescheduleDebounce(forKey: key) } func cancelHost(_ host: String) { - flushTaskByHost.removeValue(forKey: host)?.cancel() - pendingByHost.removeValue(forKey: host) - queuedByHost.removeValue(forKey: host) + for key in flushTaskByHost.keys where key.host == host { + flushTaskByHost.removeValue(forKey: key)?.cancel() + } + pendingByHost = pendingByHost.filter { $0.key.host != host } + queuedByHost = queuedByHost.filter { $0.key.host != host } + inflightHosts = inflightHosts.filter { $0.host != host } } func reset() { @@ -99,9 +110,10 @@ final class PullRequestRefreshCoordinator { private func mergeRequest( _ request: Request, - into bucket: inout [String: [Repository.ID: Request]] + into bucket: inout [BatchKey: [Repository.ID: Request]] ) { - var hostBucket = bucket[request.host] ?? [:] + let key = BatchKey(host: request.host, accountOverride: request.accountOverride) + var hostBucket = bucket[key] ?? [:] if let existing = hostBucket[request.repositoryID] { var seen = Set(existing.branches) var combined = existing.branches @@ -119,44 +131,45 @@ final class PullRequestRefreshCoordinator { host: request.host, owner: request.owner, repo: request.repo, + accountOverride: request.accountOverride, branches: combined, worktreeIDs: workCombined ) } else { hostBucket[request.repositoryID] = request } - bucket[request.host] = hostBucket + bucket[key] = hostBucket } - private func rescheduleDebounce(forHost host: String) { - flushTaskByHost.removeValue(forKey: host)?.cancel() + private func rescheduleDebounce(forKey key: BatchKey) { + flushTaskByHost.removeValue(forKey: key)?.cancel() let task = Task { [weak self, debounceWindow, clock] in do { try await clock.sleep(for: debounceWindow) } catch { return } - await self?.flush(host: host) + await self?.flush(key: key) } - flushTaskByHost[host] = task + flushTaskByHost[key] = task } - private func flush(host: String) async { - flushTaskByHost.removeValue(forKey: host) - guard let bucket = pendingByHost.removeValue(forKey: host), !bucket.isEmpty else { + private func flush(key: BatchKey) async { + flushTaskByHost.removeValue(forKey: key) + guard let bucket = pendingByHost.removeValue(forKey: key), !bucket.isEmpty else { return } - inflightHosts.insert(host) + inflightHosts.insert(key) let requests = Array(bucket.values) - await processBatch(host: host, requests: requests) - inflightHosts.remove(host) - if let queued = queuedByHost.removeValue(forKey: host), !queued.isEmpty { - pendingByHost[host, default: [:]].merge(queued) { _, new in new } - await flush(host: host) + await processBatch(key: key, requests: requests) + inflightHosts.remove(key) + if let queued = queuedByHost.removeValue(forKey: key), !queued.isEmpty { + pendingByHost[key, default: [:]].merge(queued) { _, new in new } + await flush(key: key) } } - private func processBatch(host: String, requests: [Request]) async { + private func processBatch(key: BatchKey, requests: [Request]) async { let groupsByKey = groupRequestsByRepo(requests) let crossRepoRequests = groupsByKey.values.map { group in CrossRepoPullRequestRequest( @@ -166,7 +179,11 @@ final class PullRequestRefreshCoordinator { ) } do { - let result = try await runBatchWithTimeout(host: host, requests: crossRepoRequests) + let result = try await runBatchWithTimeout( + host: key.host, + requests: crossRepoRequests, + accountOverride: key.accountOverride + ) for (key, prsByBranch) in result.successByRepo { guard let group = groupsByKey[key] else { continue @@ -208,14 +225,15 @@ final class PullRequestRefreshCoordinator { private func runBatchWithTimeout( host: String, - requests: [CrossRepoPullRequestRequest] + requests: [CrossRepoPullRequestRequest], + accountOverride: GithubAccountOverride? ) async throws -> CrossRepoPullRequestResult { try await withThrowingTaskGroup(of: BatchTimeoutOutcome.self) { group in let githubCLI = self.githubCLI let softTimeout = self.softTimeout let clock = self.clock group.addTask { - let value = try await githubCLI.batchPullRequestsAcrossRepositories(host, requests) + let value = try await githubCLI.batchPullRequestsAcrossRepositories(host, requests, accountOverride) return .completed(value) } group.addTask { @@ -241,7 +259,8 @@ final class PullRequestRefreshCoordinator { group.requests[0].host, group.key.owner, group.key.repo, - group.branches + group.branches, + group.requests[0].accountOverride ) for request in group.requests { resultHandler( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index ec81b132..38245aab 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -363,6 +363,7 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Marking PR ready…"))) do { + @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( repositoryRootURL: repoRoot, @@ -375,7 +376,12 @@ extension RepositoriesFeature { .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) return } - try await githubCLI.markPullRequestReady(worktreeRoot, remoteInfo, pullRequest.number) + try await githubCLI.markPullRequestReady( + worktreeRoot, + remoteInfo, + pullRequest.number, + repositorySettings.githubAccountOverride + ) await send(.showToast(.success("Pull request marked ready"))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { @@ -420,7 +426,13 @@ extension RepositoriesFeature { .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) return } - try await githubCLI.mergePullRequest(worktreeRoot, remoteInfo, pullRequest.number, strategy) + try await githubCLI.mergePullRequest( + worktreeRoot, + remoteInfo, + pullRequest.number, + strategy, + repositorySettings.githubAccountOverride + ) await send(.showToast(.success("Pull request merged"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) @@ -451,6 +463,7 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Closing pull request…"))) do { + @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( repositoryRootURL: repoRoot, @@ -463,7 +476,12 @@ extension RepositoriesFeature { .presentAlert(title: "GitHub repository not resolved", message: unresolvedGithubRepositoryMessage)) return } - try await githubCLI.closePullRequest(worktreeRoot, remoteInfo, pullRequest.number) + try await githubCLI.closePullRequest( + worktreeRoot, + remoteInfo, + pullRequest.number, + repositorySettings.githubAccountOverride + ) await send(.showToast(.success("Pull request closed"))) await send(.worktreeInfoEvent(pullRequestRefresh)) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) @@ -502,7 +520,9 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Fetching CI logs…"))) do { - guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { + @Shared(.repositorySettings(repoRoot)) var repositorySettings + let accountOverride = repositorySettings.githubAccountOverride + guard let run = try await githubCLI.latestRun(worktreeRoot, branchName, accountOverride) else { await send(.dismissToast) await send( .presentAlert( @@ -522,10 +542,10 @@ extension RepositoriesFeature { ) return } - let failedLogs = try await githubCLI.failedRunLogs(worktreeRoot, run.databaseId) + let failedLogs = try await githubCLI.failedRunLogs(worktreeRoot, run.databaseId, accountOverride) let logs = if failedLogs.isEmpty { - try await githubCLI.runLogs(worktreeRoot, run.databaseId) + try await githubCLI.runLogs(worktreeRoot, run.databaseId, accountOverride) } else { failedLogs } @@ -579,7 +599,9 @@ extension RepositoriesFeature { } await send(.showToast(.inProgress("Re-running failed jobs…"))) do { - guard let run = try await githubCLI.latestRun(worktreeRoot, branchName) else { + @Shared(.repositorySettings(repoRoot)) var repositorySettings + let accountOverride = repositorySettings.githubAccountOverride + guard let run = try await githubCLI.latestRun(worktreeRoot, branchName, accountOverride) else { await send(.dismissToast) await send( .presentAlert( @@ -599,7 +621,7 @@ extension RepositoriesFeature { ) return } - try await githubCLI.rerunFailedJobs(worktreeRoot, run.databaseId) + try await githubCLI.rerunFailedJobs(worktreeRoot, run.databaseId, accountOverride) await send(.showToast(.success("Failed jobs re-run started"))) await send(.githubIntegration(.delayedPullRequestRefresh(worktreeID))) } catch { @@ -719,6 +741,7 @@ extension RepositoriesFeature { await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } + @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings coordinatorClient.enqueue( PullRequestRefreshCoordinator.Request( repositoryID: repositoryID, @@ -726,6 +749,7 @@ extension RepositoriesFeature { host: info.host, owner: info.owner, repo: info.repo, + accountOverride: repositorySettings.githubAccountOverride, branches: branches, worktreeIDs: worktreeIDs ) diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 01b960e9..3117009c 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -288,6 +288,7 @@ struct RepositorySettingsFeature { .trimmingCharacters(in: .whitespacesAndNewlines) normalizedSettings.customTitle = (trimmedCustomTitle?.isEmpty ?? true) ? nil : trimmedCustomTitle + normalizedSettings.githubAccountOverride = normalizedSettings.githubAccountOverride?.normalized @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings $repositorySettings.withLock { $0 = normalizedSettings } diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index fe808511..1a4aef58 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -15,6 +15,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var copyIgnoredOnWorktreeCreate: Bool? var copyUntrackedOnWorktreeCreate: Bool? var pullRequestMergeStrategy: PullRequestMergeStrategy? + var githubAccountOverride: GithubAccountOverride? var customTitle: String? /// When `nil` (unset) or `true`, Prowl keeps the worktree line-change badges /// up to date in the background. Set to `false` to skip the periodic `git diff` @@ -37,6 +38,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case copyIgnoredOnWorktreeCreate case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy + case githubAccountOverride case customTitle case observeLineDiffsAutomatically case fetchPullRequestState @@ -52,6 +54,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, pullRequestMergeStrategy: nil, + githubAccountOverride: nil, customTitle: nil, observeLineDiffsAutomatically: nil, fetchPullRequestState: nil @@ -67,6 +70,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: Bool? = nil, copyUntrackedOnWorktreeCreate: Bool? = nil, pullRequestMergeStrategy: PullRequestMergeStrategy? = nil, + githubAccountOverride: GithubAccountOverride? = nil, customTitle: String? = nil, observeLineDiffsAutomatically: Bool? = nil, fetchPullRequestState: Bool? = nil @@ -80,6 +84,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy + self.githubAccountOverride = githubAccountOverride?.normalized self.customTitle = customTitle self.observeLineDiffsAutomatically = observeLineDiffsAutomatically self.fetchPullRequestState = fetchPullRequestState @@ -113,6 +118,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.decodeIfPresent(Bool.self, forKey: .observeLineDiffsAutomatically) fetchPullRequestState = try container.decodeIfPresent(Bool.self, forKey: .fetchPullRequestState) + githubAccountOverride = + try container.decodeIfPresent(GithubAccountOverride.self, forKey: .githubAccountOverride)?.normalized if decodedSchemaVersion >= Self.currentSchemaVersion { copyIgnoredOnWorktreeCreate = try container.decodeIfPresent( diff --git a/supacode/Features/Settings/Views/GithubSettingsView.swift b/supacode/Features/Settings/Views/GithubSettingsView.swift index 94de3a59..a7c0f13a 100644 --- a/supacode/Features/Settings/Views/GithubSettingsView.swift +++ b/supacode/Features/Settings/Views/GithubSettingsView.swift @@ -8,7 +8,7 @@ final class GithubSettingsViewModel { case unavailable case outdated case notAuthenticated - case authenticated(username: String, host: String) + case authenticated(GithubAuthStatusSnapshot) case error(String) } @@ -29,8 +29,9 @@ final class GithubSettingsViewModel { } do { - if let status = try await githubCLI.authStatus() { - state = .authenticated(username: status.username, host: status.host) + let snapshot = try await githubCLI.authStatusSnapshot() + if snapshot.allAccounts.contains(where: { $0.active }) { + state = .authenticated(snapshot) } else { state = .notAuthenticated } @@ -100,14 +101,30 @@ struct GithubSettingsView: View { .font(.callout) } - case .authenticated(let username, let host): - LabeledContent("Signed in as") { - Text(username) - .font(.body) - } - LabeledContent("Host") { - Text(host) - .font(.body) + case .authenticated(let snapshot): + ForEach(snapshot.sortedHosts, id: \.self) { host in + let accounts = snapshot.accounts(on: host) + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Host") { + Text(host) + .font(.body) + } + ForEach(accounts) { account in + HStack { + Text(account.login) + if account.active { + Text("Active") + .foregroundStyle(.secondary) + } + Spacer() + if let state = account.state { + Text(state) + .foregroundStyle(.secondary) + } + } + .font(.body) + } + } } case .error(let message): diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 6c7f8fcf..87cd46f0 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -6,6 +6,7 @@ struct RepositorySettingsView: View { @Bindable var store: StoreOf @State private var isBranchPickerPresented = false @State private var branchSearchText = "" + @State private var githubIdentityViewModel = RepositoryGithubIdentityViewModel() @State var selectedCustomCommandID: UserCustomCommand.ID? @State var recordingCustomCommandID: UserCustomCommand.ID? @@ -222,6 +223,24 @@ struct RepositorySettingsView: View { + "Disable to skip background GitHub queries and save API rate limit." ) + Picker(selection: settings.githubAccountOverride) { + Text("Automatic").tag(GithubAccountOverride?.none) + if let override = store.settings.githubAccountOverride, + !githubIdentityViewModel.accounts.contains(where: { $0.override == override }) + { + Text("\(override.login) @ \(override.host)") + .tag(GithubAccountOverride?.some(override)) + } + ForEach(githubIdentityViewModel.accounts) { account in + Text("\(account.login) @ \(account.host)") + .tag(GithubAccountOverride?.some(account.override)) + } + } label: { + Text("GitHub identity") + Text("Account Prowl switches to before running gh for this repository.") + } + .help("Select the gh account Prowl should use for this repository.") + Picker(selection: settings.pullRequestMergeStrategy) { Text( "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" @@ -325,6 +344,7 @@ struct RepositorySettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { store.send(.task) + await githubIdentityViewModel.load() syncSelectedCommandID(with: store.userSettings.customCommands) } .onChange(of: store.userSettings.customCommands) { _, commands in @@ -378,3 +398,20 @@ struct RepositorySettingsView: View { } } } + +@MainActor @Observable +private final class RepositoryGithubIdentityViewModel { + var accounts: [GithubAuthAccountStatus] = [] + + @ObservationIgnored + @Dependency(GithubCLIClient.self) private var githubCLI + + func load() async { + do { + let snapshot = try await githubCLI.authStatusSnapshot() + accounts = snapshot.allAccounts + } catch { + accounts = [] + } + } +} diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 175e030d..e4386281 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -21,7 +21,7 @@ struct BatchedPullRequestRefreshReducerTests { Issue.record("Should not resolve when cache hit") return nil } - $0.githubCLI.batchPullRequests = { _, _, _, _ in + $0.githubCLI.batchPullRequests = { _, _, _, _, _ in Issue.record("Legacy batchPullRequests should not run on coordinator path") return [:] } diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index 846b3e56..af4cb7a9 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -124,9 +124,9 @@ struct GithubCLIClientTests { ) let client = GithubCLIClient.live(shell: shell) - try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash) - try await client.closePullRequest(repoRoot, remoteInfo, 13) - try await client.markPullRequestReady(repoRoot, remoteInfo, 14) + try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash, nil) + try await client.closePullRequest(repoRoot, remoteInfo, 13, nil) + try await client.markPullRequestReady(repoRoot, remoteInfo, 14, nil) let calls = await probe.snapshot() #expect( @@ -138,6 +138,83 @@ struct GithubCLIClientTests { #expect(calls.allSatisfy { $0.currentDirectoryURL == repoRoot }) } + @Test func pullRequestMutationChecksExpectedAccountBeforeCommand() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/private") + let remoteInfo = GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo") + let account = GithubAccountOverride(host: "github.com", login: "work") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + if arguments.starts(with: ["auth", "status"]) { + return ShellOutput( + stdout: #"{"hosts":{"github.com":[{"active":true,"login":"work","state":"success"}]}}"#, + stderr: "", + exitCode: 0 + ) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + let client = GithubCLIClient.live(shell: shell) + + try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash, account) + + let calls = await probe.snapshot() + #expect( + calls.map(\.arguments) == [ + ["auth", "status", "--active", "--hostname", "github.com", "--json", "hosts"], + ["pr", "merge", "12", "--squash", "--repo", "github.com/octo/repo"], + ]) + } + + @Test func pullRequestMutationSwitchesAndRestoresExpectedAccount() async throws { + let repoRoot = URL(fileURLWithPath: "/tmp/private") + let remoteInfo = GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo") + let account = GithubAccountOverride(host: "github.com", login: "work") + let probe = GithubCommandProbe() + let shell = ShellClient( + run: { executableURL, _, _ in + if executableURL.lastPathComponent == "which" { + return ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + await probe.record(arguments: arguments, currentDirectoryURL: currentDirectoryURL) + return ShellOutput( + stdout: #"{"hosts":{"github.com":[{"active":true,"login":"personal","state":"success"}]}}"#, + stderr: "", + exitCode: 0 + ) + } + ) + let client = GithubCLIClient.live(shell: shell) + + try await client.mergePullRequest(repoRoot, remoteInfo, 12, .squash, account) + + let calls = await probe.snapshot() + #expect( + calls.map(\.arguments) == [ + ["auth", "status", "--active", "--hostname", "github.com", "--json", "hosts"], + ["auth", "switch", "--hostname", "github.com", "--user", "work"], + ["pr", "merge", "12", "--squash", "--repo", "github.com/octo/repo"], + ["auth", "switch", "--hostname", "github.com", "--user", "personal"], + ]) + } + @Test func batchPullRequestsCapsConcurrencyAtThree() async throws { let probe = GithubBatchShellProbe() let shell = ShellClient( @@ -169,7 +246,7 @@ struct GithubCLIClientTests { let client = GithubCLIClient.live(shell: shell) let branches = (0..<100).map { "feature-\($0)" } - _ = try await client.batchPullRequests("github.com", "khoi", "repo", branches) + _ = try await client.batchPullRequests("github.com", "khoi", "repo", branches, nil) let snapshot = await probe.snapshot() #expect(snapshot.ghCallCount == 4) @@ -219,7 +296,7 @@ struct GithubCLIClientTests { let branches = (0..<30).map { "feature-\($0)" } do { - _ = try await client.batchPullRequests("github.com", "khoi", "repo", branches) + _ = try await client.batchPullRequests("github.com", "khoi", "repo", branches, nil) Issue.record("Expected batchPullRequests to throw") } catch let error as GithubCLIError { switch error { @@ -259,7 +336,7 @@ struct GithubCLIClientTests { let uniqueBranches = (0..<30).map { "feature-\($0)" } let branches = uniqueBranches + ["feature-0", "feature-1", "feature-2", "", ""] - let result = try await client.batchPullRequests("github.com", "khoi", "repo", branches) + let result = try await client.batchPullRequests("github.com", "khoi", "repo", branches, nil) #expect(result.isEmpty) let snapshot = await probe.snapshot() @@ -275,7 +352,7 @@ struct GithubCLIClientTests { } let client = GithubCLIClient.live(shell: shell) - let result = try await client.batchPullRequestsAcrossRepositories("github.com", []) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", [], nil) #expect(result.successByRepo.isEmpty) #expect(result.failedRepos.isEmpty) @@ -295,7 +372,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "khoi", repo: "beta", branches: ["", " "]), ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) #expect(result.successByRepo.isEmpty) #expect(result.failedRepos.isEmpty) @@ -316,7 +393,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-3"]), ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) #expect(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")] != nil) #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) @@ -339,7 +416,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "octo", repo: "ghe-repo", branches: ["main"]) ] - _ = try await client.batchPullRequestsAcrossRepositories("github.enterprise.test", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.enterprise.test", requests, nil) let invocations = await observedArguments.snapshot() #expect(invocations.count == 1) @@ -363,7 +440,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), ] - _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let invocations = await observedArguments.snapshot() let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) @@ -393,7 +470,7 @@ struct GithubCLIClientTests { ) ] - _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let invocations = await observedArguments.snapshot() let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) @@ -414,7 +491,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "khoi", repo: "repo-\(index)", branches: ["main"]) } - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let snapshot = await probe.snapshot() #expect(snapshot.ghCallCount == 2) @@ -443,7 +520,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "khoi", repo: "repo-\(index)", branches: ["main"]) } - _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let snapshot = await probe.snapshot() #expect(snapshot.ghCallCount == 4) @@ -463,7 +540,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) #expect(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")] != nil) #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) @@ -485,7 +562,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-3"]), ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) #expect(result.failedRepos[RepoKey(owner: "khoi", repo: "alpha")] != nil) #expect(result.successByRepo[RepoKey(owner: "supabit", repo: "beta")] != nil) @@ -506,7 +583,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "supabit", repo: "beta", branches: ["feat-2"]), ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) #expect(result.successByRepo.isEmpty) #expect(result.failedRepos.count == 2) @@ -528,7 +605,7 @@ struct GithubCLIClientTests { ] do { - _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) Issue.record("Expected batchPullRequestsAcrossRepositories to throw") } catch let error as GithubCLIError { switch error { @@ -558,7 +635,7 @@ struct GithubCLIClientTests { ) ] - _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + _ = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let invocations = await observedArguments.snapshot() let queryArgument = try #require(invocations.first?.first(where: { $0.hasPrefix("query=") })) @@ -582,7 +659,7 @@ struct GithubCLIClientTests { CrossRepoPullRequestRequest(owner: "khoi", repo: "alpha", branches: ["feat-1"]) ] - let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests) + let result = try await client.batchPullRequestsAcrossRepositories("github.com", requests, nil) let alphaPRs = try #require(result.successByRepo[RepoKey(owner: "khoi", repo: "alpha")]) let pullRequest = try #require(alphaPRs["feat-1"]) diff --git a/supacodeTests/GithubCLIOutputTests.swift b/supacodeTests/GithubCLIOutputTests.swift index 7d0f571f..26df3ad5 100644 --- a/supacodeTests/GithubCLIOutputTests.swift +++ b/supacodeTests/GithubCLIOutputTests.swift @@ -94,6 +94,29 @@ struct GithubCLIOutputTests { try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: json) } + @Test func authStatusPreservesAllHostsAndAccounts() throws { + let response = try authResponse( + """ + {"hosts":{ + "github.com":[ + {"active":false,"login":"work","state":"success","gitProtocol":"ssh","tokenSource":"keyring"}, + {"active":true,"login":"personal","state":"success","gitProtocol":"ssh","tokenSource":"keyring"} + ], + "enterprise.internal":[ + {"active":true,"login":"enterprise","state":"success","gitProtocol":"https","tokenSource":"keyring"} + ] + }} + """ + ) + + let snapshot = GithubAuthStatusSnapshot(response: response) + + #expect(snapshot.hosts.count == 2) + #expect(snapshot.accounts(on: "github.com").map(\.login) == ["work", "personal"]) + #expect(snapshot.activeAccount(on: "github.com")?.login == "personal") + #expect(snapshot.activeAccount(on: "enterprise.internal")?.login == "enterprise") + } + @Test func picksActiveAccountFromNonFirstHost() throws { let response = try authResponse( #"{"hosts":{"git.example.com":[{"active":true,"login":"enterprise-user"}]}}"# diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index c0d1df66..16835954 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -69,6 +69,28 @@ struct PullRequestRefreshCoordinatorTests { #expect(hosts == ["github.com", "ghe.example"]) } + @Test func sameHostWithDifferentAccountsTriggersIndependentBatches() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, clock: clock, outcomes: outcomes, + batched: { _, requests in successResult(for: requests) } + ) + + coordinator.enqueue( + request(repo: "alpha", accountOverride: GithubAccountOverride(host: "github.com", login: "one"))) + coordinator.enqueue(request(repo: "beta", accountOverride: GithubAccountOverride(host: "github.com", login: "two"))) + + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 2) + #expect(Set(calls.compactMap(\.accountOverride?.login)) == ["one", "two"]) + } + @Test func partialErrorTriggersPerRepoFallback() async throws { let clock = TestClock() let probe = CoordinatorProbe() @@ -494,11 +516,11 @@ private func makeCoordinator( [String: GithubPullRequest] = { _, _, _, _ in [:] } ) -> PullRequestRefreshCoordinator { var client = GithubCLIClient.testValue - client.batchPullRequestsAcrossRepositories = { host, requests in - await probe.recordBatched(host: host, requests: requests) + client.batchPullRequestsAcrossRepositories = { host, requests, accountOverride in + await probe.recordBatched(host: host, requests: requests, accountOverride: accountOverride) return try await batched(host, requests) } - client.batchPullRequests = { host, owner, repo, branches in + client.batchPullRequests = { host, owner, repo, branches, _ in await probe.recordLegacy(host: host, owner: owner, repo: repo, branches: branches) return try await legacy(host, owner, repo, branches) } @@ -530,6 +552,7 @@ nonisolated private func request( repositoryID: Repository.ID? = nil, rootPath: String? = nil, host: String = "github.com", + accountOverride: GithubAccountOverride? = nil, branches: [String] = ["feat-1"], worktreeIDs: [Worktree.ID]? = nil ) -> PullRequestRefreshCoordinator.Request { @@ -540,6 +563,7 @@ nonisolated private func request( host: host, owner: "khoi", repo: repo, + accountOverride: accountOverride, branches: branches, worktreeIDs: worktreeIDs ?? ["\(resolvedRepositoryID)-wt"] ) @@ -580,6 +604,7 @@ actor CoordinatorProbe { struct BatchedCall: Sendable { let host: String let requests: [CrossRepoPullRequestRequest] + let accountOverride: GithubAccountOverride? } struct LegacyCall: Sendable { @@ -592,8 +617,12 @@ actor CoordinatorProbe { private var batched: [BatchedCall] = [] private var legacy: [LegacyCall] = [] - func recordBatched(host: String, requests: [CrossRepoPullRequestRequest]) { - batched.append(BatchedCall(host: host, requests: requests)) + func recordBatched( + host: String, + requests: [CrossRepoPullRequestRequest], + accountOverride: GithubAccountOverride? + ) { + batched.append(BatchedCall(host: host, requests: requests, accountOverride: accountOverride)) } func recordLegacy(host: String, owner: String, repo: String, branches: [String]) { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 80643766..33532f5c 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -3941,7 +3941,7 @@ struct RepositoriesFeatureTests { } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } - $0.githubCLI.mergePullRequest = { _, _, number, _ in + $0.githubCLI.mergePullRequest = { _, _, number, _, _ in mergedNumbers.withValue { $0.append(number) } } } @@ -3993,7 +3993,7 @@ struct RepositoriesFeatureTests { } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } - $0.githubCLI.mergePullRequest = { _, _, _, strategy in + $0.githubCLI.mergePullRequest = { _, _, _, strategy, _ in mergedStrategies.withValue { $0.append(strategy) } } } @@ -4042,7 +4042,7 @@ struct RepositoriesFeatureTests { Issue.record("gh resolveRemoteInfo should not run when git remote resolves") return nil } - $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _ in + $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _, _ in #expect(root == featureWorktree.workingDirectory) #expect(number == 12) mutationRemoteInfos.withValue { $0.append(remoteInfo) } @@ -4086,7 +4086,7 @@ struct RepositoriesFeatureTests { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.mergePullRequest = { _, _, _, _ in + $0.githubCLI.mergePullRequest = { _, _, _, _, _ in mergeAttempts.withValue { $0 += 1 } } } @@ -4141,7 +4141,7 @@ struct RepositoriesFeatureTests { } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } - $0.githubCLI.closePullRequest = { _, _, number in + $0.githubCLI.closePullRequest = { _, _, number, _ in closedNumbers.withValue { $0.append(number) } } } @@ -4311,7 +4311,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _ in + $0.githubCLI.batchPullRequests = { _, _, _, _, _ in Issue.record("batchPullRequests should not run when remoteInfo is unavailable") return [:] } @@ -4351,7 +4351,7 @@ struct RepositoriesFeatureTests { Issue.record("remoteInfo should not be requested when GitHub integration is unavailable") return nil } - $0.githubCLI.batchPullRequests = { _, _, _, _ in + $0.githubCLI.batchPullRequests = { _, _, _, _, _ in Issue.record("batchPullRequests should not run when GitHub integration is unavailable") return [:] } @@ -4439,7 +4439,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _ in + $0.githubCLI.batchPullRequests = { _, _, _, _, _ in Issue.record("batchPullRequests should not run when remoteInfo is unavailable") return [:] } @@ -4537,7 +4537,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _ in + $0.githubCLI.batchPullRequests = { _, _, _, _, _ in Issue.record("batchPullRequests should not run when remoteInfo is unavailable") return [:] } diff --git a/supacodeTests/RepositorySettingsFeatureTests.swift b/supacodeTests/RepositorySettingsFeatureTests.swift index d583dfee..3a42c7bf 100644 --- a/supacodeTests/RepositorySettingsFeatureTests.swift +++ b/supacodeTests/RepositorySettingsFeatureTests.swift @@ -7,6 +7,16 @@ import Testing @MainActor struct RepositorySettingsFeatureTests { + @Test func githubAccountOverrideRoundTripsThroughRepositorySettings() throws { + var settings = RepositorySettings.default + settings.githubAccountOverride = GithubAccountOverride(host: "github.com", login: "work") + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: data) + + #expect(decoded.githubAccountOverride == GithubAccountOverride(host: "github.com", login: "work")) + } + @Test(.dependencies) func plainFolderTaskLoadsWithoutGitRequests() async throws { let rootURL = URL(fileURLWithPath: "/tmp/folder-\(UUID().uuidString)") let settingsStorage = SettingsTestStorage() -- 2.51.2 From 12d5384da12eeafc0de3069c906e3c44fade96fd Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 12 Jun 2026 23:30:24 +0900 Subject: [PATCH 402/528] Clarify GitHub account status labels --- .../Settings/Views/GithubSettingsView.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Settings/Views/GithubSettingsView.swift b/supacode/Features/Settings/Views/GithubSettingsView.swift index a7c0f13a..6de9cf37 100644 --- a/supacode/Features/Settings/Views/GithubSettingsView.swift +++ b/supacode/Features/Settings/Views/GithubSettingsView.swift @@ -117,9 +117,10 @@ struct GithubSettingsView: View { .foregroundStyle(.secondary) } Spacer() - if let state = account.state { - Text(state) - .foregroundStyle(.secondary) + if let status = GithubAuthAccountStatusDisplay(account.state) { + Text(status.title) + .foregroundStyle(status.style) + .help(status.help) } } .font(.body) @@ -184,3 +185,29 @@ struct GithubSettingsView: View { } } } + +private struct GithubAuthAccountStatusDisplay { + let title: String + let help: String + let style: Color + + init?(_ state: String?) { + guard let state else { return nil } + switch state.lowercased() { + case "success": + return nil + case "timeout": + title = "Timed out" + help = "gh could not verify this account in time." + style = .orange + case "error": + title = "Needs attention" + help = "gh could not verify this account." + style = .red + default: + title = "Check failed" + help = "gh reported an authentication issue." + style = .orange + } + } +} -- 2.51.2 From 696ecf687ff7b4e72731e9713b86f62cacdf2522 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 00:07:00 +0900 Subject: [PATCH 403/528] Hold working state for all agents to stop status flapping Agents briefly clear their working indicators (spinner, "esc to interrupt") between steps, which flipped the stabilized state to idle/Done for a moment before snapping back to Working. The hold that papered over this was Claude-only and too short (1.2s). Generalize the working -> idle hold to every detected agent and widen it to 3s. Blocked still bypasses the hold so permission prompts surface immediately. --- docs/components/agent-detection.md | 7 ++-- .../AgentDetection/PaneAgentState.swift | 19 ++++++---- ...WorktreeTerminalState+AgentDetection.swift | 12 +++--- .../Models/WorktreeTerminalState.swift | 2 +- supacodeTests/PaneAgentStateTests.swift | 37 ++++++++++--------- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 15e9af3a..7de89063 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -32,9 +32,10 @@ bash, etc.) so agents launched indirectly are still found. Kimi's moon phases, etc.). To avoid flicker, detection **stabilizes**: it tolerates several consecutive -misses before declaring an agent gone, and Claude specifically gets a short -(~1.2s) hold so brief pauses between thinking and output don't drop it out of -"working". +misses before declaring an agent gone, and a working agent gets a short (~3s) +hold so brief pauses between thinking and output don't drop it out of +"working" (a genuine finish therefore reports up to ~3s late; "blocked" +bypasses the hold and surfaces immediately). ## The state machine diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 85a8a17d..d13b9b48 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -67,31 +67,36 @@ struct AgentDetectionPresence: Equatable, Sendable { } } -private let claudeWorkingHold: TimeInterval = 1.2 +// Agents briefly clear their working indicators between steps (output gaps, +// tool-call boundaries), so a raw working → idle flip is only trusted after +// the screen has read idle for this long. Keeps Working from flapping to +// Done and back during those pauses, at the cost of reporting a genuine +// finish up to this much later. +private let workingStateHold: TimeInterval = 3.0 func stabilizeAgentState( agent: DetectedAgent?, previous: AgentRawState, raw: AgentRawState, now: Date, - lastClaudeWorkingAt: inout Date? + lastWorkingAt: inout Date? ) -> AgentRawState { - guard agent == .claude else { - lastClaudeWorkingAt = nil + guard agent != nil else { + lastWorkingAt = nil return raw } switch raw { case .working: - lastClaudeWorkingAt = now + lastWorkingAt = now return .working case .blocked: return .blocked case .idle where previous == .working: - guard let lastClaudeWorkingAt else { + guard let lastWorkingAt else { return .idle } - return now.timeIntervalSince(lastClaudeWorkingAt) < claudeWorkingHold ? .working : .idle + return now.timeIntervalSince(lastWorkingAt) < workingStateHold ? .working : .idle case .idle, .unknown: return raw } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 431c3a58..2e8c35cd 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -78,15 +78,15 @@ extension WorktreeTerminalState { let raw = agent.detectState(in: activeText) guard surfaces[surfaceID] != nil else { return } - var lastClaudeWorkingAt = lastClaudeWorkingAtBySurface[surfaceID] + var lastWorkingAt = lastWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( agent: agent, previous: previous.state, raw: raw, now: now, - lastClaudeWorkingAt: &lastClaudeWorkingAt + lastWorkingAt: &lastWorkingAt ) - lastClaudeWorkingAtBySurface[surfaceID] = lastClaudeWorkingAt + lastWorkingAtBySurface[surfaceID] = lastWorkingAt let isForeground = isSelected() && isFocusedSurface(surfaceID) let becameIdleFromActive = @@ -143,7 +143,7 @@ extension WorktreeTerminalState { func removeAgentEntryIfNeeded(surfaceID: UUID) { guard surfaceAgentStates[surfaceID]?.detectedAgent != nil else { return } surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceID) + lastWorkingAtBySurface.removeValue(forKey: surfaceID) onAgentEntryRemoved?(surfaceID) } @@ -203,7 +203,7 @@ extension WorktreeTerminalState { agentDetectionTasks.removeValue(forKey: surfaceId) surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) - lastClaudeWorkingAtBySurface.removeValue(forKey: surfaceId) + lastWorkingAtBySurface.removeValue(forKey: surfaceId) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) onAgentEntryRemoved?(surfaceId) } @@ -216,7 +216,7 @@ extension WorktreeTerminalState { agentDetectionTasks.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() - lastClaudeWorkingAtBySurface.removeAll() + lastWorkingAtBySurface.removeAll() lastAgentDetectionDiagnosticsBySurface.removeAll() for id in removedIDs { onAgentEntryRemoved?(id) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 2385a55c..c02f67ec 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -72,7 +72,7 @@ final class WorktreeTerminalState { var surfaceAgentStates: [UUID: PaneAgentState] = [:] var agentDetectionTasks: [UUID: Task] = [:] var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] - var lastClaudeWorkingAtBySurface: [UUID: Date] = [:] + var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index 89a4ecdd..d7f0c16b 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -18,58 +18,59 @@ struct PaneAgentStateTests { #expect(state.displayState == .idle) } - @Test func claudeWorkingIsStickyForShortIdleGap() { + @Test(arguments: [DetectedAgent.claude, .codex, .gemini]) + func workingIsStickyForShortIdleGap(agent: DetectedAgent) { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? let working = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .idle, raw: .working, now: now, - lastClaudeWorkingAt: &lastWorking + lastWorkingAt: &lastWorking ) #expect(working == .working) let stillWorking = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .working, raw: .idle, - now: now.addingTimeInterval(0.4), - lastClaudeWorkingAt: &lastWorking + now: now.addingTimeInterval(2.9), + lastWorkingAt: &lastWorking ) #expect(stillWorking == .working) } - @Test func claudeTransitionsToIdleAfterStickyWindow() { + @Test(arguments: [DetectedAgent.claude, .codex]) + func transitionsToIdleAfterStickyWindow(agent: DetectedAgent) { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? = now let idle = stabilizeAgentState( - agent: .claude, + agent: agent, previous: .working, raw: .idle, - now: now.addingTimeInterval(1.201), - lastClaudeWorkingAt: &lastWorking + now: now.addingTimeInterval(3.001), + lastWorkingAt: &lastWorking ) #expect(idle == .idle) } - @Test func nonClaudeDoesNotUseStickyWindow() { + @Test func blockedBypassesStickyWindow() { let now = Date(timeIntervalSince1970: 100) var lastWorking: Date? = now - let idle = stabilizeAgentState( - agent: .codex, + let blocked = stabilizeAgentState( + agent: .claude, previous: .working, - raw: .idle, - now: now, - lastClaudeWorkingAt: &lastWorking + raw: .blocked, + now: now.addingTimeInterval(0.3), + lastWorkingAt: &lastWorking ) - #expect(idle == .idle) - #expect(lastWorking == nil) + #expect(blocked == .blocked) } @Test func presenceRequiresSixMissesBeforeRelease() { -- 2.51.2 From f86fea1b81d09347bda9c6cccb30621e933837df Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 01:11:13 +0900 Subject: [PATCH 404/528] Stop Claude viewer hints from forcing idle anywhere on screen detectClaude treated "ctrl+r to toggle" / the search box as transcript chrome no matter where they appeared, forcing idle for as long as the text stayed on screen. Conversation content quoting those strings (e.g. a discussion about these very heuristics) pinned a working agent to Idle with no hold able to save it. Trust the hints only on the bottom chrome lines, and treat a covered screen as carrying no signal: the stabilizer keeps the last trusted state (and its working hold) instead of flipping to idle while the transcript or search view is open. --- docs/components/agent-detection.md | 4 +- .../AgentDetection/PaneAgentState.swift | 10 ++++- .../AgentDetection/ScreenHeuristics.swift | 22 ++++++++-- supacodeTests/PaneAgentStateTests.swift | 44 +++++++++++++++++++ supacodeTests/ScreenHeuristicsTests.swift | 43 ++++++++++++++++++ 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 7de89063..0206f08d 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -35,7 +35,9 @@ To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) hold so brief pauses between thinking and output don't drop it out of "working" (a genuine finish therefore reports up to ~3s late; "blocked" -bypasses the hold and surfaces immediately). +bypasses the hold and surfaces immediately). Viewer overlays (Claude's +transcript / history-search views) cover the live status area, so frames +showing their chrome keep the last trusted state instead of forcing idle. ## The state machine diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index d13b9b48..e336f0c1 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -92,12 +92,20 @@ func stabilizeAgentState( return .working case .blocked: return .blocked + case .unknown: + // A viewer overlay (transcript, history search) is covering the live + // status area, so this frame carries no signal: keep the last trusted + // state, and keep the working hold alive while the screen stays covered. + if previous == .working { + lastWorkingAt = now + } + return previous case .idle where previous == .working: guard let lastWorkingAt else { return .idle } return now.timeIntervalSince(lastWorkingAt) < workingStateHold ? .working : .idle - case .idle, .unknown: + case .idle: return raw } } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 44b4b6ad..86feb619 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -56,10 +56,8 @@ nonisolated private func detectPi(_ content: String) -> AgentRawState { } nonisolated private func detectClaude(_ content: String) -> AgentRawState { - let lower = content.lowercased() - - if content.contains("⌕ Search…") || lower.contains("ctrl+r to toggle") { - return .idle + if hasClaudeViewerChrome(content) { + return .unknown } let currentInteraction = claudeCurrentInteractionRegion(content) if hasClaudeBlockedPrompt(content: currentInteraction, lower: currentInteraction.lowercased()) { @@ -235,6 +233,22 @@ nonisolated private func detectAmp(_ content: String) -> AgentRawState { return .idle } +// Transcript (ctrl+r/ctrl+o) and history-search views cover the live status +// area, so a frame showing their chrome carries no task-state signal — the +// caller maps `.unknown` to "keep the previous state". The hint strings are +// only trusted on the bottom chrome lines: matching them anywhere on screen +// misreads conversation text that merely quotes them (e.g. a discussion +// about these very heuristics). +nonisolated private func hasClaudeViewerChrome(_ content: String) -> Bool { + let bottomLines = content.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .suffix(3) + return bottomLines.contains { line in + line.contains("⌕ Search…") || line.lowercased().contains("ctrl+r to toggle") + } +} + nonisolated private func contentAbovePromptBox(_ content: String) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index d7f0c16b..62987f51 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -73,6 +73,50 @@ struct PaneAgentStateTests { #expect(blocked == .blocked) } + @Test func unknownObservationKeepsPreviousStateAndRefreshesHold() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? = now + let later = now.addingTimeInterval(10) + + let held = stabilizeAgentState( + agent: .claude, + previous: .working, + raw: .unknown, + now: later, + lastWorkingAt: &lastWorking + ) + + #expect(held == .working) + #expect(lastWorking == later) + + var noHistory: Date? + let idle = stabilizeAgentState( + agent: .claude, + previous: .idle, + raw: .unknown, + now: later, + lastWorkingAt: &noHistory + ) + + #expect(idle == .idle) + #expect(noHistory == nil) + } + + @Test func unknownObservationWithoutHistoryStaysUnknown() { + let now = Date(timeIntervalSince1970: 100) + var lastWorking: Date? + + let unknown = stabilizeAgentState( + agent: .claude, + previous: .unknown, + raw: .unknown, + now: now, + lastWorkingAt: &lastWorking + ) + + #expect(unknown == .unknown) + } + @Test func presenceRequiresSixMissesBeforeRelease() { var presence = AgentDetectionPresence(currentAgent: .codex) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index caaf3f50..56c9c2a7 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -177,6 +177,49 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeViewerChromeAtBottomCarriesNoSignal() { + #expect( + DetectedAgent.claude.detectState( + in: """ + ✻ Tempering… (12s · esc to interrupt) + older transcript content + ctrl+r to toggle + """ + ) == .unknown + ) + #expect( + DetectedAgent.claude.detectState( + in: """ + Task complete. + ⌕ Search… + ↑↓ to navigate + """ + ) == .unknown + ) + } + + @Test func claudeQuotedViewerHintMidConversationDoesNotForceIdle() { + // Regression: a chat message quoting "ctrl+r to toggle" used to force + // idle while the spinner below showed Claude still working. + #expect( + DetectedAgent.claude.detectState( + in: """ + ⏺ 收尾完成,现状如下: + + ❯ 3. 修一个我们现存的 bug:detectClaude 里 ctrl+r to toggle → 强制 idle,意味着 + Claude working 时按 ctrl+o/ctrl+r 看 transcript 会闪成 Done。 + + 这个仔细看看,你觉得有必要的话,可以修一下 + + ✻ Twisting… (34s · ↓ 1.8k tokens · thinking more with xhigh effort) + ───────── + ❯ + ───────── + """ + ) == .working + ) + } + @Test func codexDetection() { #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working) -- 2.51.2 From e423d2d80ce9d73f8cb9ba0797ff81d57e3056b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 10:37:11 +0900 Subject: [PATCH 405/528] Make Automatic Open In project-aware and expand supported apps - Automatic now detects the project kind from the worktree's top-level files (Xcode project / Package.swift -> Xcode, Gradle -> Android Studio, go.mod -> GoLand, Cargo.toml -> RustRover, ...) and prefers a matching app before the generic priority. Explicit per-repo or global selections are respected as before. - Add iTerm2, Sublime Text, Tower, Rider, GoLand, CLion, PhpStorm, and RubyMine to the Open In menu. - Cache pre-resized Open In menu icons: toolbar redraws previously reloaded and re-rasterized app icons on every render, while urlForApplication lookups measured cheap and stay uncached. --- docs/components/repositories-and-worktrees.md | 30 +++-- docs/reference/keyboard-shortcuts.md | 2 +- docs/reference/settings-fields.md | 4 +- supacode/Domain/OpenWorktreeAction.swift | 112 +++++++++++++++--- supacode/Domain/WorktreeProjectKind.swift | 90 ++++++++++++++ .../App/Reducer/AppFeature+Support.swift | 9 +- .../Features/App/Reducer/AppFeature.swift | 19 ++- .../OpenWorktreeActionMenuLabelView.swift | 15 +-- .../Views/AppearanceSettingsView.swift | 5 +- .../AppFeatureDefaultEditorTests.swift | 39 ++++++ supacodeTests/OpenWorktreeActionTests.swift | 96 +++++++++++++++ supacodeTests/WorktreeProjectKindTests.swift | 84 +++++++++++++ 12 files changed, 456 insertions(+), 49 deletions(-) create mode 100644 supacode/Domain/WorktreeProjectKind.swift create mode 100644 supacodeTests/WorktreeProjectKindTests.swift diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 9d1f60a1..97ad77cd 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -134,16 +134,26 @@ it from Prowl (closing its open terminals); it does **not** delete files on disk ## Opening a worktree in another app -`⌘O` opens the worktree with the auto-detected default app — your first installed -editor (Cursor → Zed → VS Code → Windsurf → …), falling through to Xcode and then -**Finder only when no preferred app is found**. Use the **Open** dropdown in the -worktree's detail toolbar to pick a different app, or set a per-repo default -(`openActionID`) / global default (`defaultEditorID`). Prowl detects: Finder, -Terminal, `$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, -Antigravity, Xcode, Android Studio, JetBrains IDEs, GitHub Desktop / Fork / -GitKraken / Sourcetree / Sublime Merge / SmartGit / GitUp, and terminals -(Alacritty, Ghostty, Kitty, Warp, WezTerm). If the chosen app isn't installed, -Prowl shows an alert. +`⌘O` opens the worktree with the selected open action. When the action is +**Automatic** (the default), Prowl inspects the worktree's top-level files and +prefers an app matching the project type: `.xcodeproj`/`.xcworkspace`/ +`Package.swift`/`Project.swift` → Xcode, Gradle files → Android Studio (then +IntelliJ IDEA), `*.sln`/`*.csproj` → Rider, `pom.xml` → IntelliJ IDEA, +`go.mod` → GoLand, `Cargo.toml` → RustRover, `CMakeLists.txt` → CLion, +`composer.json` → PhpStorm, `Gemfile` → RubyMine, Python manifests → PyCharm, +`package.json` → WebStorm. If the matching app isn't installed (or no project +type is detected), it falls back to the generic priority — your first +installed editor (Cursor → Zed → VS Code → Windsurf → …), falling through to +Xcode and then **Finder only when no preferred app is found**. Use the +**Open** dropdown in the worktree's detail toolbar to pick a different app +(this pins it for the repo), or set a per-repo default (`openActionID`) / +global default (`defaultEditorID`). Prowl detects: Finder, Terminal, +`$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, Antigravity, +Sublime Text, Xcode, Android Studio, JetBrains IDEs (IntelliJ IDEA, WebStorm, +PyCharm, RustRover, Rider, GoLand, CLion, PhpStorm, RubyMine), GitHub Desktop +/ Fork / Tower / GitKraken / Sourcetree / Sublime Merge / SmartGit / GitUp, +and terminals (Alacritty, Ghostty, iTerm2, Kitty, Warp, WezTerm). If the +chosen app isn't installed, Prowl shows an alert. Other per-row context-menu items: **Copy Path**, **Reveal in Finder**. (Repo Settings lives on the repository **header** menu, not the worktree row.) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 5c52ed55..82fb6091 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -19,7 +19,7 @@ Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control | Action | Default | Command ID | Remappable | |--------|---------|------------|------------| | New Worktree | ⌘N | `new_worktree` | yes | -| Open Worktree (opens in Finder by default) | ⌘O | `open_worktree` | yes | +| Open Worktree (with the selected Open-in app) | ⌘O | `open_worktree` | yes | | Open Repository… | ⌘⇧O | `open_repository` | yes | | Open on Code Host (e.g. GitHub) | ⌘⌃G | `open_pull_request` | yes | | Refresh Worktrees | ⌘⇧R | `refresh_worktrees` | yes | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 34b0125a..98669ace 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -24,7 +24,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | Field | Type | Default | Effect | |-------|------|---------|--------| | `appearanceMode` | enum (`system`/`light`/`dark`) | `dark` | App appearance. | -| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo). | +| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo); `auto` prefers an app matching the detected project type. | | `confirmBeforeQuit` | Bool | `true` | Confirm before quitting Prowl. | | `updateChannel` | enum (`stable`/`tip`) | `stable` | Sparkle release channel. | | `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | @@ -74,7 +74,7 @@ Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state | `setupScript` | String | `""` | Script run automatically after a worktree is created. | | `archiveScript` | String | `""` | Script run automatically before a worktree is archived. | | `runScript` | String | `""` | The on-demand Run Script (`⌘R`). | -| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`). | +| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`); `auto` prefers an app matching the detected project type. | | `worktreeBaseRef` | String? | `nil` | Default base branch/ref for new worktrees. | | `worktreeBaseDirectoryPath` | String? | `nil` | Parent directory for new worktrees (overrides global). | | `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | diff --git a/supacode/Domain/OpenWorktreeAction.swift b/supacode/Domain/OpenWorktreeAction.swift index 9316e754..bac70a38 100644 --- a/supacode/Domain/OpenWorktreeAction.swift +++ b/supacode/Domain/OpenWorktreeAction.swift @@ -9,6 +9,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case alacritty case androidStudio case antigravity + case clion case editor case finder case cursor @@ -17,14 +18,21 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case gitkraken case gitup case ghostty + case goland case intellij + case iterm2 case kitty + case phpstorm case pycharm + case rider + case rubymine case rustrover case smartgit case sourcetree case sublimeMerge + case sublimeText case terminal + case tower case vscode case vscodeInsiders case vscodium @@ -44,19 +52,27 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "Alacritty" case .androidStudio: "Android Studio" case .antigravity: "Antigravity" + case .clion: "CLion" case .cursor: "Cursor" case .githubDesktop: "GitHub Desktop" case .gitkraken: "GitKraken" case .gitup: "GitUp" case .ghostty: "Ghostty" + case .goland: "GoLand" case .intellij: "IntelliJ IDEA" + case .iterm2: "iTerm2" case .kitty: "Kitty" + case .phpstorm: "PhpStorm" case .pycharm: "PyCharm" + case .rider: "Rider" + case .rubymine: "RubyMine" case .rustrover: "RustRover" case .smartgit: "SmartGit" case .sourcetree: "Sourcetree" case .sublimeMerge: "Sublime Merge" + case .sublimeText: "Sublime Text" case .terminal: "Terminal" + case .tower: "Tower" case .vscode: "VS Code" case .vscodeInsiders: "VS Code Insiders" case .vscodium: "VSCodium" @@ -74,33 +90,59 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { switch self { case .finder: "Finder" case .editor: "$EDITOR" - case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, - .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, - .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, - .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .clion, .cursor, .fork, .githubDesktop, .gitkraken, + .gitup, .ghostty, .goland, .intellij, .iterm2, .kitty, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, .vscode, + .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: title } } + // Pre-rendered at display size and cached: `icon(forFile:)` plus the + // rasterizing resize cost milliseconds, and toolbar redraws request these + // icons constantly. Only hits are cached so a newly installed app shows up + // without invalidation; lookup misses are microseconds. + private static let menuIconSize = CGSize(width: 16, height: 16) + private static var menuIconCache: [String: MenuIcon] = [:] + var menuIcon: MenuIcon? { switch self { case .editor: return .symbol("apple.terminal") default: + if let cached = Self.menuIconCache[bundleIdentifier] { + return cached + } guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { return nil } - return .app(NSWorkspace.shared.icon(forFile: appURL.path)) + let icon = Self.resizedIcon(NSWorkspace.shared.icon(forFile: appURL.path), size: Self.menuIconSize) + let menuIcon = MenuIcon.app(icon) + Self.menuIconCache[bundleIdentifier] = menuIcon + return menuIcon } } + private static func resizedIcon(_ image: NSImage, size: CGSize) -> NSImage { + let newImage = NSImage(size: size) + newImage.lockFocus() + image.draw( + in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, + fraction: 1.0 + ) + newImage.unlockFocus() + return newImage + } + var isInstalled: Bool { switch self { case .finder, .editor: return true - case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, - .ghostty, .intellij, .kitty, .pycharm, .rustrover, .smartgit, .sourcetree, .sublimeMerge, - .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, - .xcode, .zed: + case .alacritty, .androidStudio, .antigravity, .clion, .cursor, .fork, .githubDesktop, .gitkraken, + .gitup, .ghostty, .goland, .intellij, .iterm2, .kitty, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, .vscode, + .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed: return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } } @@ -112,20 +154,28 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "alacritty" case .androidStudio: "android-studio" case .antigravity: "antigravity" + case .clion: "clion" case .cursor: "cursor" case .fork: "fork" case .githubDesktop: "github-desktop" case .gitkraken: "gitkraken" case .gitup: "gitup" case .ghostty: "ghostty" + case .goland: "goland" case .intellij: "intellij" + case .iterm2: "iterm2" case .kitty: "kitty" + case .phpstorm: "phpstorm" case .pycharm: "pycharm" + case .rider: "rider" + case .rubymine: "rubymine" case .rustrover: "rustrover" case .smartgit: "smartgit" case .sourcetree: "sourcetree" case .sublimeMerge: "sublime-merge" + case .sublimeText: "sublime-text" case .terminal: "terminal" + case .tower: "tower" case .vscode: "vscode" case .vscodeInsiders: "vscode-insiders" case .vscodium: "vscodium" @@ -145,20 +195,28 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .alacritty: "org.alacritty" case .androidStudio: "com.google.android.studio" case .antigravity: "com.google.antigravity" + case .clion: "com.jetbrains.CLion" case .cursor: "com.todesktop.230313mzl4w4u92" case .fork: "com.DanPristupov.Fork" case .githubDesktop: "com.github.GitHubClient" case .gitkraken: "com.axosoft.gitkraken" case .gitup: "co.gitup.mac" case .ghostty: "com.mitchellh.ghostty" + case .goland: "com.jetbrains.goland" case .intellij: "com.jetbrains.intellij" + case .iterm2: "com.googlecode.iterm2" case .kitty: "net.kovidgoyal.kitty" + case .phpstorm: "com.jetbrains.PhpStorm" case .pycharm: "com.jetbrains.pycharm" + case .rider: "com.jetbrains.rider" + case .rubymine: "com.jetbrains.rubymine" case .rustrover: "com.jetbrains.rustrover" case .smartgit: "com.syntevo.smartgit" case .sourcetree: "com.torusknot.SourceTreeNotMAS" case .sublimeMerge: "com.sublimemerge" + case .sublimeText: "com.sublimetext.4" case .terminal: "com.apple.Terminal" + case .tower: "com.fournova.Tower3" case .vscode: "com.microsoft.VSCode" case .vscodeInsiders: "com.microsoft.VSCodeInsiders" case .vscodium: "com.vscodium" @@ -180,11 +238,17 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { .windsurf, .vscodeInsiders, .vscodium, + .sublimeText, .androidStudio, .intellij, .webstorm, .pycharm, .rustrover, + .rider, + .goland, + .clion, + .phpstorm, + .rubymine, .antigravity, ] static let terminalPriority: [OpenWorktreeAction] = [ @@ -193,12 +257,14 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { .alacritty, .kitty, .warp, + .iterm2, .terminal, ] static let gitClientPriority: [OpenWorktreeAction] = [ .githubDesktop, .sourcetree, .fork, + .tower, .gitkraken, .sublimeMerge, .smartgit, @@ -223,7 +289,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { static func fromSettingsID( _ settingsID: String?, - defaultEditorID: String? + defaultEditorID: String?, + workingDirectory: URL? = nil ) -> OpenWorktreeAction { if let settingsID, settingsID != automaticSettingsID, let action = allCases.first(where: { $0.settingsID == settingsID }) @@ -236,7 +303,7 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { { return action } - return preferredDefault() + return preferredDefault(for: workingDirectory) } static var availableCases: [OpenWorktreeAction] { @@ -248,7 +315,21 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } static func preferredDefault() -> OpenWorktreeAction { - defaultPriority.first(where: \.isInstalled) ?? .finder + preferredDefault(for: nil) + } + + /// Resolves the automatic open action. When a working directory is given, + /// apps suited to the detected project kind (e.g. Xcode for Swift packages, + /// Android Studio for Gradle projects) are tried before the generic priority. + static func preferredDefault( + for workingDirectory: URL?, + isInstalled: (OpenWorktreeAction) -> Bool = { $0.isInstalled } + ) -> OpenWorktreeAction { + let projectActions = + workingDirectory + .flatMap { WorktreeProjectKind.detect(at: $0) }? + .preferredActions ?? [] + return (projectActions + defaultPriority).first(where: isInstalled) ?? .finder } func perform(with worktree: Worktree, onError: @escaping @MainActor @Sendable (OpenActionError) -> Void) { @@ -259,7 +340,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: NSWorkspace.shared.activateFileViewerSelecting([worktree.workingDirectory]) // Apps that require CLI arguments instead of Apple Events to open directories. - case .androidStudio, .intellij, .webstorm, .pycharm, .rustrover: + case .androidStudio, .clion, .goland, .intellij, .phpstorm, .pycharm, .rider, .rubymine, + .rustrover, .webstorm: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier @@ -291,8 +373,8 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { } } case .alacritty, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, .gitup, .ghostty, - .kitty, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, - .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: + .iterm2, .kitty, .smartgit, .sourcetree, .sublimeMerge, .sublimeText, .terminal, .tower, + .vscode, .vscodeInsiders, .vscodium, .warp, .wezterm, .windsurf, .xcode, .zed: guard let appURL = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleIdentifier diff --git a/supacode/Domain/WorktreeProjectKind.swift b/supacode/Domain/WorktreeProjectKind.swift new file mode 100644 index 00000000..be54f6af --- /dev/null +++ b/supacode/Domain/WorktreeProjectKind.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Project ecosystems Prowl can recognize from a worktree's top-level files, +/// used to pick a fitting app when the open action is set to Automatic. +enum WorktreeProjectKind: CaseIterable { + case apple + case android + case dotnet + case java + case golang + case rust + case cpp + case php + case ruby + case python + case web + + /// Detects the project kind from a single shallow listing of `directory`. + /// Checks run from the most specific marker to the least: `package.json` is + /// last because nearly any repo can carry one for tooling, while an + /// `.xcodeproj` or Gradle script identifies the project unambiguously. + static func detect(at directory: URL, fileManager: FileManager = .default) -> WorktreeProjectKind? { + guard let entries = try? fileManager.contentsOfDirectory(atPath: directory.path) else { + return nil + } + let names = Set(entries.map { $0.lowercased() }) + func hasFile(withExtension ext: String) -> Bool { + names.contains { $0.hasSuffix(".\(ext)") } + } + if hasFile(withExtension: "xcodeproj") || hasFile(withExtension: "xcworkspace") + || names.contains("package.swift") || names.contains("project.swift") + { + return .apple + } + if names.contains("settings.gradle") || names.contains("settings.gradle.kts") + || names.contains("build.gradle") || names.contains("build.gradle.kts") + || names.contains("gradlew") + { + return .android + } + if hasFile(withExtension: "sln") || hasFile(withExtension: "csproj") { + return .dotnet + } + if names.contains("pom.xml") { + return .java + } + if names.contains("go.mod") { + return .golang + } + if names.contains("cargo.toml") { + return .rust + } + if names.contains("cmakelists.txt") { + return .cpp + } + if names.contains("composer.json") { + return .php + } + if names.contains("gemfile") { + return .ruby + } + if names.contains("pyproject.toml") || names.contains("setup.py") + || names.contains("requirements.txt") || names.contains("pipfile") + { + return .python + } + if names.contains("package.json") { + return .web + } + return nil + } + + /// Apps to try before `OpenWorktreeAction.defaultPriority` when resolving + /// the Automatic open action for this project kind. + var preferredActions: [OpenWorktreeAction] { + switch self { + case .apple: [.xcode] + case .android: [.androidStudio, .intellij] + case .dotnet: [.rider] + case .java: [.intellij] + case .golang: [.goland] + case .rust: [.rustrover] + case .cpp: [.clion] + case .php: [.phpstorm] + case .ruby: [.rubymine] + case .python: [.pycharm] + case .web: [.webstorm] + } + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index 1a64a789..9f145d30 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -152,14 +152,19 @@ extension AppFeature { /// Applies a worktree's repository settings (open action, run script) into /// state. Shared by the normal `worktreeSettingsLoaded` action and the Canvas /// focus path so both stay in sync. - func applyWorktreeSettings(_ settings: RepositorySettings, into state: inout State) { + func applyWorktreeSettings( + _ settings: RepositorySettings, + workingDirectory: URL?, + into state: inout State + ) { @Shared(.settingsFile) var settingsFile let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID( settingsFile.global.defaultEditorID ) state.openActionSelection = OpenWorktreeAction.fromSettingsID( settings.openActionID, - defaultEditorID: normalizedDefaultEditorID + defaultEditorID: normalizedDefaultEditorID, + workingDirectory: workingDirectory ) state.selectedRunScript = settings.runScript } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 725d31c0..d42f665a 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -419,7 +419,8 @@ struct AppFeature { @Shared(.repositorySettings(rootURL)) var repositorySettings state.openActionSelection = OpenWorktreeAction.fromSettingsID( repositorySettings.openActionID, - defaultEditorID: settings.defaultEditorID + defaultEditorID: settings.defaultEditorID, + workingDirectory: selectedWorktree.workingDirectory ) } state.resolvedKeybindings = resolvedKeybindings( @@ -746,7 +747,11 @@ struct AppFeature { // Run + Custom Command items must update in the same transaction — // otherwise the command list lands a frame later and the toolbar // visibly reflows when switching between cards with different commands. - applyWorktreeSettings(repositorySettings, into: &state) + applyWorktreeSettings( + repositorySettings, + workingDirectory: worktree.workingDirectory, + into: &state + ) return applyWorktreeUserSettings(userRepositorySettings, into: &state) case .runScriptDraftChanged(let script): @@ -868,10 +873,16 @@ struct AppFeature { ) case .worktreeSettingsLoaded(let settings, let worktreeID): - guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { + guard let worktree = actionTargetWorktree(repositories: state.repositories), + worktree.id == worktreeID + else { return .none } - applyWorktreeSettings(settings, into: &state) + applyWorktreeSettings( + settings, + workingDirectory: worktree.workingDirectory, + into: &state + ) return .none case .worktreeUserSettingsLoaded(let settings, let worktreeID): diff --git a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift index 1778698a..3929c212 100644 --- a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift +++ b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift @@ -5,25 +5,12 @@ struct OpenWorktreeActionMenuLabelView: View { let action: OpenWorktreeAction let shortcutHint: String? - private func resizedIcon(_ image: NSImage, size: CGSize) -> NSImage { - let newImage = NSImage(size: size) - newImage.lockFocus() - image.draw( - in: NSRect(origin: .zero, size: size), - from: NSRect(origin: .zero, size: image.size), - operation: .sourceOver, - fraction: 1.0 - ) - newImage.unlockFocus() - return newImage - } - var body: some View { HStack(spacing: 6) { if let icon = action.menuIcon { switch icon { case .app(let image): - Image(nsImage: resizedIcon(image, size: CGSize(width: 16, height: 16))) + Image(nsImage: image) .renderingMode(.original) .accessibilityHidden(true) case .symbol(let name): diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index f429128a..d49d127b 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -132,7 +132,10 @@ struct AppearanceSettingsView: View { .tag(action.settingsID) } } - .help("Applies to worktrees without repository overrides.") + .help( + "Applies to worktrees without repository overrides. " + + "Automatic prefers an app matching the project type, e.g. Xcode for Swift projects." + ) } Section("Run") { Toggle( diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index a8e9d9bd..d312c749 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import DependenciesTestSupport import Foundation @@ -7,6 +8,9 @@ import Testing @MainActor struct AppFeatureDefaultEditorTests { + nonisolated private static var xcodeInstalled: Bool { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") != nil + } @Test(.dependencies) func defaultEditorAppliesToAutomaticRepositorySettings() async { let worktree = makeWorktree() let repositoriesState = makeRepositoriesState(worktree: worktree) @@ -95,6 +99,41 @@ struct AppFeatureDefaultEditorTests { await store.finish() } + @Test(.dependencies, .enabled(if: xcodeInstalled)) + func automaticSelectionPrefersXcodeForSwiftPackageWorktree() async throws { + let worktree = makeWorktree() + let fileManager = FileManager.default + try fileManager.createDirectory( + at: worktree.workingDirectory, + withIntermediateDirectories: true + ) + defer { try? fileManager.removeItem(at: worktree.repositoryRootURL) } + try Data().write(to: worktree.workingDirectory.appending(path: "Package.swift")) + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + } + + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) + await store.receive(\.worktreeSettingsLoaded) { + $0.openActionSelection = .xcode + } + await store.receive(\.worktreeUserSettingsLoaded) + await store.finish() + } + @Test(.dependencies) func selectedWorktreeChangedOnlyUpdatesWatcherSelection() async { let worktree = makeWorktree() let repositoriesState = makeRepositoriesState(worktree: worktree) diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index e3cfd1bc..b664b7b5 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import supacode @@ -16,12 +17,28 @@ struct OpenWorktreeActionTests { #expect(settingsIDs.contains("pycharm")) } + @Test func menuOrderIncludesAllCases() { + #expect(Set(OpenWorktreeAction.menuOrder) == Set(OpenWorktreeAction.allCases)) + #expect(OpenWorktreeAction.menuOrder.count == OpenWorktreeAction.allCases.count) + } + @Test func jetBrainsIDEsHaveCorrectBundleIdentifiers() { #expect(OpenWorktreeAction.androidStudio.bundleIdentifier == "com.google.android.studio") #expect(OpenWorktreeAction.intellij.bundleIdentifier == "com.jetbrains.intellij") #expect(OpenWorktreeAction.webstorm.bundleIdentifier == "com.jetbrains.WebStorm") #expect(OpenWorktreeAction.pycharm.bundleIdentifier == "com.jetbrains.pycharm") #expect(OpenWorktreeAction.rustrover.bundleIdentifier == "com.jetbrains.rustrover") + #expect(OpenWorktreeAction.rider.bundleIdentifier == "com.jetbrains.rider") + #expect(OpenWorktreeAction.goland.bundleIdentifier == "com.jetbrains.goland") + #expect(OpenWorktreeAction.clion.bundleIdentifier == "com.jetbrains.CLion") + #expect(OpenWorktreeAction.phpstorm.bundleIdentifier == "com.jetbrains.PhpStorm") + #expect(OpenWorktreeAction.rubymine.bundleIdentifier == "com.jetbrains.rubymine") + } + + @Test func newActionsHaveCorrectBundleIdentifiers() { + #expect(OpenWorktreeAction.iterm2.bundleIdentifier == "com.googlecode.iterm2") + #expect(OpenWorktreeAction.sublimeText.bundleIdentifier == "com.sublimetext.4") + #expect(OpenWorktreeAction.tower.bundleIdentifier == "com.fournova.Tower3") } @Test func jetBrainsIDEsAreInEditorPriority() { @@ -31,5 +48,84 @@ struct OpenWorktreeActionTests { #expect(editors.contains(.webstorm)) #expect(editors.contains(.pycharm)) #expect(editors.contains(.rustrover)) + #expect(editors.contains(.rider)) + #expect(editors.contains(.goland)) + #expect(editors.contains(.clion)) + #expect(editors.contains(.phpstorm)) + #expect(editors.contains(.rubymine)) + } + + @Test func projectKindsPreferMatchingSpecialistApps() { + #expect(WorktreeProjectKind.apple.preferredActions.first == .xcode) + #expect(WorktreeProjectKind.android.preferredActions == [.androidStudio, .intellij]) + #expect(WorktreeProjectKind.dotnet.preferredActions.first == .rider) + #expect(WorktreeProjectKind.golang.preferredActions.first == .goland) + #expect(WorktreeProjectKind.rust.preferredActions.first == .rustrover) + } + + @Test func preferredDefaultPicksXcodeForAppleProject() throws { + try withProjectDirectory(entries: ["Package.swift"]) { directory in + let installed: Set = [.xcode, .cursor, .vscode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .xcode) + } + } + + @Test func preferredDefaultPicksAndroidStudioForGradleProject() throws { + try withProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in + let installed: Set = [.androidStudio, .cursor, .xcode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .androidStudio) + } + } + + @Test func preferredDefaultFallsBackToSecondSpecialistThenGenericPriority() throws { + try withProjectDirectory(entries: ["build.gradle.kts"]) { directory in + let withIntellij: Set = [.intellij, .cursor, .finder] + let intellijPick = OpenWorktreeAction.preferredDefault(for: directory) { + withIntellij.contains($0) + } + #expect(intellijPick == .intellij) + + let withoutJetBrains: Set = [.cursor, .finder] + let genericPick = OpenWorktreeAction.preferredDefault(for: directory) { + withoutJetBrains.contains($0) + } + #expect(genericPick == .cursor) + } + } + + @Test func preferredDefaultIgnoresProjectKindWithoutMarkers() throws { + try withProjectDirectory(entries: ["README.md"]) { directory in + let installed: Set = [.xcode, .vscode, .finder] + let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } + #expect(action == .vscode) + } + } + + @Test func preferredDefaultWithoutDirectoryUsesGenericPriority() { + let installed: Set = [.xcode, .zed, .finder] + let action = OpenWorktreeAction.preferredDefault(for: nil) { installed.contains($0) } + #expect(action == .zed) + } + + @Test func preferredDefaultFallsBackToFinderWhenNothingInstalled() { + let action = OpenWorktreeAction.preferredDefault(for: nil) { _ in false } + #expect(action == .finder) + } + + private func withProjectDirectory( + entries: [String], + body: (URL) throws -> Void + ) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "open-action-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + try Data().write(to: directory.appending(path: entry)) + } + try body(directory) } } diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift new file mode 100644 index 00000000..fded7c13 --- /dev/null +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing + +@testable import supacode + +struct WorktreeProjectKindTests { + @Test(arguments: [ + (["App.xcodeproj/"], WorktreeProjectKind.apple), + (["App.xcworkspace/"], WorktreeProjectKind.apple), + (["Package.swift"], WorktreeProjectKind.apple), + (["Project.swift"], WorktreeProjectKind.apple), + (["settings.gradle"], WorktreeProjectKind.android), + (["settings.gradle.kts"], WorktreeProjectKind.android), + (["build.gradle.kts"], WorktreeProjectKind.android), + (["gradlew"], WorktreeProjectKind.android), + (["App.sln"], WorktreeProjectKind.dotnet), + (["App.csproj"], WorktreeProjectKind.dotnet), + (["pom.xml"], WorktreeProjectKind.java), + (["go.mod"], WorktreeProjectKind.golang), + (["Cargo.toml"], WorktreeProjectKind.rust), + (["CMakeLists.txt"], WorktreeProjectKind.cpp), + (["composer.json"], WorktreeProjectKind.php), + (["Gemfile"], WorktreeProjectKind.ruby), + (["pyproject.toml"], WorktreeProjectKind.python), + (["requirements.txt"], WorktreeProjectKind.python), + (["package.json"], WorktreeProjectKind.web), + ]) + func detectsKindFromMarker(entries: [String], expected: WorktreeProjectKind) throws { + try withTemporaryDirectory(entries: entries) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == expected) + } + } + + @Test(arguments: [ + (["App.xcodeproj/", "package.json"], WorktreeProjectKind.apple), + (["Package.swift", "package.json"], WorktreeProjectKind.apple), + (["gradlew", "package.json"], WorktreeProjectKind.android), + (["Cargo.toml", "package.json"], WorktreeProjectKind.rust), + (["go.mod", "CMakeLists.txt"], WorktreeProjectKind.golang), + (["composer.json", "package.json"], WorktreeProjectKind.php), + ]) + func specificMarkersWinOverGenericOnes(entries: [String], expected: WorktreeProjectKind) throws { + try withTemporaryDirectory(entries: entries) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == expected) + } + } + + @Test func returnsNilWithoutMarkers() throws { + try withTemporaryDirectory(entries: ["README.md", "src/"]) { directory in + #expect(WorktreeProjectKind.detect(at: directory) == nil) + } + } + + @Test func returnsNilForMissingDirectory() { + let directory = FileManager.default.temporaryDirectory + .appending(path: "missing-\(UUID().uuidString)") + #expect(WorktreeProjectKind.detect(at: directory) == nil) + } + + /// Creates a temporary directory containing `entries` (a trailing slash + /// marks a subdirectory, like an `.xcodeproj` bundle) and removes it after + /// `body` runs. + private func withTemporaryDirectory( + entries: [String], + body: (URL) throws -> Void + ) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "project-kind-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + if entry.hasSuffix("/") { + try fileManager.createDirectory( + at: directory.appending(path: String(entry.dropLast())), + withIntermediateDirectories: true + ) + } else { + try Data().write(to: directory.appending(path: entry)) + } + } + try body(directory) + } +} -- 2.51.2 From 2940a94ed3cbecbe29f9bc25ebffccdb10c937b1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 10:45:11 +0900 Subject: [PATCH 406/528] Share the temporary project directory helper between tests --- supacodeTests/OpenWorktreeActionTests.swift | 22 +++---------- supacodeTests/TemporaryProjectDirectory.swift | 26 ++++++++++++++++ supacodeTests/WorktreeProjectKindTests.swift | 31 ++----------------- 3 files changed, 33 insertions(+), 46 deletions(-) create mode 100644 supacodeTests/TemporaryProjectDirectory.swift diff --git a/supacodeTests/OpenWorktreeActionTests.swift b/supacodeTests/OpenWorktreeActionTests.swift index b664b7b5..f5023985 100644 --- a/supacodeTests/OpenWorktreeActionTests.swift +++ b/supacodeTests/OpenWorktreeActionTests.swift @@ -64,7 +64,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultPicksXcodeForAppleProject() throws { - try withProjectDirectory(entries: ["Package.swift"]) { directory in + try withTemporaryProjectDirectory(entries: ["Package.swift"]) { directory in let installed: Set = [.xcode, .cursor, .vscode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .xcode) @@ -72,7 +72,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultPicksAndroidStudioForGradleProject() throws { - try withProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in + try withTemporaryProjectDirectory(entries: ["settings.gradle.kts", "gradlew"]) { directory in let installed: Set = [.androidStudio, .cursor, .xcode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .androidStudio) @@ -80,7 +80,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultFallsBackToSecondSpecialistThenGenericPriority() throws { - try withProjectDirectory(entries: ["build.gradle.kts"]) { directory in + try withTemporaryProjectDirectory(entries: ["build.gradle.kts"]) { directory in let withIntellij: Set = [.intellij, .cursor, .finder] let intellijPick = OpenWorktreeAction.preferredDefault(for: directory) { withIntellij.contains($0) @@ -96,7 +96,7 @@ struct OpenWorktreeActionTests { } @Test func preferredDefaultIgnoresProjectKindWithoutMarkers() throws { - try withProjectDirectory(entries: ["README.md"]) { directory in + try withTemporaryProjectDirectory(entries: ["README.md"]) { directory in let installed: Set = [.xcode, .vscode, .finder] let action = OpenWorktreeAction.preferredDefault(for: directory) { installed.contains($0) } #expect(action == .vscode) @@ -114,18 +114,4 @@ struct OpenWorktreeActionTests { #expect(action == .finder) } - private func withProjectDirectory( - entries: [String], - body: (URL) throws -> Void - ) throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appending(path: "open-action-\(UUID().uuidString)") - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } - for entry in entries { - try Data().write(to: directory.appending(path: entry)) - } - try body(directory) - } } diff --git a/supacodeTests/TemporaryProjectDirectory.swift b/supacodeTests/TemporaryProjectDirectory.swift new file mode 100644 index 00000000..c8497d14 --- /dev/null +++ b/supacodeTests/TemporaryProjectDirectory.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Creates a temporary directory containing `entries` (a trailing slash marks +/// a subdirectory, like an `.xcodeproj` bundle) and removes it after `body` +/// runs. +func withTemporaryProjectDirectory( + entries: [String], + body: (URL) throws -> Void +) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "project-fixture-\(UUID().uuidString)") + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + for entry in entries { + if entry.hasSuffix("/") { + try fileManager.createDirectory( + at: directory.appending(path: String(entry.dropLast())), + withIntermediateDirectories: true + ) + } else { + try Data().write(to: directory.appending(path: entry)) + } + } + try body(directory) +} diff --git a/supacodeTests/WorktreeProjectKindTests.swift b/supacodeTests/WorktreeProjectKindTests.swift index fded7c13..19e4e91f 100644 --- a/supacodeTests/WorktreeProjectKindTests.swift +++ b/supacodeTests/WorktreeProjectKindTests.swift @@ -26,7 +26,7 @@ struct WorktreeProjectKindTests { (["package.json"], WorktreeProjectKind.web), ]) func detectsKindFromMarker(entries: [String], expected: WorktreeProjectKind) throws { - try withTemporaryDirectory(entries: entries) { directory in + try withTemporaryProjectDirectory(entries: entries) { directory in #expect(WorktreeProjectKind.detect(at: directory) == expected) } } @@ -40,13 +40,13 @@ struct WorktreeProjectKindTests { (["composer.json", "package.json"], WorktreeProjectKind.php), ]) func specificMarkersWinOverGenericOnes(entries: [String], expected: WorktreeProjectKind) throws { - try withTemporaryDirectory(entries: entries) { directory in + try withTemporaryProjectDirectory(entries: entries) { directory in #expect(WorktreeProjectKind.detect(at: directory) == expected) } } @Test func returnsNilWithoutMarkers() throws { - try withTemporaryDirectory(entries: ["README.md", "src/"]) { directory in + try withTemporaryProjectDirectory(entries: ["README.md", "src/"]) { directory in #expect(WorktreeProjectKind.detect(at: directory) == nil) } } @@ -56,29 +56,4 @@ struct WorktreeProjectKindTests { .appending(path: "missing-\(UUID().uuidString)") #expect(WorktreeProjectKind.detect(at: directory) == nil) } - - /// Creates a temporary directory containing `entries` (a trailing slash - /// marks a subdirectory, like an `.xcodeproj` bundle) and removes it after - /// `body` runs. - private func withTemporaryDirectory( - entries: [String], - body: (URL) throws -> Void - ) throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appending(path: "project-kind-\(UUID().uuidString)") - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } - for entry in entries { - if entry.hasSuffix("/") { - try fileManager.createDirectory( - at: directory.appending(path: String(entry.dropLast())), - withIntermediateDirectories: true - ) - } else { - try Data().write(to: directory.appending(path: entry)) - } - } - try body(directory) - } } -- 2.51.2 From e8d8ff488c965ce430a1bf9327ad344d4f52108c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 12:09:40 +0900 Subject: [PATCH 407/528] Add an Automatic entry to the Open In menu to clear a pinned app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an app from the toolbar's Open dropdown pins it to the repository's openActionID, and there was no way to undo that — the project-aware Automatic resolution became unreachable once a repo had ever been pinned. The global defaultEditorID could be reset to "auto" from settings, but the per-repo/per-folder pin could not. Add an "Automatic" entry at the top of the dropdown that writes openActionID back to "auto", re-resolves the open action (project-aware), and opens with it. Track openActionIsAutomatic in AppFeature state so the entry shows a checkmark while the repo is on automatic. --- docs/components/repositories-and-worktrees.md | 6 +- .../App/Reducer/AppFeature+Support.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 25 +++++++ .../Views/WorktreeDetailToolbarViews.swift | 2 + .../Views/WorktreeDetailView.swift | 30 ++++++++- .../AppFeatureDefaultEditorTests.swift | 67 +++++++++++++++++++ .../AppFeaturePlainFolderTerminalTests.swift | 1 + 7 files changed, 127 insertions(+), 5 deletions(-) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index 97ad77cd..b09e834e 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -146,8 +146,10 @@ type is detected), it falls back to the generic priority — your first installed editor (Cursor → Zed → VS Code → Windsurf → …), falling through to Xcode and then **Finder only when no preferred app is found**. Use the **Open** dropdown in the worktree's detail toolbar to pick a different app -(this pins it for the repo), or set a per-repo default (`openActionID`) / -global default (`defaultEditorID`). Prowl detects: Finder, Terminal, +(this pins it for the repo), or pick **Automatic** at the top of that dropdown +to clear the pin and return to project-aware selection. You can also set a +per-repo default (`openActionID`) / global default (`defaultEditorID`). Prowl +detects: Finder, Terminal, `$EDITOR`, VS Code (+ Insiders), VSCodium, Cursor, Zed, Windsurf, Antigravity, Sublime Text, Xcode, Android Studio, JetBrains IDEs (IntelliJ IDEA, WebStorm, PyCharm, RustRover, Rider, GoLand, CLion, PhpStorm, RubyMine), GitHub Desktop diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index 9f145d30..f59eddba 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -166,6 +166,7 @@ extension AppFeature { defaultEditorID: normalizedDefaultEditorID, workingDirectory: workingDirectory ) + state.openActionIsAutomatic = settings.openActionID == OpenWorktreeAction.automaticSettingsID state.selectedRunScript = settings.runScript } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index d42f665a..72eb44cb 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -13,6 +13,10 @@ struct AppFeature { var updates = UpdatesFeature.State() var commandPalette = CommandPaletteFeature.State() var openActionSelection: OpenWorktreeAction = .finder + /// Whether the selected worktree's repository resolves its open action + /// automatically (project-aware) rather than a user-pinned app. Drives the + /// "Automatic" entry's checkmark in the toolbar's Open menu. + var openActionIsAutomatic: Bool = true var selectedRunScript: String = "" var selectedCustomCommands: [UserCustomCommand] = [] var resolvedKeybindings: ResolvedKeybindingMap = .appDefaults @@ -49,6 +53,7 @@ struct AppFeature { case updates(UpdatesFeature.Action) case commandPalette(CommandPaletteFeature.Action) case openActionSelectionChanged(OpenWorktreeAction) + case openActionResetToAutomatic case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) case worktreeUserSettingsLoaded(UserRepositorySettings, worktreeID: Worktree.ID) case openSelectedWorktree @@ -422,6 +427,8 @@ struct AppFeature { defaultEditorID: settings.defaultEditorID, workingDirectory: selectedWorktree.workingDirectory ) + state.openActionIsAutomatic = + repositorySettings.openActionID == OpenWorktreeAction.automaticSettingsID } state.resolvedKeybindings = resolvedKeybindings( settings: state.settings, @@ -533,6 +540,7 @@ struct AppFeature { case .openActionSelectionChanged(let action): state.openActionSelection = action + state.openActionIsAutomatic = false guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } @@ -542,6 +550,22 @@ struct AppFeature { $repositorySettings.withLock { $0.openActionID = actionID } return .none + case .openActionResetToAutomatic: + guard let worktree = state.repositories.selectedTerminalWorktree else { + return .none + } + let rootURL = worktree.repositoryRootURL + @Shared(.repositorySettings(rootURL)) var repositorySettings + $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + @Shared(.settingsFile) var settingsFile + state.openActionSelection = OpenWorktreeAction.fromSettingsID( + OpenWorktreeAction.automaticSettingsID, + defaultEditorID: settingsFile.global.defaultEditorID, + workingDirectory: worktree.workingDirectory + ) + state.openActionIsAutomatic = true + return .send(.openSelectedWorktree) + case .openSelectedWorktree: return .send(.openWorktree(OpenWorktreeAction.availableSelection(state.openActionSelection))) @@ -726,6 +750,7 @@ struct AppFeature { let worktree = terminalWorktree(for: worktreeID, repositories: state.repositories) else { state.openActionSelection = .finder + state.openActionIsAutomatic = true state.selectedRunScript = "" state.selectedCustomCommands = [] state.resolvedKeybindings = resolvedKeybindings( diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index e490ff9e..9c42b758 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -232,6 +232,7 @@ private struct WorktreeToolbarPreview: View { notificationGroups: [], unseenNotificationWorktreeCount: 0, openActionSelection: .finder, + openActionIsAutomatic: true, showExtras: false, runScriptEnabled: true, runScriptIsRunning: false, @@ -271,6 +272,7 @@ private struct WorktreeToolbarPreview: View { onConsumeExternalRenamePrompt: { _ in }, onOpenWorktree: { _ in }, onOpenActionSelectionChanged: { _ in }, + onResetOpenActionToAutomatic: {}, onCopyPath: {}, onSelectNotification: { _, _ in }, onDismissAllNotifications: {}, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 33a12150..abfb218e 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -10,6 +10,7 @@ struct WorktreeDetailView: View { let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction + let openActionIsAutomatic: Bool let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool @@ -68,7 +69,6 @@ struct WorktreeDetailView: View { actionTargetWorktree != nil && loadingInfo == nil && !showsMultiSelectionSummary - let openActionSelection = state.openActionSelection let runScriptEnabled = hasActiveTerminalTarget let runScriptIsRunning = actionTargetWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true let customCommands = state.selectedCustomCommands @@ -110,7 +110,8 @@ struct WorktreeDetailView: View { selectedWorktree: selectedWorktree, notificationGroups: notificationGroups, unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, - openActionSelection: openActionSelection, + openActionSelection: state.openActionSelection, + openActionIsAutomatic: state.openActionIsAutomatic, showExtras: commandKeyObserver.isPressed, runScriptEnabled: runScriptEnabled, runScriptIsRunning: runScriptIsRunning, @@ -177,6 +178,9 @@ struct WorktreeDetailView: View { onOpenActionSelectionChanged: { action in store.send(.openActionSelectionChanged(action)) }, + onResetOpenActionToAutomatic: { + store.send(.openActionResetToAutomatic) + }, onCopyPath: { guard let actionTargetWorktree else { return } NSPasteboard.general.clearContents() @@ -323,6 +327,7 @@ struct WorktreeDetailView: View { notificationGroups: input.notificationGroups, unseenNotificationWorktreeCount: input.unseenNotificationWorktreeCount, openActionSelection: input.openActionSelection, + openActionIsAutomatic: input.openActionIsAutomatic, showExtras: input.showExtras, runScriptEnabled: input.runScriptEnabled, runScriptIsRunning: input.runScriptIsRunning, @@ -748,6 +753,7 @@ struct WorktreeDetailView: View { let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int let openActionSelection: OpenWorktreeAction + let openActionIsAutomatic: Bool let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool @@ -766,6 +772,7 @@ struct WorktreeDetailView: View { let onConsumeExternalRenamePrompt: (Int) -> Void let onOpenWorktree: (OpenWorktreeAction) -> Void let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void + let onResetOpenActionToAutomatic: () -> Void let onCopyPath: () -> Void let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void let onDismissAllNotifications: () -> Void @@ -815,6 +822,7 @@ struct WorktreeDetailView: View { ToolbarItemGroup { openMenu( openActionSelection: toolbarState.openActionSelection, + openActionIsAutomatic: toolbarState.openActionIsAutomatic, showExtras: toolbarState.showExtras ) } @@ -824,7 +832,11 @@ struct WorktreeDetailView: View { } @ViewBuilder - private func openMenu(openActionSelection: OpenWorktreeAction, showExtras: Bool) -> some View { + private func openMenu( + openActionSelection: OpenWorktreeAction, + openActionIsAutomatic: Bool, + showExtras: Bool + ) -> some View { let availableActions = OpenWorktreeAction.availableCases let resolvedOpenActionSelection = OpenWorktreeAction.availableSelection(openActionSelection) Button { @@ -838,6 +850,18 @@ struct WorktreeDetailView: View { .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) Menu { + Button { + onResetOpenActionToAutomatic() + } label: { + if openActionIsAutomatic { + Label("Automatic", systemImage: "checkmark") + } else { + Text("Automatic") + } + } + .buttonStyle(.plain) + .help("Pick the app automatically based on the project type") + Divider() ForEach(availableActions) { action in let isDefault = action == resolvedOpenActionSelection Button { diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index d312c749..54672c2e 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -93,6 +93,7 @@ struct AppFeatureDefaultEditorTests { await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false $0.selectedRunScript = "pnpm dev" } await store.receive(\.worktreeUserSettingsLoaded) @@ -170,6 +171,72 @@ struct AppFeatureDefaultEditorTests { #expect(watcherCommands.value == [.setSelectedWorktreeID(worktree.id)]) } + @Test(.dependencies) func selectingAppPinsItAndClearsAutomatic() async { + let worktree = makeWorktree() + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } + + await store.send(.openActionSelectionChanged(.terminal)) { + $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false + } + await store.finish() + } + + @Test(.dependencies) func resetToAutomaticClearsRepositoryPinAndReopens() async { + let worktree = makeWorktree() + let repositoriesState = makeRepositoriesState(worktree: worktree) + let storage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } operation: { + var settings = GlobalSettings.default + settings.defaultEditorID = OpenWorktreeAction.finder.settingsID + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + var initialState = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State(settings: settings) + ) + // Simulate a worktree whose repository has pinned a specific app. + initialState.openActionSelection = .terminal + initialState.openActionIsAutomatic = false + return TestStore(initialState: initialState) { + AppFeature() + } + } + store.exhaustivity = .off + + await store.send(.openActionResetToAutomatic) { + $0.openActionSelection = .finder + $0.openActionIsAutomatic = true + } + await store.receive(\.openSelectedWorktree) + await store.receive(\.openWorktree) + } + private func makeWorktree() -> Worktree { let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") let worktreeURL = repositoryRootURL.appending(path: "wt-1") diff --git a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift index 23fb948b..42a48dbd 100644 --- a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift +++ b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift @@ -70,6 +70,7 @@ struct AppFeaturePlainFolderTerminalTests { await store.receive(\.repositories.delegate.selectedWorktreeChanged) await store.receive(\.worktreeSettingsLoaded) { $0.openActionSelection = .terminal + $0.openActionIsAutomatic = false $0.selectedRunScript = "pnpm dev" } await store.receive(\.worktreeUserSettingsLoaded) { -- 2.51.2 From 3a24efcede24db7deabac5d2795d3315f946748c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 14:45:35 +0900 Subject: [PATCH 408/528] Address Copilot review on the Open In changes - Make the menu icon cache's MainActor isolation explicit. Every access is during SwiftUI rendering on the main thread, so the cache was already race-free via the module's default actor isolation; the attribute makes that contract compiler-enforced and documents why a lock is the wrong tool here (NSImage isn't Sendable). - Skip the redundant repository-settings write when resetting an already-automatic repo to Automatic, while still re-resolving and reopening so the entry stays consistent with the concrete app rows. --- supacode/Domain/OpenWorktreeAction.swift | 7 ++++++- supacode/Features/App/Reducer/AppFeature.swift | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/supacode/Domain/OpenWorktreeAction.swift b/supacode/Domain/OpenWorktreeAction.swift index bac70a38..5ca5d18f 100644 --- a/supacode/Domain/OpenWorktreeAction.swift +++ b/supacode/Domain/OpenWorktreeAction.swift @@ -103,7 +103,12 @@ enum OpenWorktreeAction: CaseIterable, Identifiable { // icons constantly. Only hits are cached so a newly installed app shows up // without invalidation; lookup misses are microseconds. private static let menuIconSize = CGSize(width: 16, height: 16) - private static var menuIconCache: [String: MenuIcon] = [:] + // `@MainActor` makes the cache's isolation explicit and compiler-enforced: + // every `menuIcon` access happens during SwiftUI rendering on the main + // thread, so this shared mutable state is never touched concurrently. (A + // lock would be the wrong tool here — `NSImage` isn't `Sendable`, so the + // cached values shouldn't cross threads in the first place.) + @MainActor private static var menuIconCache: [String: MenuIcon] = [:] var menuIcon: MenuIcon? { switch self { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 72eb44cb..756b8b0c 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -554,9 +554,14 @@ struct AppFeature { guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } - let rootURL = worktree.repositoryRootURL - @Shared(.repositorySettings(rootURL)) var repositorySettings - $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + // Clearing the pin only matters when the repo isn't already automatic; + // re-resolve and reopen unconditionally so the entry behaves like the + // concrete app rows, where selecting always opens. + if !state.openActionIsAutomatic { + let rootURL = worktree.repositoryRootURL + @Shared(.repositorySettings(rootURL)) var repositorySettings + $repositorySettings.withLock { $0.openActionID = OpenWorktreeAction.automaticSettingsID } + } @Shared(.settingsFile) var settingsFile state.openActionSelection = OpenWorktreeAction.fromSettingsID( OpenWorktreeAction.automaticSettingsID, -- 2.51.2 From 45fdbb9e2a304d152867bc85e5d14a633d6d8428 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 15:13:41 +0900 Subject: [PATCH 409/528] Sync docs for 2026.6.13 --- docs/.sync-meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index e074ec70..6a8e0bcc 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "6da2ba4a0b2f88923aaa1b72282b5fa41a83706d", - "last_synced_date": "2026-06-11", - "note": "No doc edits needed: the two user-facing features in range — split zoom with cmd+opt+shift+F and per-pane zoom buttons (#435), and Shelf agent status badges + cmd-held trackpad book/tab swiping (#434) — were already documented in their feature PRs (terminal.md, keyboard-shortcuts.md, shelf.md, active-agents.md, settings-fields.md showActiveAgentStatusInShelf row), verified against AppShortcuts.swift and GlobalSettings.swift. Remaining changes are internal fixes (windowless stall diagnostics, Sentry HTTP filtering, detection resync, worktree Advanced field labels). Baseline bump only." + "last_synced_commit": "7b5bd85be27faff68b63eb5e041998f6f3734c3c", + "last_synced_date": "2026-06-13", + "note": "No doc edits needed: the three user-facing features in range were already documented in their feature PRs. (1) Open In — Automatic project-aware app selection + many added apps (CLion, GoLand, PhpStorm, Rider, RubyMine, Sublime Text, Tower, iTerm2) and the 'Automatic' clear-pin menu entry (#439) are covered in repositories-and-worktrees.md, verified against OpenWorktreeAction.swift / WorktreeProjectKind.swift. (2) Per-repo GitHub identity override + multi-account GitHub settings tab (#437) are covered in github-pull-requests.md and settings-fields.md (githubAccountOverride row), verified against RepositorySettings.swift / GithubSettingsView.swift. (3) Working-state hold generalized to all agents at ~3s + viewer-overlay last-state handling (#438) are covered generically in agent-detection.md, verified against PaneAgentState.swift. Remaining changes are internal wiring (gh client/reducers/coordinators) and a tooltip tweak. Baseline bump only." } -- 2.51.2 From 1e6f55994924baf403d0513dc5561df30f1ac974 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 15:17:52 +0900 Subject: [PATCH 410/528] bump v2026.6.13 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 302df799..30c3a584 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260611; + CURRENT_PROJECT_VERSION = 20260613; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.11; + MARKETING_VERSION = 2026.6.13; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260611; + CURRENT_PROJECT_VERSION = 20260613; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.11; + MARKETING_VERSION = 2026.6.13; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260611; + CURRENT_PROJECT_VERSION = 20260613; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.11; + MARKETING_VERSION = 2026.6.13; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260611; + CURRENT_PROJECT_VERSION = 20260613; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.11; + MARKETING_VERSION = 2026.6.13; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index d01a52dc..e7822487 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.11" + public static let current = "2026.6.13" } -- 2.51.2 From 5b7d29c0216959c6cea6c25ffe9c050228c8de55 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 15:17:53 +0900 Subject: [PATCH 411/528] Update CHANGELOG for 2026.6.13 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0c6a4a..eb29cd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2026.6.13](https://github.com/onevcat/Prowl/releases/tag/v2026.6.13) + +This release improves multi-account GitHub workflows, expands editor support in the Open In menu, and stabilizes agent status detection. + +### New + +- **Per-repo GitHub identity**: You can now assign a specific GitHub CLI account to each repository in its settings. Prowl will use that identity automatically when fetching pull requests, so multi-account setups (e.g. work and personal) work without manual `gh auth switch`. +- GitHub Settings now lists all authenticated hosts and accounts instead of showing only a single entry, making it easier to see which identities are available. +- **Project-aware Automatic editor**: The "Automatic" option in the Open In menu now detects the project type (Swift/Xcode, Android, .NET, Go, Rust, and more) and prefers a matching specialist IDE when one is installed — Xcode for Swift packages, Android Studio for Gradle projects, Rider for .NET solutions, and so on. An explicit per-repo or global editor choice is always respected as-is. +- **New editors**: iTerm2, Sublime Text, Tower, and the full JetBrains family (Rider, GoLand, CLion, PhpStorm, RubyMine) are now available in the Open In menu and the Default Editor picker. +- Added an "Automatic" entry to the Open In dropdown that clears any pinned app for the current repo and returns to automatic selection. + +### Fixed + +- Agent status no longer flickers between Working and Done during brief pauses between steps. The stabilization hold is now 3 seconds and applies to all detected agents (Claude, Codex, Gemini, and others), not just Claude. +- Opening the transcript viewer (`ctrl+r`) or search overlay while an agent is working no longer briefly flashes the agent's status to Done. +- Conversation text that quotes Claude viewer hint strings (e.g. "ctrl+r to toggle") no longer causes the agent to appear idle while it is still working. + ## [2026.6.11](https://github.com/onevcat/Prowl/releases/tag/v2026.6.11) This release adds split-pane zoom controls, Shelf agent status badges, and trackpad navigation for the Shelf. -- 2.51.2 From 54ce23fb71db6c66b8169ce23a01071b1d22ca2c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 17:42:58 +0900 Subject: [PATCH 412/528] Support Oh My Pi agent detection --- docs/components/agent-detection.md | 13 ++++----- .../Terminal/Models/CommandIconMap.swift | 2 ++ .../AgentDetection/AgentClassifier.swift | 2 +- .../AgentDetection/ScreenHeuristics.swift | 27 ++++++++++++++++++- supacodeTests/AgentClassifierTests.swift | 21 +++++++++++++++ supacodeTests/CommandIconMapTests.swift | 2 ++ supacodeTests/ScreenHeuristicsTests.swift | 3 +++ 7 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 0206f08d..75ccd92a 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,8 +17,9 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, and Pi. Detection covers common wrappers (node, python, bun, -bash, etc.) so agents launched indirectly are still found. +Kimi, Droid, Amp, and Pi / Oh My Pi (`pi`, `omp`, `oh-my-pi`). Detection covers +common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are +still found. ## How detection works (two stages) @@ -26,10 +27,10 @@ bash, etc.) so agents launched indirectly are still found. process names / argv against known agent executables, scoring argv[0] highest, then process name, then command-line tokens. 2. **Screen heuristics.** It scans the last ~24 non-blank lines of the pane for - agent-specific UI cues — e.g. "Esc to interrupt" (working), confirmation/ - permission prompts (blocked), idle prompts. Each agent has its own patterns - (including spinner glyphs: braille frames, symbol cycles, Cursor's hexagons, - Kimi's moon phases, etc.). + agent-specific UI cues — e.g. "Esc to interrupt" or Oh My Pi's `Working… ⟦esc⟧` + loader (working), confirmation/permission prompts (blocked), idle prompts. Each + agent has its own patterns (including spinner glyphs: braille frames, symbol cycles, + Cursor's hexagons, Kimi's moon phases, etc.). To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index a9360ac5..7905666a 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -44,6 +44,8 @@ enum CommandIconMap { "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), + "omp": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), + "oh-my-pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), "pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), // Editors / IDEs / pagers diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 9e8b0a67..5540684b 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -2,7 +2,7 @@ import Foundation func identifyAgent(processName: String) -> DetectedAgent? { switch processName.lowercased() { - case "pi": + case "pi", "omp", "oh-my-pi": return .pi case "claude", "claude-code": return .claude diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 86feb619..c17c92c5 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -52,7 +52,32 @@ nonisolated private func recentLines(_ content: String, limit: Int) -> String { } nonisolated private func detectPi(_ content: String) -> AgentRawState { - content.contains("Working...") ? .working : .idle + let lower = content.lowercased() + if lower.contains("working...") + || content.contains("Working…") + || content.contains("Interrupting…") + || hasPiInterruptHint(content) + { + return .working + } + return .idle +} + +nonisolated private func hasPiInterruptHint(_ content: String) -> Bool { + let interruptHints = ["⟦esc⟧", "⟨esc⟩", "[esc]"] + let bottomLines = + content + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .suffix(5) + + return bottomLines.contains { line in + interruptHints.contains { hint in + guard line.hasSuffix(hint) else { return false } + return !line.dropLast(hint.count).trimmingCharacters(in: .whitespaces).isEmpty + } + } } nonisolated private func detectClaude(_ content: String) -> AgentRawState { diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index 912a91e2..c92bc47a 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -25,6 +25,27 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "amp-local") == .amp) } + @Test func identifiesOhMyPiCommandNames() throws { + #expect(identifyAgent(processName: "omp") == .pi) + #expect(identifyAgent(processName: "oh-my-pi") == .pi) + + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "bun", + argv0: "bun", + cmdline: "bun /opt/homebrew/bin/omp --model gpt-5" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .pi) + #expect(result.name == "omp") + } + @Test func identifiesCursorAgentAliasCommandLines() throws { let job = ForegroundJob( processGroupID: 42, diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index ad4b0f5d..133ea4bb 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -63,6 +63,8 @@ struct CommandIconMapTests { // MARK: - Coding agents @Test func codingAgentsResolved() { + #expect(CommandIconMap.iconForFirstToken("omp")?.assetName == "Pi") + #expect(CommandIconMap.iconForFirstToken("oh-my-pi")?.assetName == "Pi") // Sample of the coding-agent set — they all share the sparkle SF // Symbol fallback, asset names match the imageset folders. #expect(CommandIconMap.iconForFirstToken("agent")?.assetName == "Cursor") diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 56c9c2a7..0cdca1d0 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -10,6 +10,9 @@ struct ScreenHeuristicsTests { @Test func piDetection() { #expect(DetectedAgent.pi.detectState(in: "Working...") == .working) + #expect(DetectedAgent.pi.detectState(in: "Working… ⟦esc⟧") == .working) + #expect(DetectedAgent.pi.detectState(in: "Reading files ⟨esc⟩") == .working) + #expect(DetectedAgent.pi.detectState(in: "Interrupting…") == .working) #expect(DetectedAgent.pi.detectState(in: "Done") == .idle) } -- 2.51.2 From 42a716d49b89087f70587151423f555617eca8ca Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 18:07:52 +0900 Subject: [PATCH 413/528] Fix Oh My Pi working-state heuristics --- docs/components/agent-detection.md | 8 +-- .../AgentDetection/ScreenHeuristics.swift | 46 ++++++++-------- supacodeTests/ScreenHeuristicsTests.swift | 53 +++++++++++++++++++ 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 75ccd92a..2e1b30ea 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -27,10 +27,10 @@ still found. process names / argv against known agent executables, scoring argv[0] highest, then process name, then command-line tokens. 2. **Screen heuristics.** It scans the last ~24 non-blank lines of the pane for - agent-specific UI cues — e.g. "Esc to interrupt" or Oh My Pi's `Working… ⟦esc⟧` - loader (working), confirmation/permission prompts (blocked), idle prompts. Each - agent has its own patterns (including spinner glyphs: braille frames, symbol cycles, - Cursor's hexagons, Kimi's moon phases, etc.). + agent-specific UI cues — e.g. "Esc to interrupt", Oh My Pi's `Working… ⟦esc⟧` + loader or braille spinner status line (working), confirmation/permission prompts + (blocked), idle prompts. Each agent has its own patterns (including spinner glyphs: + braille frames, symbol cycles, Cursor's hexagons, Kimi's moon phases, etc.). To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index c17c92c5..580c7fe3 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -52,34 +52,38 @@ nonisolated private func recentLines(_ content: String, limit: Int) -> String { } nonisolated private func detectPi(_ content: String) -> AgentRawState { - let lower = content.lowercased() - if lower.contains("working...") - || content.contains("Working…") - || content.contains("Interrupting…") - || hasPiInterruptHint(content) - { - return .working + hasPiWorkingLine(content) ? .working : .idle +} + +nonisolated private func hasPiWorkingLine(_ content: String) -> Bool { + content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return isPiWorkingText(trimmed) + || hasPiInterruptHint(trimmed) + || hasPiBrailleSpinner(trimmed) } - return .idle } -nonisolated private func hasPiInterruptHint(_ content: String) -> Bool { - let interruptHints = ["⟦esc⟧", "⟨esc⟩", "[esc]"] - let bottomLines = - content - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - .suffix(5) +nonisolated private func isPiWorkingText(_ line: String) -> Bool { + line == "Working..." || line == "Working…" || line == "Interrupting…" +} - return bottomLines.contains { line in - interruptHints.contains { hint in - guard line.hasSuffix(hint) else { return false } - return !line.dropLast(hint.count).trimmingCharacters(in: .whitespaces).isEmpty - } +nonisolated private func hasPiInterruptHint(_ line: String) -> Bool { + let interruptHints = ["⟦esc⟧", "⟨esc⟩", "[esc]"] + return interruptHints.contains { hint in + guard line.hasSuffix(hint) else { return false } + return !line.dropLast(hint.count).trimmingCharacters(in: .whitespaces).isEmpty } } +nonisolated private func hasPiBrailleSpinner(_ line: String) -> Bool { + guard let first = line.unicodeScalars.first else { return false } + let rest = String(line.unicodeScalars.dropFirst()) + return (0x2800...0x28FF).contains(Int(first.value)) + && rest.hasPrefix(" ") + && rest.contains(where: \.isLetter) +} + nonisolated private func detectClaude(_ content: String) -> AgentRawState { if hasClaudeViewerChrome(content) { return .unknown diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 0cdca1d0..36532fca 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -16,6 +16,59 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.pi.detectState(in: "Done") == .idle) } + @Test func piIgnoresStaleWorkingMentionInCompletedOutput() { + #expect( + DetectedAgent.pi.detectState( + in: """ + 2. 增强 Pi / Oh My Pi 的屏幕状态判断 + - 原来 Pi 只认 Working... + - 现在还认: + - Working… + - Interrupting… + - 底部 5 行中以 ⟦esc⟧ / ⟨esc⟩ / [esc] 结尾、且前面有内容的行 + - 这些都会判定为 .working。 + - 其他情况仍然是 .idle。 + + 结论:这是一个很聚焦的 branch,目的就是让 Prowl 正确识别 Oh My Pi,并显示 Pi 图标与 working 状态。 + +  857  685 cache: 45K + ╭──    GPT-5.5 ·  high   ~/Sync/github/Prowl   add-oh-my-pi-agent ──╮ + ╰─ + """ + ) == .idle + ) + } + + @Test func piDetectsActiveSpinnerEvenWhenStatusPanelFollows() { + #expect( + DetectedAgent.pi.detectState( + in: """ + After I add a failing test case, I should edit the code accordingly. It’s important to reproduce the error first, then run the Swift tests after the edits. + +  980  571 cache: 50K + + ┌───  Todo 5 tasks ─────────────────────────────────────────────────────┐ + │ I. Investigation │ + │ ├─  Reproduce stale working state │ + │ └─  Trace Pi heuristic cause │ + │ II. Fix │ + │ ├─  Add failing regression test │ + │ ├─  Tighten Pi working detection │ + │ └─  Run relevant verification │ + └────────────────────────────────────────────────────────────────────────┘ + + ⠹ Updating progress ⟨esc⟩ + + Todos + └ II. Fix + └  Add failing regression test +  Tighten Pi working detection +  Run relevant verification + """ + ) == .working + ) + } + @Test func claudeDetection() { #expect( DetectedAgent.claude.detectState( -- 2.51.2 From 22797a7fffe475c38783e7a9c71e95ca4d27ccca Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 18:35:16 +0900 Subject: [PATCH 414/528] Distinguish OMP command icon --- docs/components/agent-detection.md | 7 ++++--- .../CommandIcons/OMP.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/OMP.imageset/OMP.svg | 1 + .../Terminal/Models/CommandIconMap.swift | 4 ++-- supacodeTests/CommandIconMapTests.swift | 4 ++-- supacodeTests/ScreenHeuristicsTests.swift | 3 ++- 6 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 supacode/Assets.xcassets/CommandIcons/OMP.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/OMP.imageset/OMP.svg diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 2e1b30ea..a4977dd4 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,9 +17,10 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, and Pi / Oh My Pi (`pi`, `omp`, `oh-my-pi`). Detection covers +Kimi, Droid, Amp, Pi (`pi`), and Oh My Pi (`omp`, `oh-my-pi`). Detection covers common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are -still found. +still found. Oh My Pi reuses Pi-derived screen heuristics but uses its own command +icon where Prowl shows detected command icons, including terminal tabs. ## How detection works (two stages) @@ -29,7 +30,7 @@ still found. 2. **Screen heuristics.** It scans the last ~24 non-blank lines of the pane for agent-specific UI cues — e.g. "Esc to interrupt", Oh My Pi's `Working… ⟦esc⟧` loader or braille spinner status line (working), confirmation/permission prompts - (blocked), idle prompts. Each agent has its own patterns (including spinner glyphs: + (blocked), idle prompts. Each agent family has its own patterns (including spinner glyphs: braille frames, symbol cycles, Cursor's hexagons, Kimi's moon phases, etc.). To avoid flicker, detection **stabilizes**: it tolerates several consecutive diff --git a/supacode/Assets.xcassets/CommandIcons/OMP.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/OMP.imageset/Contents.json new file mode 100644 index 00000000..74fa3e63 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/OMP.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "OMP.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/OMP.imageset/OMP.svg b/supacode/Assets.xcassets/CommandIcons/OMP.imageset/OMP.svg new file mode 100644 index 00000000..71f784ed --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/OMP.imageset/OMP.svg @@ -0,0 +1 @@ +omp diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index 7905666a..665cb00c 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -44,8 +44,8 @@ enum CommandIconMap { "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), - "omp": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), - "oh-my-pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), + "omp": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), + "oh-my-pi": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), "pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), // Editors / IDEs / pagers diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index 133ea4bb..1a9cd28c 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -63,8 +63,8 @@ struct CommandIconMapTests { // MARK: - Coding agents @Test func codingAgentsResolved() { - #expect(CommandIconMap.iconForFirstToken("omp")?.assetName == "Pi") - #expect(CommandIconMap.iconForFirstToken("oh-my-pi")?.assetName == "Pi") + #expect(CommandIconMap.iconForFirstToken("omp")?.assetName == "OMP") + #expect(CommandIconMap.iconForFirstToken("oh-my-pi")?.assetName == "OMP") // Sample of the coding-agent set — they all share the sparkle SF // Symbol fallback, asset names match the imageset folders. #expect(CommandIconMap.iconForFirstToken("agent")?.assetName == "Cursor") diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 36532fca..633344f1 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -43,7 +43,8 @@ struct ScreenHeuristicsTests { #expect( DetectedAgent.pi.detectState( in: """ - After I add a failing test case, I should edit the code accordingly. It’s important to reproduce the error first, then run the Swift tests after the edits. + After I add a failing test case, I should edit the code accordingly. + It’s important to reproduce the error first, then run the Swift tests after the edits.  980  571 cache: 50K -- 2.51.2 From fad1f0c4bd90af91b3e35fac40f07ae76c22f864 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 18:51:52 +0900 Subject: [PATCH 415/528] Fix Active Agents command icon aliases --- docs/components/active-agents.md | 4 ++- .../AgentDetection/PaneAgentState.swift | 3 +++ .../Models/ActiveAgentEntry.swift | 7 +++++ .../ActiveAgents/Views/ActiveAgentRow.swift | 2 +- ...WorktreeTerminalState+AgentDetection.swift | 3 +++ supacodeTests/ActiveAgentsFeatureTests.swift | 27 +++++++++++++++++-- .../AppFeatureSettingsChangedTests.swift | 1 + supacodeTests/RepositoriesFeatureTests.swift | 3 +++ .../RepositorySectionViewTests.swift | 1 + 9 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index c72ab17c..f8efec90 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -24,7 +24,9 @@ Command Palette → "Toggle Active Agents Panel". tab title or branch (secondary) ``` -- **Icon** — the detected agent's icon (falls back to a sparkle). +- **Icon** — the detected command/agent icon (for example `omp` keeps the OMP + icon even though it reports as the Pi agent; unknown wrappers fall back to the + agent icon, then a sparkle). - **Title** — agent name + repository (repo color-coded). - **Subtitle** — the tab title (if `showActiveAgentTabTitles`) or branch name. - **Status pill** — one of: diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index e336f0c1..68440989 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -2,6 +2,7 @@ import Foundation struct PaneAgentState: Equatable, Sendable { var detectedAgent: DetectedAgent? + var iconLookupToken: String? var fallbackState: AgentRawState var state: AgentRawState var seen: Bool @@ -9,12 +10,14 @@ struct PaneAgentState: Equatable, Sendable { init( detectedAgent: DetectedAgent? = nil, + iconLookupToken: String? = nil, fallbackState: AgentRawState = .unknown, state: AgentRawState = .unknown, seen: Bool = true, lastChangedAt: Date = Date() ) { self.detectedAgent = detectedAgent + self.iconLookupToken = iconLookupToken self.fallbackState = fallbackState self.state = state self.seen = seen diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index bd6b7aa0..29e3b9ef 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -16,8 +16,15 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { let tabTitle: String let surfaceID: UUID let paneIndex: Int + /// Command/process token used for row icon lookup. This can be more specific than + /// `agent` for aliases that share one semantic agent, e.g. `omp` vs `pi`. + let iconLookupToken: String let agent: DetectedAgent let rawState: AgentRawState let displayState: AgentDisplayState let lastChangedAt: Date + + var iconSource: TabIconSource? { + CommandIconMap.iconForFirstToken(iconLookupToken) ?? CommandIconMap.iconForFirstToken(agent.iconLookupToken) + } } diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index b2776621..61c06af0 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -46,7 +46,7 @@ struct ActiveAgentRow: View { private var agentIcon: some View { Group { - if let icon = CommandIconMap.iconForFirstToken(entry.agent.iconLookupToken) { + if let icon = entry.iconSource { TabIconImage(rawName: icon.storageString, pointSize: 16) } else { Image(systemName: "sparkle") diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 2e8c35cd..b79d8658 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -100,9 +100,11 @@ extension WorktreeTerminalState { } else { seen = previous.seen } + let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt let next = PaneAgentState( detectedAgent: agent, + iconLookupToken: iconLookupToken, fallbackState: raw, state: stabilized, seen: seen, @@ -191,6 +193,7 @@ extension WorktreeTerminalState { tabTitle: tabTitle, surfaceID: surfaceID, paneIndex: paneIndex, + iconLookupToken: state.iconLookupToken ?? agent.iconLookupToken, agent: agent, rawState: state.fallbackState, displayState: state.displayState, diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index a5f0553f..241ae379 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -100,6 +100,26 @@ struct ActiveAgentsFeatureTests { ) } + @Test func rowIconUsesDetectedCommandTokenBeforeAgentFallback() { + let ompEntry = entry( + id: UUID(0), + state: .idle, + changedAt: Date(timeIntervalSince1970: 10), + agent: .pi, + iconLookupToken: "omp" + ) + #expect(ompEntry.iconSource?.assetName == "OMP") + + let fallbackEntry = entry( + id: UUID(1), + state: .idle, + changedAt: Date(timeIntervalSince1970: 10), + agent: .pi, + iconLookupToken: "unknown-wrapper" + ) + #expect(fallbackEntry.iconSource?.assetName == "Pi") + } + @Test func navigationReturnsNilForEmptyList() { let entries: IdentifiedArrayOf = [] #expect(ActiveAgentsFeature.entryID(navigatingFrom: nil, direction: .next, in: entries) == nil) @@ -228,7 +248,9 @@ struct ActiveAgentsFeatureTests { id: UUID, tabTitle: String = "1", state: AgentDisplayState, - changedAt: Date + changedAt: Date, + agent: DetectedAgent = .codex, + iconLookupToken: String? = nil ) -> ActiveAgentEntry { ActiveAgentEntry( id: id, @@ -239,7 +261,8 @@ struct ActiveAgentsFeatureTests { tabTitle: tabTitle, surfaceID: id, paneIndex: 1, - agent: .codex, + iconLookupToken: iconLookupToken ?? agent.iconLookupToken, + agent: agent, rawState: state == .blocked ? .blocked : state == .working ? .working : .idle, displayState: state, lastChangedAt: changedAt diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index bd6a31af..86fb0de6 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -246,6 +246,7 @@ struct AppFeatureSettingsChangedTests { tabTitle: "codex", surfaceID: fixedUUID(0), paneIndex: 1, + iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 33532f5c..c8d4f90f 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1025,6 +1025,7 @@ struct RepositoriesFeatureTests { tabTitle: "agent", surfaceID: surfaceID, paneIndex: 0, + iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, @@ -1079,6 +1080,7 @@ struct RepositoriesFeatureTests { tabTitle: "agent", surfaceID: surfaceID, paneIndex: 0, + iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, @@ -1276,6 +1278,7 @@ struct RepositoriesFeatureTests { tabTitle: "agent", surfaceID: surfaceID, paneIndex: 0, + iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index b9c75d73..0d247608 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -305,6 +305,7 @@ struct RepositorySectionViewTests { tabTitle: "agent", surfaceID: UUID(), paneIndex: 1, + iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, -- 2.51.2 From 7f6e08afbc42ada25428ada55891711b52b80be1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 19:21:00 +0900 Subject: [PATCH 416/528] Show command aliases in Active Agents --- docs/components/active-agents.md | 3 ++- .../ActiveAgents/Models/ActiveAgentEntry.swift | 11 +++++++++++ .../ActiveAgents/Views/ActiveAgentRow.swift | 2 +- supacode/Features/Shelf/Views/ShelfSpineView.swift | 4 ++-- supacodeTests/ActiveAgentsFeatureTests.swift | 13 ++++++++++++- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index f8efec90..df32cff6 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -27,7 +27,8 @@ Command Palette → "Toggle Active Agents Panel". - **Icon** — the detected command/agent icon (for example `omp` keeps the OMP icon even though it reports as the Pi agent; unknown wrappers fall back to the agent icon, then a sparkle). -- **Title** — agent name + repository (repo color-coded). +- **Title** — detected command/agent name + repository (repo color-coded); + command aliases such as `omp` are shown directly. - **Subtitle** — the tab title (if `showActiveAgentTabTitles`) or branch name. - **Status pill** — one of: diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index 29e3b9ef..e58deb80 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -23,6 +23,17 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { let rawState: AgentRawState let displayState: AgentDisplayState let lastChangedAt: Date + var displayName: String { + let trimmed = iconLookupToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard + !trimmed.isEmpty, + trimmed != "agent", + CommandIconMap.iconForFirstToken(trimmed) != nil + else { + return agent.displayName + } + return trimmed + } var iconSource: TabIconSource? { CommandIconMap.iconForFirstToken(iconLookupToken) ?? CommandIconMap.iconForFirstToken(agent.iconLookupToken) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 61c06af0..88c0cb05 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -31,7 +31,7 @@ struct ActiveAgentRow: View { private var title: some View { HStack(alignment: .firstTextBaseline, spacing: 3) { - Text(entry.agent.displayName) + Text(entry.displayName) .font(.body.weight(.medium)) .foregroundStyle(.primary) Text("·") diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 243f95a7..81e1a216 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -619,12 +619,12 @@ private struct ShelfSpineTabSlot: View { private var helpText: String { guard let activeAgentEntry else { return tab.displayTitle } let tabTitle = ActiveAgentsPanel.tabTitle(for: activeAgentEntry) - return "Jump to \(activeAgentEntry.agent.displayName): \(activeAgentEntry.displayState.label) - \(tabTitle)" + return "Jump to \(activeAgentEntry.displayName): \(activeAgentEntry.displayState.label) - \(tabTitle)" } private var accessibilityLabel: String { guard let activeAgentEntry else { return tab.displayTitle } - return "Jump to \(activeAgentEntry.agent.displayName), \(activeAgentEntry.displayState.label)" + return "Jump to \(activeAgentEntry.displayName), \(activeAgentEntry.displayState.label)" } } diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 241ae379..89e6e437 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -100,7 +100,7 @@ struct ActiveAgentsFeatureTests { ) } - @Test func rowIconUsesDetectedCommandTokenBeforeAgentFallback() { + @Test func rowDisplayUsesDetectedCommandTokenBeforeAgentFallback() { let ompEntry = entry( id: UUID(0), state: .idle, @@ -109,6 +109,7 @@ struct ActiveAgentsFeatureTests { iconLookupToken: "omp" ) #expect(ompEntry.iconSource?.assetName == "OMP") + #expect(ompEntry.displayName == "omp") let fallbackEntry = entry( id: UUID(1), @@ -118,6 +119,16 @@ struct ActiveAgentsFeatureTests { iconLookupToken: "unknown-wrapper" ) #expect(fallbackEntry.iconSource?.assetName == "Pi") + #expect(fallbackEntry.displayName == "pi") + + let cursorEntry = entry( + id: UUID(2), + state: .idle, + changedAt: Date(timeIntervalSince1970: 10), + agent: .cursor, + iconLookupToken: "agent" + ) + #expect(cursorEntry.displayName == "cursor") } @Test func navigationReturnsNilForEmptyList() { -- 2.51.2 From 9fa4a3a5c985ddb361c0e9d7639635322cd7cd58 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 21:43:48 +0900 Subject: [PATCH 417/528] Improve active agent detection scheduling --- .../plans/2026-06-13-prowl-cli-agents-plan.md | 170 ++++++++++++++++++ docs/components/agent-detection.md | 6 +- .../Clients/Terminal/TerminalClient.swift | 1 - .../AgentDetectionSchedule.swift | 44 +++++ .../Reducer/ActiveAgentsFeature.swift | 7 - .../Features/App/Reducer/AppFeature.swift | 62 ------- .../WorktreeTerminalManager.swift | 12 -- ...WorktreeTerminalState+AgentDetection.swift | 67 ++++--- .../WorktreeTerminalState+Surfaces.swift | 5 +- .../Models/WorktreeTerminalState.swift | 16 +- supacodeTests/ActiveAgentsFeatureTests.swift | 31 ---- .../AgentDetectionScheduleTests.swift | 42 +++++ .../AppFeatureArchivedSelectionTests.swift | 3 - .../AppFeatureSettingsChangedTests.swift | 68 ------- .../WorktreeTerminalManagerTests.swift | 44 +++++ 15 files changed, 366 insertions(+), 212 deletions(-) create mode 100644 doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md create mode 100644 supacode/Domain/AgentDetection/AgentDetectionSchedule.swift create mode 100644 supacodeTests/AgentDetectionScheduleTests.swift diff --git a/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md b/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md new file mode 100644 index 00000000..3fb728cd --- /dev/null +++ b/doc-onevcat/plans/2026-06-13-prowl-cli-agents-plan.md @@ -0,0 +1,170 @@ +# Prowl CLI Agents Command Plan + +## Context + +Issue: + +The request is to expose the same Active Agents roster that Prowl already shows +in the sidebar through the `prowl` CLI. The CLI should be read-only for this +feature: switching/focusing is already covered by existing commands such as +`prowl focus --pane `, `prowl read --pane `, and `prowl send --pane `. + +Before implementing the command, agent detection scheduling should be made +reliable and efficient independently of the Active Agents panel visibility. The +CLI command should not depend on whether the panel is expanded, whether Shelf +status markers are visible, or any other UI-only preference. + +## Proposed Command + +Add: + +```bash +prowl agents +prowl agents --json +``` + +Do not add a first-class "switch agent" subcommand. Users and automation can +resolve `pane.id` from `prowl agents --json`, then call existing pane-oriented +commands. + +## Output Semantics + +`prowl agents` should expose detected agents, not the worktree-level task status +from `prowl list`. + +Important distinction: + +- `prowl list` currently reports `task.status` at worktree level as + `running | idle | null`. +- `prowl agents` should report per-pane agent detection state as + `working | blocked | done | idle`, plus the raw detector state. + +The command should return only panes where an agent is currently detected or has +a retained Active Agents entry. Empty shells and ordinary non-agent commands +should not appear. + +## JSON Schema Sketch + +Schema version: `prowl.cli.agents.v1` + +```json +{ + "count": 2, + "agents": [ + { + "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", + "type": "codex", + "name": "codex", + "status": "blocked", + "raw_state": "blocked", + "last_changed_at": "2026-06-13T04:12:25Z", + "project": { + "name": "Prowl", + "branch": "feature/cli-agents", + "path": "/Users/onevcat/Sync/github/Prowl" + }, + "worktree": { + "id": "Prowl:/Users/onevcat/Sync/github/Prowl", + "name": "feature/cli-agents", + "path": "/Users/onevcat/Sync/github/Prowl", + "root_path": "/Users/onevcat/Sync/github/Prowl", + "kind": "git" + }, + "tab": { + "id": "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0", + "title": "issue 330", + "selected": true + }, + "pane": { + "id": "6E1A2A10-D99F-4E3F-920C-D93AA3C05764", + "index": 1, + "title": "codex", + "cwd": "/Users/onevcat/Sync/github/Prowl", + "focused": false + } + } + ] +} +``` + +Notes: + +- `id` should equal `pane.id` / `surfaceID`, matching Active Agents entries. +- `type` should be the normalized `DetectedAgent.rawValue`. +- `name` should be `ActiveAgentEntry.displayName`, preserving command aliases + such as `omp`. +- `status` should be `ActiveAgentEntry.displayState.rawValue`. +- `raw_state` should be `ActiveAgentEntry.rawState.rawValue`. +- `last_changed_at` should use ISO-8601. + +## Project vs Owning Worktree + +An agent may run in a different directory than the worktree that owns its +terminal pane. The CLI should expose both: + +- `project`: display-oriented repository/branch resolved from + `ActiveAgentEntry.workingDirectory`, using the same rules as the Active Agents + panel (`SidebarListView.activeAgentRowDisplay`). +- `worktree`: the actual terminal owner, used for focus/read/send targeting. + +This prevents automation from losing the concrete pane while still showing the +human-facing project label users expect. + +## Text Rendering + +Default text output should optimize for scanability: + +```text +Blocked codex Prowl:feature/cli-agents issue 330 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 +Working claude Notes:main review EF65FF31-1B72-40B2-80DA-3AA87B7B6858 +``` + +Suggested ordering: + +1. `blocked` +2. `working` +3. `done` +4. `idle` + +Within each status group, preserve Active Agents insertion order unless a later +UX pass finds a better sort. + +## Implementation Plan + +1. Add shared CLI input/payload models: + - `AgentsInput` + - `AgentsCommandPayload` + - `AgentsCommandAgent` + - nested `project`, `worktree`, `tab`, and `pane` payload structs +2. Add `Command.agents(AgentsInput)` and route it through `CLICommandRouter`. +3. Add `AgentsCommandHandler`. + - Snapshot source: `appStore.state.repositories.activeAgents.entries` + - Repository metadata: reuse `SidebarListView.activeAgentWorktreeMetadata` + and `SidebarListView.activeAgentRowDisplay`. + - Terminal metadata: reuse existing target/list snapshot builders where + possible to resolve tab selected state, pane title, cwd, and focus. +4. Add `ProwlCLI/Commands/AgentsCommand.swift` and register it in + `ProwlCommand`. +5. Add text rendering in `OutputRenderer.renderAgents`. +6. Update `docs/components/cli.md` and `docs/components/active-agents.md`. + +## Test Plan + +- Command envelope round-trip for `agents`. +- Router dispatch test. +- Handler payload test covering: + - status/raw state passthrough + - alias display name (`omp` vs `pi`) + - project label from `workingDirectory` + - owning worktree/pane still present + - focused pane marking +- CLI integration test for JSON output. +- CLI text rendering test for status ordering and empty state. + +## Open Questions + +- Whether `idle` agents should be included by default or hidden behind a flag. + Initial recommendation: include them because the Active Agents panel includes + retained idle/done entries, and automation can filter by status. +- Whether to add filtering flags such as `--status blocked` later. Initial + recommendation: skip flags for v1; JSON + `jq` is enough. diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index a4977dd4..26fff9b5 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -58,8 +58,10 @@ A **Done** pane becomes **Idle** the moment you focus it. ## How often it runs -- ~**300 ms** when an agent is present or you're typing (active). -- ~**2 s** when idle. +- **No polling** for cold panes that have not received recent input. +- ~**2 s** for a short warm window after typing, paste, CLI input, or an initial + command starts in the pane. +- ~**300 ms** once an agent is detected, so Working/Blocked/Done stays responsive. The heavier process probe is throttled (cached ≈ 0.75 s per process group unless something changes) so many panes don't add up to high CPU. Status indicators redraw on a diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index cd2136ff..0894218f 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -50,7 +50,6 @@ struct TerminalClient { case prune(Set) case setNotificationsEnabled(Bool) case setCommandFinishedNotification(enabled: Bool, threshold: Int) - case setAgentDetectionEnabled(Bool) case setCanvasMode(Bool) case setSelectedWorktreeID(Worktree.ID?) case saveLayoutSnapshot diff --git a/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift b/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift new file mode 100644 index 00000000..c412c07b --- /dev/null +++ b/supacode/Domain/AgentDetection/AgentDetectionSchedule.swift @@ -0,0 +1,44 @@ +import Foundation + +enum AgentDetectionSchedule: Equatable, Sendable { + static let warmWindow: TimeInterval = 30 + + case cold + case warm(until: Date) + case active + + func warmed(now: Date) -> Self { + switch self { + case .active: + return .active + case .cold, .warm: + return .warm(until: now.addingTimeInterval(Self.warmWindow)) + } + } + + func observedAgent(now _: Date) -> Self { + .active + } + + func observedNoAgent(now: Date) -> Self { + switch self { + case .active: + return .warm(until: now.addingTimeInterval(Self.warmWindow)) + case .warm(let until) where until > now: + return .warm(until: until) + case .cold, .warm: + return .cold + } + } + + func nextInterval(now: Date) -> Duration? { + switch self { + case .cold: + return nil + case .warm(let until): + return until > now ? idleAgentDetectionInterval : nil + case .active: + return activeAgentDetectionInterval + } + } +} diff --git a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift index 21cf8b55..3e5c802f 100644 --- a/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift +++ b/supacode/Features/ActiveAgents/Reducer/ActiveAgentsFeature.swift @@ -128,11 +128,4 @@ struct ActiveAgentsFeature { max(minimumPanelHeight, min(maximumPanelHeight, height - reservedSidebarListHeight)) } - static func detectionEnabled( - isPanelHidden: Bool, - autoShowPanel: Bool, - isShelfVisible: Bool - ) -> Bool { - isShelfVisible || !isPanelHidden || autoShowPanel - } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 756b8b0c..d19b4c2f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -81,7 +81,6 @@ struct AppFeature { case endSearch case systemNotificationsPermissionFailed(errorMessage: String?) case systemNotificationTapped(worktreeID: Worktree.ID, surfaceID: UUID) - case syncAgentDetectionEnabled case alert(PresentationAction) case terminalEvent(TerminalClient.Event) } @@ -104,35 +103,6 @@ struct AppFeature { @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient - func agentDetectionEnabled( - state: State, - autoShowPanel: Bool? = nil, - showShelfStatus: Bool? = nil - ) -> Bool { - let shelfStatusVisible = - state.repositories.isShowingShelf && (showShelfStatus ?? state.settings.showActiveAgentStatusInShelf) - return ActiveAgentsFeature.detectionEnabled( - isPanelHidden: state.repositories.activeAgents.isPanelHidden, - autoShowPanel: autoShowPanel ?? state.settings.autoShowActiveAgentsPanel, - isShelfVisible: shelfStatusVisible - ) - } - - static func repositoriesActionMayChangeShelfVisibility(_ action: RepositoriesFeature.Action) -> Bool { - switch action { - case .selectArchivedWorktrees, .selectCanvas, .selectShelf, .selectTabbed, .setTopSegment, .toggleCanvas, - .toggleShelf: - return true - // `isShowingShelf` also requires a non-empty repository list, so actions - // that add or remove repositories can flip it without touching `isShelfActive`. - case .repositoriesLoaded, .repositoryManagement(.repositoryRemoved), - .repositoryManagement(.openRepositoriesFinished): - return true - default: - return false - } - } - var body: some Reducer { let core = Reduce { state, action in switch action { @@ -141,15 +111,11 @@ struct AppFeature { appLogger.info("[LayoutRestore] appLaunched: launchRestoreMode=\(String(describing: state.launchRestoreMode))") state.launchedAt = now state.repositories.launchRestoreMode = state.launchRestoreMode - let agentDetectionEnabled = agentDetectionEnabled(state: state) analyticsClient.capture("app_launched", nil) return .merge( .send(.repositories(.task)), .send(.settings(.task)), .send(.updates(.task)), - .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - }, .run { _ in await dockClient.setNotificationBadge(0) }, @@ -414,11 +380,6 @@ struct AppFeature { state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles - let agentDetectionEnabled = agentDetectionEnabled( - state: state, - autoShowPanel: settings.autoShowActiveAgentsPanel, - showShelfStatus: settings.showActiveAgentStatusInShelf - ) if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -481,9 +442,6 @@ struct AppFeature { ) ) }, - .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - }, .run { _ in await worktreeInfoWatcher.send( .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) @@ -940,12 +898,6 @@ struct AppFeature { } ) - case .syncAgentDetectionEnabled: - let agentDetectionEnabled = agentDetectionEnabled(state: state) - return .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - } - case .alert(.dismiss): state.alert = nil return .none @@ -960,20 +912,6 @@ struct AppFeature { case .alert: return .none - case .repositories(.activeAgents(.togglePanelVisibility)): - let nextIsPanelHidden = !state.repositories.activeAgents.isPanelHidden - let agentDetectionEnabled = ActiveAgentsFeature.detectionEnabled( - isPanelHidden: nextIsPanelHidden, - autoShowPanel: state.settings.autoShowActiveAgentsPanel, - isShelfVisible: state.repositories.isShowingShelf && state.settings.showActiveAgentStatusInShelf - ) - return .run { _ in - await terminalClient.send(.setAgentDetectionEnabled(agentDetectionEnabled)) - } - - case .repositories(let action) where Self.repositoriesActionMayChangeShelfVisibility(action): - return .send(.syncAgentDetectionEnabled) - case .repositories: return .none diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 264f76ac..4dd83d17 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -15,7 +15,6 @@ final class WorktreeTerminalManager { private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true private var commandFinishedNotificationThreshold = 10 - private var agentDetectionEnabled = true private var preferredFontSize: Float32? private let baselineFontSize: Float32 private var lastNotificationIndicatorCount: Int? @@ -156,8 +155,6 @@ final class WorktreeTerminalManager { setNotificationsEnabled(enabled) case .setCommandFinishedNotification(let enabled, let threshold): setCommandFinishedNotification(enabled: enabled, threshold: threshold) - case .setAgentDetectionEnabled(let enabled): - setAgentDetectionEnabled(enabled) case .setCanvasMode(let enabled): if enabled { terminalLogger.info("[CanvasExit] enteringCanvas previousSelectedWorktree=\(selectedWorktreeID ?? "nil")") @@ -242,7 +239,6 @@ final class WorktreeTerminalManager { enabled: commandFinishedNotificationEnabled, threshold: commandFinishedNotificationThreshold ) - state.setAgentDetectionEnabled(agentDetectionEnabled) state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } @@ -458,14 +454,6 @@ final class WorktreeTerminalManager { } } - func setAgentDetectionEnabled(_ enabled: Bool) { - guard agentDetectionEnabled != enabled else { return } - agentDetectionEnabled = enabled - for state in states.values { - state.setAgentDetectionEnabled(enabled) - } - } - func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { states[worktreeID]?.hasUnseenNotification == true } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index b79d8658..b8ee672d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -2,40 +2,60 @@ import Foundation import GhosttyKit extension WorktreeTerminalState { - func setAgentDetectionEnabled(_ enabled: Bool) { - guard agentDetectionEnabled != enabled else { return } - agentDetectionEnabled = enabled + func wakeAgentDetection(forSurfaceID surfaceID: UUID) { + guard let view = surfaces[surfaceID], + let tabId = tabId(containing: surfaceID) + else { + return + } + wakeAgentDetection(for: view, tabId: tabId) + } - if enabled { - for (surfaceID, view) in surfaces { - guard let tabId = tabId(containing: surfaceID) else { continue } - startAgentDetection(for: view, tabId: tabId) - } - } else { - cleanupAllAgentDetectionState() + func wakeAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID, now: Date = Date()) { + agentDetectionSchedules[view.id] = (agentDetectionSchedules[view.id] ?? .cold).warmed(now: now) + if surfaceAgentStates[view.id] == nil { + surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: now) } + startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) } - func startAgentDetection(for view: GhosttySurfaceView, tabId: TerminalTabID) { - guard agentDetectionEnabled else { return } - agentDetectionTasks[view.id]?.cancel() - surfaceAgentStates[view.id] = PaneAgentState(lastChangedAt: Date()) + func startAgentDetectionTaskIfNeeded(for view: GhosttySurfaceView, tabId: TerminalTabID) { + guard agentDetectionTasks[view.id] == nil else { return } agentDetectionTasks[view.id] = Task { @MainActor [weak self, weak view] in while !Task.isCancelled { guard let self, let view, self.surfaces[view.id] != nil else { return } - await self.detectAgentState(for: view, tabId: tabId) - let hasAgent = self.surfaceAgentStates[view.id]?.detectedAgent != nil - try? await Task.sleep(for: hasAgent ? activeAgentDetectionInterval : idleAgentDetectionInterval) + let hasAgent = await self.detectAgentState(for: view, tabId: tabId) + let now = Date() + let schedule = self.agentDetectionSchedules[view.id] ?? .cold + self.agentDetectionSchedules[view.id] = + hasAgent ? schedule.observedAgent(now: now) : schedule.observedNoAgent(now: now) + + guard let interval = self.agentDetectionSchedules[view.id]?.nextInterval(now: now) else { + self.finishColdAgentDetection(forSurfaceID: view.id) + return + } + try? await Task.sleep(for: interval) } } } - func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async { + func finishColdAgentDetection(forSurfaceID surfaceID: UUID) { + agentDetectionTasks.removeValue(forKey: surfaceID) + agentDetectionSchedules.removeValue(forKey: surfaceID) + agentDetectionPresenceBySurface.removeValue(forKey: surfaceID) + lastWorkingAtBySurface.removeValue(forKey: surfaceID) + lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceID) + if surfaceAgentStates[surfaceID]?.detectedAgent == nil { + surfaceAgentStates.removeValue(forKey: surfaceID) + } + } + + func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async -> Bool { let surfaceID = view.id let childPID = view.bridge.childPID() let processGroupID = view.bridge.foregroundProcessGroupID() let job = await AgentProcessProbe.shared.foregroundJob(processGroupID: processGroupID, childPID: childPID) - guard surfaces[surfaceID] != nil else { return } + guard surfaces[surfaceID] != nil else { return false } let identified = job.flatMap { identifyAgentInJob($0) } let probedAgent = identified?.agent @@ -63,7 +83,7 @@ extension WorktreeTerminalState { ) } removeAgentEntryIfNeeded(surfaceID: surfaceID) - return + return false } let now = Date() @@ -76,7 +96,7 @@ extension WorktreeTerminalState { // capture behind that never reached ARC; over a 24 h session this added // up to hundreds of MB of unreferenced allocations. let raw = agent.detectState(in: activeText) - guard surfaces[surfaceID] != nil else { return } + guard surfaces[surfaceID] != nil else { return false } var lastWorkingAt = lastWorkingAtBySurface[surfaceID] let stabilized = stabilizeAgentState( @@ -128,9 +148,10 @@ extension WorktreeTerminalState { ) ) } - guard next != previous else { return } + guard next != previous else { return true } surfaceAgentStates[surfaceID] = next emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) + return true } func markAgentSeen(surfaceID: UUID) { @@ -204,6 +225,7 @@ extension WorktreeTerminalState { func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { agentDetectionTasks[surfaceId]?.cancel() agentDetectionTasks.removeValue(forKey: surfaceId) + agentDetectionSchedules.removeValue(forKey: surfaceId) surfaceAgentStates.removeValue(forKey: surfaceId) agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastWorkingAtBySurface.removeValue(forKey: surfaceId) @@ -217,6 +239,7 @@ extension WorktreeTerminalState { } let removedIDs = Array(surfaceAgentStates.keys) agentDetectionTasks.removeAll() + agentDetectionSchedules.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() lastWorkingAtBySurface.removeAll() diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 9107c58f..e0ea530b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -357,7 +357,9 @@ extension WorktreeTerminalState { configureBridgeCallbacks(for: view, tabId: tabId) configureSurfaceCallbacks(for: view, tabId: tabId) surfaces[view.id] = view - startAgentDetection(for: view, tabId: tabId) + if initialInput?.isEmpty == false { + wakeAgentDetection(for: view, tabId: tabId) + } return view } @@ -423,6 +425,7 @@ extension WorktreeTerminalState { view.onKeyInput = { [weak self, weak view] in guard let self, let view else { return } self.recordKeyInput(forSurfaceID: view.id) + self.wakeAgentDetection(for: view, tabId: tabId) self.markNotificationsRead(forSurfaceID: view.id) } view.onFontSizeShortcut = { [weak self] in diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index c02f67ec..8faf1861 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -70,11 +70,11 @@ final class WorktreeTerminalState { var surfaces: [UUID: GhosttySurfaceView] = [:] var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] var surfaceAgentStates: [UUID: PaneAgentState] = [:] + var agentDetectionSchedules: [UUID: AgentDetectionSchedule] = [:] var agentDetectionTasks: [UUID: Task] = [:] var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] - var agentDetectionEnabled = true var tabIsRunningById: [TerminalTabID: Bool] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var runScriptTabId: TerminalTabID? @@ -213,6 +213,7 @@ final class WorktreeTerminalState { func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool { guard let surface = surfaceView(for: tabId) else { return false } surface.insertCommittedTextForBroadcast(text) + wakeAgentDetection(forSurfaceID: surface.id) return true } @@ -220,6 +221,7 @@ final class WorktreeTerminalState { func insertCommittedText(_ text: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } surface.insertCommittedTextForBroadcast(text) + wakeAgentDetection(forSurfaceID: surface.id) return true } @@ -232,13 +234,21 @@ final class WorktreeTerminalState { @discardableResult func submitLine(in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } - return surface.submitLine() + let submitted = surface.submitLine() + if submitted { + wakeAgentDetection(forSurfaceID: surface.id) + } + return submitted } @discardableResult func sendKeyToken(_ token: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } - return surface.sendCLIKeyToken(token) + let sent = surface.sendCLIKeyToken(token) + if sent { + wakeAgentDetection(forSurfaceID: surface.id) + } + return sent } var taskStatus: WorktreeTaskStatus { diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 89e6e437..8f92f259 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -69,37 +69,6 @@ struct ActiveAgentsFeatureTests { #expect(ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: 250) == 120) } - @Test func detectionStaysEnabledForVisiblePanelAutoShowOrShelf() { - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: false, - autoShowPanel: false, - isShelfVisible: false - ) - ) - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: true, - isShelfVisible: false - ) - ) - #expect( - ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: false, - isShelfVisible: true - ) - ) - #expect( - !ActiveAgentsFeature.detectionEnabled( - isPanelHidden: true, - autoShowPanel: false, - isShelfVisible: false - ) - ) - } - @Test func rowDisplayUsesDetectedCommandTokenBeforeAgentFallback() { let ompEntry = entry( id: UUID(0), diff --git a/supacodeTests/AgentDetectionScheduleTests.swift b/supacodeTests/AgentDetectionScheduleTests.swift new file mode 100644 index 00000000..d3e2431d --- /dev/null +++ b/supacodeTests/AgentDetectionScheduleTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import supacode + +struct AgentDetectionScheduleTests { + @Test func coldSurfacesDoNotScheduleDetection() { + let now = Date(timeIntervalSince1970: 100) + let schedule = AgentDetectionSchedule.cold + + #expect(schedule.nextInterval(now: now) == nil) + } + + @Test func userActivityWarmsAColdSurfaceForAShortWindow() { + let now = Date(timeIntervalSince1970: 100) + let schedule = AgentDetectionSchedule.cold.warmed(now: now) + + #expect(schedule.nextInterval(now: now) == .seconds(2)) + #expect(schedule.nextInterval(now: now.addingTimeInterval(29.9)) == .seconds(2)) + #expect(schedule.nextInterval(now: now.addingTimeInterval(30.1)) == nil) + } + + @Test func warmingExtendsTheWindow() { + let now = Date(timeIntervalSince1970: 100) + let first = AgentDetectionSchedule.cold.warmed(now: now) + let extended = first.warmed(now: now.addingTimeInterval(20)) + + #expect(extended.nextInterval(now: now.addingTimeInterval(49.9)) == .seconds(2)) + #expect(extended.nextInterval(now: now.addingTimeInterval(50.1)) == nil) + } + + @Test func detectedAgentKeepsActiveDetectionUntilItDisappears() { + let now = Date(timeIntervalSince1970: 100) + let active = AgentDetectionSchedule.cold.warmed(now: now).observedAgent(now: now) + + #expect(active.nextInterval(now: now.addingTimeInterval(120)) == .milliseconds(300)) + + let cooldown = active.observedNoAgent(now: now.addingTimeInterval(120)) + #expect(cooldown.nextInterval(now: now.addingTimeInterval(149.9)) == .seconds(2)) + #expect(cooldown.nextInterval(now: now.addingTimeInterval(150.1)) == nil) + } +} diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index f3e13560..fc55813d 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -45,9 +45,6 @@ struct AppFeatureArchivedSelectionTests { $0.repositories.worktreeHistoryBackStack = [worktree.id] $0.repositories.selection = .archivedWorktrees } - // Leaving the worktree view can flip Shelf visibility, so AppFeature - // resyncs agent detection alongside the selection change. - await store.receive(\.syncAgentDetectionEnabled) await store.receive(\.repositories.delegate.selectedWorktreeChanged) await store.finish() #expect(saved.value.isEmpty) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 86fb0de6..34badd4d 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -102,74 +102,6 @@ struct AppFeatureSettingsChangedTests { } } - @Test(.dependencies) func syncAgentDetectionKeepsDetectionEnabledWhenShelfVisible() async { - let worktree = makeWorktree() - let repository = makeRepository(worktrees: [worktree]) - let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) - var settings = SettingsFeature.State() - settings.autoShowActiveAgentsPanel = false - var state = AppFeature.State(settings: settings) - state.repositories.repositories = [repository] - state.repositories.isShelfActive = true - state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sentTerminalCommands.withValue { $0.append(command) } - } - } - - await store.send(.syncAgentDetectionEnabled) - await store.finish() - - #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(true)]) - } - - @Test(.dependencies) func syncAgentDetectionDisablesShelfOnlyDetectionWhenShelfStatusOptedOut() async { - let worktree = makeWorktree() - let repository = makeRepository(worktrees: [worktree]) - let sentTerminalCommands = LockIsolated<[TerminalClient.Command]>([]) - var settings = SettingsFeature.State() - settings.autoShowActiveAgentsPanel = false - settings.showActiveAgentStatusInShelf = false - var state = AppFeature.State(settings: settings) - state.repositories.repositories = [repository] - state.repositories.isShelfActive = true - state.repositories.activeAgents.$isPanelHidden.withLock { $0 = true } - let store = TestStore(initialState: state) { - AppFeature() - } withDependencies: { - $0.terminalClient.send = { command in - sentTerminalCommands.withValue { $0.append(command) } - } - } - - await store.send(.syncAgentDetectionEnabled) - await store.finish() - - #expect(sentTerminalCommands.value == [.setAgentDetectionEnabled(false)]) - } - - @Test func repositoryListChangesTriggerShelfVisibilityResync() { - // `isShowingShelf` requires a non-empty repository list, so adding or - // removing repositories must resync detection even though `isShelfActive` - // is untouched. - #expect( - AppFeature.repositoriesActionMayChangeShelfVisibility( - .repositoryManagement(.repositoryRemoved("/tmp/repo", selectionWasRemoved: false)) - ) - ) - #expect( - AppFeature.repositoriesActionMayChangeShelfVisibility( - .repositoriesLoaded([], failures: [], roots: [], animated: false) - ) - ) - #expect( - !AppFeature.repositoriesActionMayChangeShelfVisibility(.selectNextShelfBook) - ) - } - @Test func appStateInitializesActiveAgentTabTitleDisplayFromSettings() { var settings = SettingsFeature.State() settings.showActiveAgentTabTitles = true diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index c481789a..4226cd0a 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -89,6 +89,50 @@ struct WorktreeTerminalManagerTests { #expect(state.canCloseFocusedSurface == false) } + @Test func newEmptyTabStartsColdAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + #expect(state.agentDetectionSchedules[surfaceId] == nil) + #expect(state.agentDetectionTasks[surfaceId] == nil) + } + + @Test func wakingSurfaceStartsWarmAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + state.wakeAgentDetection(forSurfaceID: surfaceId) + + let schedule = try #require(state.agentDetectionSchedules[surfaceId]) + #expect(schedule.nextInterval(now: Date()) != nil) + #expect(state.agentDetectionTasks[surfaceId] != nil) + + state.cleanupAllAgentDetectionState() + } + + @Test func initialInputStartsWarmAgentDetection() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab(initialInput: "codex\n")) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + let schedule = try #require(state.agentDetectionSchedules[surfaceId]) + #expect(schedule.nextInterval(now: Date()) != nil) + #expect(state.agentDetectionTasks[surfaceId] != nil) + + state.cleanupAllAgentDetectionState() + } + @Test func firstTabUsesTabSurfaceContext() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() -- 2.51.2 From de8cfe388f5186e03cca0ba60952f21d2b0c43e7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 22:11:56 +0900 Subject: [PATCH 418/528] Add prowl agents CLI command --- ProwlCLI/Commands/AgentsCommand.swift | 23 ++ ProwlCLI/Commands/ProwlCommand.swift | 1 + ProwlCLI/Output/OutputRenderer.swift | 49 ++++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 176 ++++++++++++++ docs/components/active-agents.md | 11 +- docs/components/cli.md | 39 ++- supacode/App/supacodeApp.swift | 10 + .../CLIService/AgentsCommandHandler.swift | 151 ++++++++++++ supacode/CLIService/CLICommandRouter.swift | 4 + .../Shared/AgentsCommandPayload.swift | 132 ++++++++++ .../CLIService/Shared/CommandEnvelope.swift | 2 + supacode/CLIService/Shared/ErrorCodes.swift | 3 + supacode/CLIService/Shared/InputModels.swift | 4 + .../CLIAgentsCommandHandlerTests.swift | 227 ++++++++++++++++++ supacodeTests/CLICommandEnvelopeTests.swift | 17 ++ supacodeTests/CLICommandRouterTests.swift | 13 + supacodeTests/SupacodeAppCLITests.swift | 7 +- 17 files changed, 862 insertions(+), 7 deletions(-) create mode 100644 ProwlCLI/Commands/AgentsCommand.swift create mode 100644 supacode/CLIService/AgentsCommandHandler.swift create mode 100644 supacode/CLIService/Shared/AgentsCommandPayload.swift create mode 100644 supacodeTests/CLIAgentsCommandHandlerTests.swift diff --git a/ProwlCLI/Commands/AgentsCommand.swift b/ProwlCLI/Commands/AgentsCommand.swift new file mode 100644 index 00000000..5ccfa532 --- /dev/null +++ b/ProwlCLI/Commands/AgentsCommand.swift @@ -0,0 +1,23 @@ +// ProwlCLI/Commands/AgentsCommand.swift + +import ArgumentParser +import ProwlCLIShared + +struct AgentsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "agents", + abstract: "List detected agent panes." + ) + + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + try CLIExecution.run(command: "agents", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .agents(AgentsInput()) + ) + try CLIRunner.execute(envelope) + } + } +} diff --git a/ProwlCLI/Commands/ProwlCommand.swift b/ProwlCLI/Commands/ProwlCommand.swift index 3b598ba6..b6681ab9 100644 --- a/ProwlCLI/Commands/ProwlCommand.swift +++ b/ProwlCLI/Commands/ProwlCommand.swift @@ -13,6 +13,7 @@ struct ProwlCommand: ParsableCommand { subcommands: [ OpenCommand.self, ListCommand.self, + AgentsCommand.self, FocusCommand.self, SendCommand.self, KeyCommand.self, diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index bdc7d9cd..686c5ee2 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -57,6 +57,14 @@ enum OutputRenderer { return } + if response.command == "agents", + let data = response.data, + let payload = try? data.decode(as: AgentsCommandPayload.self) + { + print(renderAgents(payload)) + return + } + if response.command == "focus", let data = response.data, let payload = try? data.decode(as: FocusCommandPayload.self) @@ -195,6 +203,47 @@ enum OutputRenderer { return lines.joined(separator: "\n") } + private static func renderAgents(_ payload: AgentsCommandPayload) -> String { + guard !payload.agents.isEmpty else { + return "No agents found." + } + + let order: [AgentsCommandStatus: Int] = [ + .blocked: 0, + .working: 1, + .done: 2, + .idle: 3, + ] + let indexedAgents = payload.agents.enumerated() + let sortedAgents = indexedAgents.sorted { left, right in + let leftRank = order[left.element.status] ?? Int.max + let rightRank = order[right.element.status] ?? Int.max + if leftRank != rightRank { + return leftRank < rightRank + } + return left.offset < right.offset + }.map(\.element) + + return sortedAgents.map { agent in + let statusLabel = agentStatusLabel(agent.status) + let projectLabel = "\(agent.project.name):\(agent.project.branch)" + return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(agent.pane.id)" + }.joined(separator: "\n") + } + + private static func agentStatusLabel(_ status: AgentsCommandStatus) -> String { + switch status { + case .blocked: + return "Blocked".red.bold + case .working: + return "Working".green + case .done: + return "Done".dim + case .idle: + return "Idle".dim + } + } + private static func renderTab(_ payload: TabCommandPayload) -> String { let wt = payload.target.worktree let tab = payload.target.tab diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 095c8726..1e6a13f3 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -38,6 +38,34 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(error["code"] as? String, CLIErrorCode.appNotRunning) } + func testAgentsCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "agents") + let response = try CommandResponse( + ok: true, + command: "agents", + schemaVersion: "prowl.cli.agents.v1", + data: RawJSON(encoding: AgentsResponseData(count: 0, agents: [])) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["agents", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .agents = envelope.command { + // expected + } else { + XCTFail("Expected agents command envelope") + } + + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, true) + XCTAssertEqual(payload["command"] as? String, "agents") + } + func testOpenCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "open") let response = try CommandResponse( @@ -383,6 +411,80 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(result.stdout.contains("No panes found."), "Expected empty message: \(result.stdout)") } + func testAgentsCommandTextRenderingFromSocket() throws { + let socketPath = temporarySocketPath(suffix: "agents-text") + let response = try CommandResponse( + ok: true, + command: "agents", + schemaVersion: "prowl.cli.agents.v1", + data: RawJSON(encoding: AgentsResponseData( + count: 3, + agents: [ + makeAgentResponse( + id: "done-pane", + name: "codex", + status: "done", + projectName: "Prowl", + branch: "main", + tabTitle: "Done tab" + ), + makeAgentResponse( + id: "blocked-pane", + name: "omp", + status: "blocked", + projectName: "Prowl", + branch: "feature/cli-agents", + tabTitle: "issue 330" + ), + makeAgentResponse( + id: "working-pane", + name: "claude", + status: "working", + projectName: "Notes", + branch: "main", + tabTitle: "review" + ), + ] + )) + ) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["agents"] + ) + + XCTAssertEqual(result.exitCode, 0) + let lines = result.stdout.split(separator: "\n").map(String.init) + XCTAssertEqual(lines.count, 3, "Unexpected agents output: \(result.stdout)") + XCTAssertTrue(lines[0].contains("Blocked"), "Expected blocked first: \(result.stdout)") + XCTAssertTrue(lines[0].contains("omp"), "Missing agent name: \(result.stdout)") + XCTAssertTrue(lines[0].contains("Prowl:feature/cli-agents"), "Missing project label: \(result.stdout)") + XCTAssertTrue(lines[0].contains("issue 330"), "Missing tab title: \(result.stdout)") + XCTAssertTrue(lines[0].contains("blocked-pane"), "Missing pane id: \(result.stdout)") + XCTAssertTrue(lines[1].contains("Working"), "Expected working second: \(result.stdout)") + XCTAssertTrue(lines[2].contains("Done"), "Expected done third: \(result.stdout)") + } + + func testAgentsEmptyPayloadShowsNoAgentsFound() throws { + let socketPath = temporarySocketPath(suffix: "agents-empty") + let response = try CommandResponse( + ok: true, + command: "agents", + schemaVersion: "prowl.cli.agents.v1", + data: RawJSON(encoding: AgentsResponseData(count: 0, agents: [])) + ) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["agents"] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertTrue(result.stdout.contains("No agents found."), "Expected empty message: \(result.stdout)") + } + func testListMultipleWorktreesGroupedWithBlankLine() throws { let socketPath = temporarySocketPath(suffix: "list-multi-wt") let response = try CommandResponse( @@ -1349,6 +1451,34 @@ final class ProwlCLIIntegrationTests: XCTestCase { PaneCommandPayload(action: action, target: makeTabTarget()) } + private func makeAgentResponse( + id: String, + name: String, + status: String, + projectName: String, + branch: String, + tabTitle: String + ) -> AgentsResponseAgent { + AgentsResponseAgent( + id: id, + type: name, + name: name, + status: status, + rawState: status, + lastChangedAt: "2026-06-13T04:12:25Z", + project: AgentsResponseProject(name: projectName, branch: branch, path: "/Projects/\(projectName)"), + worktree: ListWorktree( + id: "\(projectName):/Projects/\(projectName)", + name: branch, + path: "/Projects/\(projectName)", + rootPath: "/Projects/\(projectName)", + kind: "git" + ), + tab: ListTab(id: "\(id)-tab", title: tabTitle, selected: true), + pane: AgentsResponsePane(id: id, index: 1, title: name, cwd: "/Projects/\(projectName)", focused: false) + ) + } + private func makeTabTarget() -> TabTarget { TabTarget( worktree: TabTargetWorktree( @@ -1518,6 +1648,52 @@ private struct ListTask: Encodable { let status: String? } +private struct AgentsResponseData: Encodable { + let count: Int + let agents: [AgentsResponseAgent] +} + +private struct AgentsResponseAgent: Encodable { + let id: String + let type: String + let name: String + let status: String + + enum CodingKeys: String, CodingKey { + case id + case type + case name + case status + case rawState = "raw_state" + case lastChangedAt = "last_changed_at" + case project + case worktree + case tab + case pane + } + + let rawState: String + let lastChangedAt: String + let project: AgentsResponseProject + let worktree: ListWorktree + let tab: ListTab + let pane: AgentsResponsePane +} + +private struct AgentsResponseProject: Encodable { + let name: String + let branch: String + let path: String +} + +private struct AgentsResponsePane: Encodable { + let id: String + let index: Int + let title: String + let cwd: String? + let focused: Bool +} + private struct FocusResponseData: Encodable { let requested: FocusRequested diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index df32cff6..e3b52b0c 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -5,7 +5,7 @@ **Keywords:** active agents, agents panel, running agents, status list, working, blocked, done, idle, jump to agent, roster -**Related:** [agent-detection](agent-detection.md) · [notifications](notifications.md) · [command-palette](command-palette.md) · [canvas](canvas.md) +**Related:** [agent-detection](agent-detection.md) · [cli](cli.md) · [notifications](notifications.md) · [command-palette](command-palette.md) · [canvas](canvas.md) ## What it is @@ -66,6 +66,10 @@ When nothing is running: "New agents will appear here". ## Relationship to other features - **Agent detection** ([agent-detection](agent-detection.md)) feeds this panel. +- **CLI** ([cli](cli.md)) exposes the same roster through `prowl agents` and + `prowl agents --json`. The command is read-only; use the returned + `pane.id` with `prowl focus --pane`, `prowl read --pane`, or + `prowl send --pane` for follow-up actions. - **Notifications** ([notifications](notifications.md)) are driven by a separate signal — terminal bell / OSC desktop notifications and command-finished events — which usually coincides with, but is not the same as, a detected finish. @@ -81,5 +85,6 @@ When nothing is running: "New agents will appear here". - "Blocked" is the actionable state — it means an agent is **waiting on a human** (a permission/confirmation prompt). Surface these first. - This panel reflects **detected** agents; detection is best-effort (see - [agent-detection](agent-detection.md)). For programmatic certainty about a - pane's task status, use [`prowl list`](cli.md) → `task.status`. + [agent-detection](agent-detection.md)). For the same detected roster in + automation, use [`prowl agents --json`](cli.md). For an all-pane inventory, + including non-agent shells, use [`prowl list --json`](cli.md). diff --git a/docs/components/cli.md b/docs/components/cli.md index fed71120..863fbe9f 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -4,9 +4,9 @@ > an agent) can list panes, read their screens, run commands and capture output, > send keystrokes, focus, and open/close tabs and panes programmatically. -**Keywords:** prowl cli, command line, prowl list, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, pane id, automation, json, capture, socket +**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, pane id, automation, json, capture, socket -**Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) +**Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) > This is the reference for the `prowl` binary. For an opinionated, safety-first > *workflow* guide (recipes, pitfalls, quoting), the repository also ships the @@ -80,6 +80,37 @@ Find your own pane (to avoid operating on yourself): self_pane="$(prowl list --json | jq -r '.data.items[] | select(.pane.focused==true) | .pane.id')" ``` +### `prowl agents` +Snapshot of detected agent panes, matching the Active Agents roster. No +selectors. + +```bash +prowl agents --json +``` +Each agent contains: +- `id`: the pane/surface UUID, suitable for `--pane`. +- `type`, `name`: normalized detector type and displayed command name. Aliases + such as `omp` are preserved in `name`. +- `status`, `raw_state`: detected agent state. `status` is one of `blocked`, + `working`, `done`, `idle`; `raw_state` is the lower-level detector state. +- `last_changed_at`: ISO-8601 timestamp for the most recent state change. +- `project`: display-oriented `name`, `branch`, `path` resolved from the + agent's working directory. +- `worktree`, `tab`, `pane`: the actual terminal owner and pane metadata for + automation. + +`prowl agents` is read-only. To jump to or operate on an agent, resolve +`.data.agents[].pane.id`, then use existing commands: + +```bash +pane="$(prowl agents --json | jq -r '.data.agents[] | select(.status=="blocked") | .pane.id' | head -n1)" +prowl focus --pane "$pane" +prowl read --pane "$pane" --last 120 --wait-stable +``` + +Text output is sorted for triage: `Blocked`, `Working`, `Done`, then `Idle`. +Empty output prints `No agents found.`. + ### `prowl read [target]` Read a pane's content. @@ -203,7 +234,7 @@ inside-root / new-root), `app_launched`, `brought_to_front`, `created_tab`, and | `UNSUPPORTED_KEY` / `INVALID_REPEAT` | Check `prowl key --help`. | | `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`tab create` path. | | `LAUNCH_FAILED` | App launch or socket wait failed. | -| `*_FAILED` (`LIST_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`) | The action itself failed. | +| `*_FAILED` (`LIST_FAILED`, `AGENTS_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`) | The action itself failed. | ## Safety & self-targeting @@ -228,6 +259,8 @@ prowl pane close --pane "$pane" --json - Resolve a `pane.id` before `read`/`send`/`key`/`focus`/close — never trust tab titles. +- Use `prowl agents --json` when you need agent status; use `prowl list --json` + when you need all panes, including ordinary shells. - `--capture` needs shell integration; otherwise `read --wait-stable` or file redirection. - `open` is navigation, not a guaranteed new pane — use `tab create`. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 6f50fbcb..e86ad408 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -368,6 +368,15 @@ struct SupacodeApp: App { terminalManager: terminalManager ) } + let agentsHandler = AgentsCommandHandler { + AgentsRuntimeSnapshot( + repositoriesState: appStore.state.repositories, + listSnapshot: ListRuntimeSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager + ) + ) + } let sendHandler = SendCommandHandler( resolveProvider: { selector in let resolver = TargetResolver { @@ -546,6 +555,7 @@ struct SupacodeApp: App { return CLICommandRouter( openHandler: openHandler, listHandler: listHandler, + agentsHandler: agentsHandler, focusHandler: focusHandler, sendHandler: sendHandler, keyHandler: keyHandler, diff --git a/supacode/CLIService/AgentsCommandHandler.swift b/supacode/CLIService/AgentsCommandHandler.swift new file mode 100644 index 00000000..1aa4136c --- /dev/null +++ b/supacode/CLIService/AgentsCommandHandler.swift @@ -0,0 +1,151 @@ +import Foundation + +struct AgentsRuntimeSnapshot { + let repositoriesState: RepositoriesFeature.State + let listSnapshot: ListRuntimeSnapshot +} + +final class AgentsCommandHandler: CommandHandler { + typealias SnapshotProvider = @MainActor () throws -> AgentsRuntimeSnapshot + + private struct TerminalAgentContext { + let worktree: ListRuntimeSnapshot.Worktree + let tab: ListRuntimeSnapshot.Tab + let pane: ListRuntimeSnapshot.Pane + let focused: Bool + } + + private let snapshotProvider: SnapshotProvider + private let dateFormatter: ISO8601DateFormatter + + init(snapshotProvider: @escaping SnapshotProvider) { + self.snapshotProvider = snapshotProvider + self.dateFormatter = ISO8601DateFormatter() + self.dateFormatter.formatOptions = [.withInternetDateTime] + self.dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + } + + // swiftlint:disable:next async_without_await + func handle(envelope _: CommandEnvelope) async -> CommandResponse { + do { + let snapshot = try snapshotProvider() + let payload = makePayload(from: snapshot) + return try CommandResponse( + ok: true, + command: "agents", + schemaVersion: "prowl.cli.agents.v1", + data: RawJSON(encoding: payload) + ) + } catch { + return CommandResponse( + ok: false, + command: "agents", + schemaVersion: "prowl.cli.agents.v1", + error: CommandError( + code: CLIErrorCode.agentsFailed, + message: "Failed to list agents." + ) + ) + } + } + + private func makePayload(from snapshot: AgentsRuntimeSnapshot) -> AgentsCommandPayload { + let repositoriesState = snapshot.repositoriesState + let metadata = SidebarListView.activeAgentWorktreeMetadata( + repositories: repositoriesState.repositories, + customTitles: repositoriesState.repositoryCustomTitles + ) + let terminalContexts = makeTerminalContexts(from: snapshot.listSnapshot) + let worktreeContexts = Dictionary( + uniqueKeysWithValues: + ListRuntimeSnapshotBuilder + .orderedWorktreeContexts(from: repositoriesState) + .map { ($0.id, $0) } + ) + + let agents = repositoriesState.activeAgents.entries.compactMap { entry -> AgentsCommandAgent? in + guard let terminalContext = terminalContexts[entry.surfaceID] else { + return nil + } + let display = SidebarListView.activeAgentRowDisplay( + for: entry, + repositories: repositoriesState.repositories, + metadata: metadata + ) + let projectPath = projectPath( + for: entry, repositoriesState: repositoriesState, worktreeContexts: worktreeContexts) + + return AgentsCommandAgent( + id: entry.surfaceID.uuidString, + type: entry.agent.rawValue, + name: entry.displayName, + status: AgentsCommandStatus(rawValue: entry.displayState.rawValue) ?? .idle, + rawState: entry.rawState.rawValue, + lastChangedAt: dateFormatter.string(from: entry.lastChangedAt), + project: AgentsCommandProject( + name: display.repositoryName, + branch: display.branchName, + path: projectPath + ), + worktree: AgentsCommandWorktree( + id: terminalContext.worktree.id, + name: terminalContext.worktree.name, + path: terminalContext.worktree.path, + rootPath: terminalContext.worktree.rootPath, + kind: terminalContext.worktree.kind.rawValue + ), + tab: AgentsCommandTab( + id: terminalContext.tab.id.uuidString, + title: terminalContext.tab.title, + selected: terminalContext.tab.selected + ), + pane: AgentsCommandPane( + id: terminalContext.pane.id.uuidString, + index: entry.paneIndex, + title: terminalContext.pane.title, + cwd: terminalContext.pane.cwd, + focused: terminalContext.focused + ) + ) + } + + return AgentsCommandPayload(count: agents.count, agents: agents) + } + + private func makeTerminalContexts(from snapshot: ListRuntimeSnapshot) -> [UUID: TerminalAgentContext] { + var contexts: [UUID: TerminalAgentContext] = [:] + for worktree in snapshot.worktrees { + for tab in worktree.tabs { + for pane in tab.panes { + contexts[pane.id] = TerminalAgentContext( + worktree: worktree, + tab: tab, + pane: pane, + focused: worktree.id == snapshot.focusedWorktreeID && tab.selected && tab.focusedPaneID == pane.id + ) + } + } + } + return contexts + } + + private func projectPath( + for entry: ActiveAgentEntry, + repositoriesState: RepositoriesFeature.State, + worktreeContexts: [Worktree.ID: ListRuntimeSnapshotBuilder.WorktreeContext] + ) -> String { + if let workingDirectory = entry.workingDirectory, + let key = SidebarListView.resolveWorktreeID( + forWorkingDirectory: workingDirectory, + in: repositoriesState.repositories + ), + let context = worktreeContexts[key] + { + return context.path + } + if let workingDirectory = entry.workingDirectory { + return workingDirectory.path(percentEncoded: false) + } + return worktreeContexts[entry.worktreeID]?.path ?? entry.worktreeName + } +} diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index b4183cb8..17723ba8 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -7,6 +7,7 @@ import Foundation final class CLICommandRouter { private let openHandler: any CommandHandler private let listHandler: any CommandHandler + private let agentsHandler: any CommandHandler private let focusHandler: any CommandHandler private let sendHandler: any CommandHandler private let keyHandler: any CommandHandler @@ -17,6 +18,7 @@ final class CLICommandRouter { init( openHandler: any CommandHandler = StubCommandHandler(command: "open"), listHandler: any CommandHandler = StubCommandHandler(command: "list"), + agentsHandler: any CommandHandler = StubCommandHandler(command: "agents"), focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), sendHandler: any CommandHandler = StubCommandHandler(command: "send"), keyHandler: any CommandHandler = StubCommandHandler(command: "key"), @@ -26,6 +28,7 @@ final class CLICommandRouter { ) { self.openHandler = openHandler self.listHandler = listHandler + self.agentsHandler = agentsHandler self.focusHandler = focusHandler self.sendHandler = sendHandler self.keyHandler = keyHandler @@ -39,6 +42,7 @@ final class CLICommandRouter { switch envelope.command { case .open: handler = openHandler case .list: handler = listHandler + case .agents: handler = agentsHandler case .focus: handler = focusHandler case .send: handler = sendHandler case .key: handler = keyHandler diff --git a/supacode/CLIService/Shared/AgentsCommandPayload.swift b/supacode/CLIService/Shared/AgentsCommandPayload.swift new file mode 100644 index 00000000..4f94acbd --- /dev/null +++ b/supacode/CLIService/Shared/AgentsCommandPayload.swift @@ -0,0 +1,132 @@ +import Foundation + +public struct AgentsCommandPayload: Codable, Equatable { + public let count: Int + public let agents: [AgentsCommandAgent] + + public init(count: Int, agents: [AgentsCommandAgent]) { + self.count = count + self.agents = agents + } +} + +public struct AgentsCommandAgent: Codable, Equatable { + public let id: String + public let type: String + public let name: String + public let status: AgentsCommandStatus + public let rawState: String + public let lastChangedAt: String + public let project: AgentsCommandProject + public let worktree: AgentsCommandWorktree + public let tab: AgentsCommandTab + public let pane: AgentsCommandPane + + enum CodingKeys: String, CodingKey { + case id + case type + case name + case status + case rawState = "raw_state" + case lastChangedAt = "last_changed_at" + case project + case worktree + case tab + case pane + } + + public init( + id: String, + type: String, + name: String, + status: AgentsCommandStatus, + rawState: String, + lastChangedAt: String, + project: AgentsCommandProject, + worktree: AgentsCommandWorktree, + tab: AgentsCommandTab, + pane: AgentsCommandPane + ) { + self.id = id + self.type = type + self.name = name + self.status = status + self.rawState = rawState + self.lastChangedAt = lastChangedAt + self.project = project + self.worktree = worktree + self.tab = tab + self.pane = pane + } +} + +public enum AgentsCommandStatus: String, Codable, Equatable { + case blocked + case working + case done + case idle +} + +public struct AgentsCommandProject: Codable, Equatable { + public let name: String + public let branch: String + public let path: String + + public init(name: String, branch: String, path: String) { + self.name = name + self.branch = branch + self.path = path + } +} + +public struct AgentsCommandWorktree: Codable, Equatable { + public let id: String + public let name: String + public let path: String + public let rootPath: String + public let kind: String + + enum CodingKeys: String, CodingKey { + case id + case name + case path + case rootPath = "root_path" + case kind + } + + public init(id: String, name: String, path: String, rootPath: String, kind: String) { + self.id = id + self.name = name + self.path = path + self.rootPath = rootPath + self.kind = kind + } +} + +public struct AgentsCommandTab: Codable, Equatable { + public let id: String + public let title: String + public let selected: Bool + + public init(id: String, title: String, selected: Bool) { + self.id = id + self.title = title + self.selected = selected + } +} + +public struct AgentsCommandPane: Codable, Equatable { + public let id: String + public let index: Int + public let title: String + public let cwd: String? + public let focused: Bool + + public init(id: String, index: Int, title: String, cwd: String?, focused: Bool) { + self.id = id + self.index = index + self.title = title + self.cwd = cwd + self.focused = focused + } +} diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index bd75de6c..874ac655 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -16,6 +16,7 @@ public struct CommandEnvelope: Codable, Sendable { public enum Command: Codable, Sendable { case open(OpenInput) case list(ListInput) + case agents(AgentsInput) case focus(FocusInput) case send(SendInput) case key(KeyInput) @@ -27,6 +28,7 @@ public enum Command: Codable, Sendable { switch self { case .open: "open" case .list: "list" + case .agents: "agents" case .focus: "focus" case .send: "send" case .key: "key" diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index 8c588b75..577563e3 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -20,6 +20,9 @@ public enum CLIErrorCode { // List public static let listFailed = "LIST_FAILED" + // Agents + public static let agentsFailed = "AGENTS_FAILED" + // Focus public static let focusFailed = "FOCUS_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index c67926ca..cfc972af 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -26,6 +26,10 @@ public struct ListInput: Codable, Sendable { public init() {} } +public struct AgentsInput: Codable, Sendable { + public init() {} +} + public struct FocusInput: Codable, Sendable { public let selector: TargetSelector diff --git a/supacodeTests/CLIAgentsCommandHandlerTests.swift b/supacodeTests/CLIAgentsCommandHandlerTests.swift new file mode 100644 index 00000000..89a78aa8 --- /dev/null +++ b/supacodeTests/CLIAgentsCommandHandlerTests.swift @@ -0,0 +1,227 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct CLIAgentsCommandHandlerTests { + + @Test func buildsAgentsPayloadFromActiveEntriesAndTerminalSnapshot() async throws { + let fixture = makePayloadFixture() + let handler = AgentsCommandHandler { + fixture.snapshot + } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agents(AgentsInput())) + ) + + #expect(response.ok) + #expect(response.command == "agents") + #expect(response.schemaVersion == "prowl.cli.agents.v1") + + let payload = try #require(try response.data?.decode(as: AgentsCommandPayload.self)) + #expect(payload.count == 2) + #expect(payload.agents.map(\.id) == [fixture.tabPaneID.uuidString, fixture.otherPaneID.uuidString]) + + let agent = payload.agents[0] + #expect(agent.type == "pi") + #expect(agent.name == "omp") + #expect(agent.status == .blocked) + #expect(agent.rawState == "blocked") + #expect(agent.lastChangedAt == "2026-09-21T14:00:00Z") + #expect(agent.project.name == "Prowl") + #expect(agent.project.branch == "feature/agents") + #expect(agent.project.path == "/tmp/project-repo") + #expect(agent.worktree.id == fixture.tabWorktree.id) + #expect(agent.worktree.name == "main") + #expect(agent.worktree.path == "/tmp/tab-repo") + #expect(agent.tab.id == fixture.tabID.uuidString) + #expect(agent.tab.title == "issue 330") + #expect(agent.tab.selected) + #expect(agent.pane.id == fixture.tabPaneID.uuidString) + #expect(agent.pane.index == 2) + #expect(agent.pane.title == "omp") + #expect(agent.pane.cwd == "/tmp/project-repo/Sources") + #expect(agent.pane.focused) + + let idleAgent = payload.agents[1] + #expect(idleAgent.status == .idle) + #expect(idleAgent.project.name == "Tab Repo") + #expect(idleAgent.project.branch == "main") + #expect(idleAgent.pane.focused == false) + } + + @Test func returnsAgentsFailedWhenSnapshotProviderThrows() async { + struct DummyError: Error {} + + let handler = AgentsCommandHandler { + throw DummyError() + } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agents(AgentsInput())) + ) + + #expect(response.ok == false) + #expect(response.command == "agents") + #expect(response.schemaVersion == "prowl.cli.agents.v1") + #expect(response.error?.code == CLIErrorCode.agentsFailed) + } + + private func makeWorktree(repoRoot: String, path: String, branch: String) -> Worktree { + Worktree( + id: path, + name: branch, + detail: branch, + workingDirectory: URL(fileURLWithPath: path), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository( + id: String, + name: String, + kind: Repository.Kind = .git, + worktrees: IdentifiedArrayOf + ) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: name, + kind: kind, + worktrees: worktrees + ) + } + + private func makePayloadFixture() -> PayloadFixture { + let tabPaneID = UUID(uuidString: "6E1A2A10-D99F-4E3F-920C-D93AA3C05764")! + let otherPaneID = UUID(uuidString: "EF65FF31-1B72-40B2-80DA-3AA87B7B6858")! + let tabID = UUID(uuidString: "2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0")! + let tabWorktree = makeWorktree(repoRoot: "/tmp/tab-repo", path: "/tmp/tab-repo", branch: "main") + let projectWorktree = makeWorktree( + repoRoot: "/tmp/project-repo", path: "/tmp/project-repo", branch: "feature/agents") + let tabRepo = makeRepository(id: "/tmp/tab-repo", name: "Tab Repo", worktrees: [tabWorktree]) + let projectRepo = makeRepository(id: "/tmp/project-repo", name: "Project Repo", worktrees: [projectWorktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [tabRepo, projectRepo] + repositoriesState.repositoryCustomTitles = [projectRepo.id: "Prowl"] + repositoriesState.activeAgents.entries = [ + makeAgentEntry( + AgentEntryInput( + paneID: tabPaneID, + tabID: tabID, + worktree: tabWorktree, + workingDirectory: URL(fileURLWithPath: "/tmp/project-repo/Sources"), + paneIndex: 2, + iconLookupToken: "omp", + agent: .pi, + rawState: .blocked, + displayState: .blocked, + lastChangedAt: Date(timeIntervalSince1970: 1_789_999_200) + )), + makeAgentEntry( + AgentEntryInput( + paneID: otherPaneID, + tabID: tabID, + worktree: tabWorktree, + workingDirectory: nil, + paneIndex: 1, + iconLookupToken: DetectedAgent.codex.iconLookupToken, + agent: .codex, + rawState: .idle, + displayState: .idle, + lastChangedAt: Date(timeIntervalSince1970: 1_789_999_260) + )), + ] + + return PayloadFixture( + tabPaneID: tabPaneID, + otherPaneID: otherPaneID, + tabID: tabID, + tabWorktree: tabWorktree, + snapshot: AgentsRuntimeSnapshot( + repositoriesState: repositoriesState, + listSnapshot: makeListSnapshot( + tabPaneID: tabPaneID, + otherPaneID: otherPaneID, + tabID: tabID, + tabWorktree: tabWorktree + ) + ) + ) + } + + private func makeAgentEntry(_ input: AgentEntryInput) -> ActiveAgentEntry { + ActiveAgentEntry( + id: input.paneID, + worktreeID: input.worktree.id, + worktreeName: input.worktree.name, + workingDirectory: input.workingDirectory, + tabID: TerminalTabID(rawValue: input.tabID), + tabTitle: "issue 330", + surfaceID: input.paneID, + paneIndex: input.paneIndex, + iconLookupToken: input.iconLookupToken, + agent: input.agent, + rawState: input.rawState, + displayState: input.displayState, + lastChangedAt: input.lastChangedAt + ) + } + + private func makeListSnapshot( + tabPaneID: UUID, + otherPaneID: UUID, + tabID: UUID, + tabWorktree: Worktree + ) -> ListRuntimeSnapshot { + ListRuntimeSnapshot( + worktrees: [ + .init( + id: tabWorktree.id, + name: tabWorktree.name, + path: tabWorktree.workingDirectory.path(percentEncoded: false), + rootPath: tabWorktree.repositoryRootURL.path(percentEncoded: false), + kind: .git, + taskStatus: .running, + tabs: [ + .init( + id: tabID, + title: "issue 330", + selected: true, + focusedPaneID: tabPaneID, + panes: [ + .init(id: otherPaneID, title: "zsh", cwd: "/tmp/tab-repo"), + .init(id: tabPaneID, title: "omp", cwd: "/tmp/project-repo/Sources"), + ] + ) + ] + ) + ], + focusedWorktreeID: tabWorktree.id + ) + } + + private struct PayloadFixture { + let tabPaneID: UUID + let otherPaneID: UUID + let tabID: UUID + let tabWorktree: Worktree + let snapshot: AgentsRuntimeSnapshot + } + + private struct AgentEntryInput { + let paneID: UUID + let tabID: UUID + let worktree: Worktree + let workingDirectory: URL? + let paneIndex: Int + let iconLookupToken: String + let agent: DetectedAgent + let rawState: AgentRawState + let displayState: AgentDisplayState + let lastChangedAt: Date + } +} diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index aa6ca09d..65db6dfb 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -56,6 +56,21 @@ struct CLICommandEnvelopeTests { } } + @Test func envelopeAgentsRoundTrips() throws { + let envelope = CommandEnvelope( + output: .json, + command: .agents(AgentsInput()) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + #expect(decoded.output == .json) + if case .agents = decoded.command { + // expected + } else { + Issue.record("Expected .agents command") + } + } + @Test func envelopeSendWithSelectorRoundTrips() throws { let envelope = CommandEnvelope( output: .json, @@ -231,6 +246,7 @@ struct CLICommandEnvelopeTests { let commands: [(Command, String)] = [ (.open(OpenInput(path: nil)), "open"), (.list(ListInput()), "list"), + (.agents(AgentsInput()), "agents"), (.focus(FocusInput()), "focus"), (.send(SendInput(text: "x")), "send"), (.key(KeyInput(rawToken: "tab", token: "tab")), "key"), @@ -249,6 +265,7 @@ struct CLICommandEnvelopeTests { let commands: [Command] = [ .open(OpenInput(path: "/tmp")), .list(ListInput()), + .agents(AgentsInput()), .focus(FocusInput()), .send(SendInput(text: "test")), .key(KeyInput(rawToken: "enter", token: "enter")), diff --git a/supacodeTests/CLICommandRouterTests.swift b/supacodeTests/CLICommandRouterTests.swift index a22c8683..4cc7e9cd 100644 --- a/supacodeTests/CLICommandRouterTests.swift +++ b/supacodeTests/CLICommandRouterTests.swift @@ -55,6 +55,18 @@ struct CLICommandRouterTests { #expect(response.command == "focus") } + @MainActor + @Test func routerDispatchesAgentsToAgentsHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .agents(AgentsInput()) + ) + let response = await router.route(envelope) + #expect(response.command == "agents") + #expect(response.error?.code == "NOT_IMPLEMENTED") + } + @MainActor @Test func routerDispatchesKeyToKeyHandler() async { let router = CLICommandRouter() @@ -124,6 +136,7 @@ struct CLICommandRouterTests { let commands: [Command] = [ .open(OpenInput()), .list(ListInput()), + .agents(AgentsInput()), .focus(FocusInput()), .send(SendInput(text: "x")), .key(KeyInput(rawToken: "tab", token: "tab")), diff --git a/supacodeTests/SupacodeAppCLITests.swift b/supacodeTests/SupacodeAppCLITests.swift index 6ac01b41..01e69643 100644 --- a/supacodeTests/SupacodeAppCLITests.swift +++ b/supacodeTests/SupacodeAppCLITests.swift @@ -7,13 +7,16 @@ import Testing @MainActor struct SupacodeAppCLITests { - @Test func cliRouterWiresKeyAndReadHandlersInsteadOfStubHandlers() async { + @Test func cliRouterWiresAgentsKeyAndReadHandlersInsteadOfStubHandlers() async { let store = Store(initialState: AppFeature.State()) { AppFeature() } let terminalManager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let router = SupacodeApp.makeCLICommandRouter(appStore: store, terminalManager: terminalManager) + let agentsResponse = await router.route( + CommandEnvelope(output: .json, command: .agents(AgentsInput())) + ) let keyResponse = await router.route( CommandEnvelope(output: .json, command: .key(KeyInput(rawToken: "enter", token: "enter"))) ) @@ -21,8 +24,10 @@ struct SupacodeAppCLITests { CommandEnvelope(output: .json, command: .read(ReadInput())) ) + #expect(agentsResponse.command == "agents") #expect(keyResponse.command == "key") #expect(readResponse.command == "read") + #expect(agentsResponse.error?.code != "NOT_IMPLEMENTED") #expect(keyResponse.error?.code != "NOT_IMPLEMENTED") #expect(readResponse.error?.code != "NOT_IMPLEMENTED") } -- 2.51.2 From a497a286db811664f9e17d101e9caf4a7aaa15e1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 22:19:58 +0900 Subject: [PATCH 419/528] Document prowl agents skill workflow --- skills/prowl-cli/SKILL.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 47d20918..12ab91c0 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -18,6 +18,14 @@ prowl list --json Pick the target by `pane.id`, `tab.id`, `worktree.id`, `worktree.path`, `pane.cwd`, and `pane.focused`. Do not trust tab titles: they are free-form and can lag or lie. +When you specifically need active agent status, prefer the agent roster: + +```bash +prowl agents --json +``` + +`prowl agents` lists detected agent panes only. It is the right starting point for "which agents are blocked/working/done?", while `prowl list` remains the all-pane inventory, including ordinary shells. + If your session was launched from a Prowl pane, the focused pane is often you. Treat focused pane IDs as something to identify and avoid unless you intentionally want to operate on yourself. ```bash @@ -150,6 +158,25 @@ Human scan: prowl list --no-color ``` +Find active agents, prioritizing prompts that need attention: + +```bash +prowl agents --no-color +``` + +Get the first blocked agent pane and inspect it: + +```bash +pane="$(prowl agents --json | jq -r ' + .data.agents[] + | select(.status == "blocked") + | .pane.id +' | head -n 1)" +prowl read --pane "$pane" --last 120 --wait-stable --json +``` + +When no agent is blocked, use the same pattern with `working`, `done`, or `idle` depending on the task. The JSON payload also includes `.project.name`, `.project.branch`, `.worktree.path`, `.tab.title`, and `.pane.focused`, so automation can filter by human project label while still targeting the concrete pane. + `-t/--target` can auto-resolve pane UUID, tab UUID, or worktree id/name/path, but explicit `--pane ` is safer for automation. ## Argument Rules @@ -184,6 +211,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - Never target by tab title alone; use `pane.id` plus path/cwd. - Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. +- Use `prowl agents --json` for detected agent status; use `prowl list --json` for all panes and worktree-level `task.status`. - `open /path` is a project/path navigation command. It may refocus an existing pane and is not a deterministic create command. - Use `tab create` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. - Focused pane is not stable; `open` and `focus` change it. @@ -193,6 +221,8 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - `prowl list --json | jq ...` snippets should pass shell values with `--arg`. - In zsh, do not name variables `status`; it is readonly. - Parser errors are not JSON even if `--json` is present, because parsing happens before command execution. +- The CLI talks to one socket owner by default. If two Prowl app instances are running, the default `prowl` command reaches whichever app owns the standard socket. For a manually launched dev instance, start the app and every CLI command with the same `PROWL_CLI_SOCKET=/tmp/name.sock`. +- A newer CLI command sent to an older app can fail at transport level. If `prowl agents` returns `TRANSPORT_FAILED`, confirm the running app instance was built with the command. - `cmd-w` can close a temporary tab, but double-check the pane first. ## Error Handling @@ -206,6 +236,7 @@ In `--json` mode, command-level failures look like: Common codes and recovery: - `APP_NOT_RUNNING`: Prowl is not reachable. Ask before restarting the app. +- `TRANSPORT_FAILED`: the socket connection broke or the running app could not decode the command. Recheck which Prowl instance owns the socket. - `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID. - `EMPTY_INPUT`: `send` got neither argv text nor stdin. - `NO_ACTIVE_PANE`: no pane resolved for positional (focused-pane) targeting; pass an explicit `--pane`. @@ -219,4 +250,4 @@ Always check the exit code before piping output into `jq`; parser-level errors p ## Command Set -Current commands: `list`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. +Current commands: `list`, `agents`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. -- 2.51.2 From 6968da20cca031c33a56ec223d534baee6c10191 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 22:42:14 +0900 Subject: [PATCH 420/528] Add Prowl self verification skill --- .claude/skills/self-verify-prowl/SKILL.md | 135 ++++++++++++++++++ .../self-verify-prowl/agents/openai.yaml | 4 + 2 files changed, 139 insertions(+) create mode 100644 .claude/skills/self-verify-prowl/SKILL.md create mode 100644 .claude/skills/self-verify-prowl/agents/openai.yaml diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md new file mode 100644 index 00000000..c2b2603e --- /dev/null +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -0,0 +1,135 @@ +--- +name: self-verify-prowl +description: Bootstrap-verify Prowl changes by launching a debug app with a dedicated PROWL_CLI_SOCKET and driving it from the current Prowl session. Use after implementing Prowl app, terminal, Active Agents, or CLI changes when Codex should validate behavior end-to-end in a separate Prowl instance, including opening worktrees, creating tabs, running commands or agent sessions, reading panes, and falling back to screenshots or macOS Accessibility inspection when the prowl CLI is insufficient. +--- + +# Self Verify Prowl + +## Overview + +Use this skill to validate a Prowl change from inside Prowl itself: start a freshly built debug app, point it at a temporary CLI socket, and use `prowl` from the current session to drive that separate app instance. + +Prefer `prowl` CLI operations because they are scriptable and leave useful evidence. When CLI coverage is not enough, use screenshots and macOS Accessibility inspection as secondary checks. + +## When To Use + +Use this after changes that affect: + +- Prowl app behavior that needs a real running GUI instance. +- Terminal tabs, panes, focus, worktrees, or command routing. +- Active Agents detection or roster presentation. +- `ProwlCLI/`, CLI payloads, socket transport, or CLI docs. +- Workflows where one Prowl-hosted agent should verify another Prowl instance. + +Do not use this as a replacement for unit tests, `make check`, `make build-app`, or CLI integration tests. It is an end-to-end manual validation layer on top of those checks. + +## Preconditions + +- Work from the Prowl repository root. +- Preserve unrelated user changes. Do not close or kill the user's normal Prowl app. +- If the CLI changed, build and use the repo CLI, usually `./.build/debug/prowl`. +- If the CLI did not change, an installed `prowl` may be usable, but the repo-built CLI keeps the app/CLI protocol aligned. +- For detailed CLI targeting and recipes, consult the dedicated `prowl-cli` skill first when needed. + +## Launch A Separate App + +Start the debug app with a dedicated socket so it does not fight the normal installed Prowl instance for the default socket: + +```bash +socket="/tmp/prowl-self-verify-$$.sock" +PROWL_CLI_SOCKET="$socket" make run-app +``` + +Keep that command running in its own shell session. If plain `make run-app` reports a socket ownership problem, relaunch with a custom `PROWL_CLI_SOCKET`; the installed app may already own the standard socket. + +When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. + +## Drive With Prowl CLI + +Use the new CLI when CLI behavior changed: + +```bash +make build-cli +cli="./.build/debug/prowl" +``` + +Then operate the debug app through the custom socket: + +```bash +PROWL_CLI_SOCKET="$socket" "$cli" open . +PROWL_CLI_SOCKET="$socket" "$cli" list --json +PROWL_CLI_SOCKET="$socket" "$cli" tab create --title "Self Verify" --json +PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json +PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable --json +``` + +Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. + +## Verify Active Agents + +For Active Agents behavior, create a fresh tab or pane, start a small agent task, then query the roster: + +```bash +PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" \ + 'codex --sandbox read-only "Reply exactly SELF_VERIFY_OK. Do not modify files."' \ + --no-wait --json + +PROWL_CLI_SOCKET="$socket" "$cli" agents --json +PROWL_CLI_SOCKET="$socket" "$cli" agents --no-color +``` + +Use the returned `data.agents[].pane.id` to inspect or control the agent's pane: + +```bash +agent_pane="$(PROWL_CLI_SOCKET="$socket" "$cli" agents --json | jq -r '.data.agents[0].pane.id')" +PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$agent_pane" --last 120 --wait-stable --json +``` + +Short non-interactive agent tasks can finish before the roster is sampled. If the goal is to observe `working` to `idle` transitions or retained Active Agents entries, use an interactive agent session in a dedicated tab and sample `prowl agents` repeatedly. + +## Fallback Checks + +`prowl` is the primary control surface, but it cannot verify every visual or accessibility detail. Use fallbacks when CLI output cannot prove the behavior. + +For screenshots: + +```bash +mkdir -p /tmp/prowl-self-verify +screencapture -x /tmp/prowl-self-verify/prowl-screen.png +``` + +Use `view_image` or another available image inspection tool when visual review matters. + +For a shallow macOS Accessibility inspection: + +```bash +osascript -e 'tell application "System Events" to tell process "Prowl" to get name of windows' +osascript -e 'tell application "System Events" to tell process "Prowl" to get {role, subrole, name} of UI elements of window 1' +``` + +Accessibility inspection may require macOS Accessibility permission for the controlling process, and SwiftUI often exposes broad `AXHostingView` nodes rather than a rich semantic tree. Treat AX output as supporting evidence, not a complete substitute for CLI checks or direct screenshots. + +Browser or computer-use skills are useful for web and localhost targets, but they do not automatically provide reliable control over arbitrary macOS apps. Prefer Prowl CLI first, then screenshots or AX inspection for gaps. + +## Cleanup + +Close tabs or panes created for verification: + +```bash +PROWL_CLI_SOCKET="$socket" "$cli" tab close --tab "$tab" --force --json +``` + +Stop the `make run-app` session when validation is done. If you must terminate manually, target only the debug app launched from DerivedData and do not kill `/Applications/Prowl.app`. + +Remove temporary screenshots or socket files when they are no longer useful. + +## Report Results + +In the final report, include: + +- The socket path used for the debug app. +- Which CLI binary was used and why. +- The concrete CLI operations and agent tasks performed. +- The relevant observed app or agent states. +- Any screenshot or AX fallback evidence. +- Cleanup performed and any remaining limitations. diff --git a/.claude/skills/self-verify-prowl/agents/openai.yaml b/.claude/skills/self-verify-prowl/agents/openai.yaml new file mode 100644 index 00000000..1a642767 --- /dev/null +++ b/.claude/skills/self-verify-prowl/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Self Verify Prowl" + short_description: "Bootstrap-verify Prowl with its CLI" + default_prompt: "Use $self-verify-prowl to validate this Prowl change against a debug app instance." -- 2.51.2 From b83a812e56eeffa69467089a4a2e6f0fbdf1aafe Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 22:46:26 +0900 Subject: [PATCH 421/528] Expose Claude skills to Codex --- .codex/skills | 1 + 1 file changed, 1 insertion(+) create mode 120000 .codex/skills diff --git a/.codex/skills b/.codex/skills new file mode 120000 index 00000000..454b8427 --- /dev/null +++ b/.codex/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file -- 2.51.2 From aeb206971b45982aac4db123fd261df2fe3980c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 13 Jun 2026 22:59:04 +0900 Subject: [PATCH 422/528] Generalize Prowl self verification scenarios --- .claude/skills/self-verify-prowl/SKILL.md | 29 +++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index c2b2603e..a931dfde 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -65,27 +65,36 @@ PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable -- Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. -## Verify Active Agents +## Run Observable Scenarios -For Active Agents behavior, create a fresh tab or pane, start a small agent task, then query the roster: +Turn the change into one or more observable scenarios. Prefer small checks that prove the behavior directly: + +- For command routing, run a command that prints the cwd, environment, or a unique marker. +- For tab, pane, focus, or worktree behavior, create an isolated tab or pane and inspect `list --json` before and after the action. +- For long-running task behavior, start a controlled command with visible output, then sample the pane with `read`. +- For agent-specific behavior, start a short agent session and use `agents --json` only when the changed behavior involves the Active Agents roster. + +Example command scenario: ```bash PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" \ - 'codex --sandbox read-only "Reply exactly SELF_VERIFY_OK. Do not modify files."' \ - --no-wait --json + 'printf "SELF_VERIFY:%s\n" "$PWD"' \ + --capture --timeout 30 --json -PROWL_CLI_SOCKET="$socket" "$cli" agents --json -PROWL_CLI_SOCKET="$socket" "$cli" agents --no-color +PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable --json ``` -Use the returned `data.agents[].pane.id` to inspect or control the agent's pane: +Example long-running scenario: ```bash -agent_pane="$(PROWL_CLI_SOCKET="$socket" "$cli" agents --json | jq -r '.data.agents[0].pane.id')" -PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$agent_pane" --last 120 --wait-stable --json +PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" \ + 'for i in 1 2 3; do echo "SELF_VERIFY_STEP:$i"; sleep 1; done' \ + --no-wait --json + +PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 120 --json ``` -Short non-interactive agent tasks can finish before the roster is sampled. If the goal is to observe `working` to `idle` transitions or retained Active Agents entries, use an interactive agent session in a dedicated tab and sample `prowl agents` repeatedly. +If the scenario uses another agent, keep it scoped and reversible. Short non-interactive agent tasks can finish before they are sampled; use an interactive session only when the behavior under test requires observing an active retained pane. ## Fallback Checks -- 2.51.2 From e5fed6f8410480590bf0969cc53c6ae6af40c036 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 00:03:37 +0900 Subject: [PATCH 423/528] Fix mock-server socket file leak in CLI integration tests The mock server bound sockets under NSTemporaryDirectory() named prowl-cli--.sock. That full path (~49B dir + suffix + 36B UUID + .sock) exceeds the AF_UNIX sun_path limit (~104B), so bind() silently truncated it on disk while unlink() used the full, untruncated string. The two never matched, leaking one socket per case (784 had piled up under /var/folders during skill development). - Move socket files to /tmp so the full path stays well under sun_path, matching the CLI's own socket convention. - precondition the path length so a future long suffix fails fast instead of silently truncating and leaking again. - Give MockSocketServer an idempotent stop() invoked via defer in the helper, so cleanup is deterministic rather than deinit-timing reliant. - Add a tearDownWithError backstop that sweeps leftover prowl-cli-* sockets (prefix-only match also catches truncated names). --- ProwlCLITests/ProwlCLIIntegrationTests.swift | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 1e6a13f3..cbbe829e 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -14,6 +14,21 @@ final class ProwlCLIIntegrationTests: XCTestCase { .deletingLastPathComponent() } + /// Backstop cleanup for mock-server socket files. `MockSocketServer.stop()` + /// (run via `defer`) covers the normal and throwing paths, but a test process + /// killed mid-run (timeout, Ctrl-C) skips both `defer` and `deinit` and leaks + /// the bound socket. Sweep any leftover `prowl-cli-*` from the socket + /// directory after each test so they cannot accumulate. Matched by prefix + /// only (no `.sock` suffix), so a truncated name would still be caught. + override func tearDownWithError() throws { + let dir = Self.socketDirectory + for name in (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [] + where name.hasPrefix("prowl-cli-") { + unlink((dir as NSString).appendingPathComponent(name)) + } + try super.tearDownWithError() + } + func testHelpAndVersionSmoke() throws { let version = try runProwl(args: ["--version"]) XCTAssertEqual(version.exitCode, 0) @@ -1432,6 +1447,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { encoder.outputFormatting = [.sortedKeys] let responseData = try encoder.encode(response) let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) + defer { server.stop() } try server.start() let result = try runProwl( @@ -1575,10 +1591,22 @@ final class ProwlCLIIntegrationTests: XCTestCase { return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) } + /// Directory for mock-server socket files, kept deliberately short. AF_UNIX + /// `sun_path` is capped at ~104 bytes; `NSTemporaryDirectory()` alone is ~49, + /// so `prowl-cli--.sock` overflows and `bind()` silently + /// truncates the path. A truncated path would not match the string we pass to + /// `unlink()`, leaking the socket file. `/tmp` keeps the full path well under + /// the limit (and matches the CLI's own socket convention). + private static let socketDirectory = "/tmp" + private func temporarySocketPath(suffix: String) -> String { let uuid = UUID().uuidString.lowercased() let filename = "prowl-cli-\(suffix)-\(uuid).sock" - return (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) + let path = (Self.socketDirectory as NSString).appendingPathComponent(filename) + // Fail fast if a future suffix pushes the path past the sun_path limit, + // rather than letting bind() truncate and leak the socket again. + precondition(path.utf8.count <= 103, "Socket path exceeds AF_UNIX sun_path limit: \(path)") + return path } } @@ -1914,9 +1942,16 @@ private final class MockSocketServer: @unchecked Sendable { self.responseData = responseData } - deinit { + deinit { stop() } + + /// Idempotent teardown: closes the listening socket and removes its file. + /// Invoked via `defer` from the test helper so cleanup does not rely on + /// non-deterministic `deinit` timing — the accept loop runs on a background + /// queue, which can delay ARC release and leave the bound socket file behind. + func stop() { if serverFD >= 0 { close(serverFD) + serverFD = -1 } unlink(socketPath) } -- 2.51.2 From b7bd0653f12ae18e1302ee11e4eaaceb04dae9ce Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 00:10:59 +0900 Subject: [PATCH 424/528] Refine self-verify-prowl and prowl-cli skills - self-verify-prowl: remove macOS Accessibility inspection (no longer useful), switch the screenshot fallback to a single debug-window capture via CGWindowListCopyWindowInfo + screencapture -l, and clean up the temporary socket plus its never-unlinked .lock file. - prowl-cli: add a "Parsing JSON Output" section with jq recipes and a per-command field table so agents stop guessing field names (terminal text is .data.text, not .content). Fix the tab create example that used a non-existent --title option. Refs #443 --- .claude/skills/self-verify-prowl/SKILL.md | 51 ++++++++++++++--------- skills/prowl-cli/SKILL.md | 35 ++++++++++++++++ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index a931dfde..738c50e9 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -1,6 +1,6 @@ --- name: self-verify-prowl -description: Bootstrap-verify Prowl changes by launching a debug app with a dedicated PROWL_CLI_SOCKET and driving it from the current Prowl session. Use after implementing Prowl app, terminal, Active Agents, or CLI changes when Codex should validate behavior end-to-end in a separate Prowl instance, including opening worktrees, creating tabs, running commands or agent sessions, reading panes, and falling back to screenshots or macOS Accessibility inspection when the prowl CLI is insufficient. +description: Bootstrap-verify Prowl changes by launching a debug app with a dedicated PROWL_CLI_SOCKET and driving it from the current Prowl session. Use after implementing Prowl app, terminal, Active Agents, or CLI changes when Codex should validate behavior end-to-end in a separate Prowl instance, including opening worktrees, creating tabs, running commands or agent sessions, reading panes, and falling back to a single-window screenshot of the debug app when the prowl CLI is insufficient. --- # Self Verify Prowl @@ -9,7 +9,7 @@ description: Bootstrap-verify Prowl changes by launching a debug app with a dedi Use this skill to validate a Prowl change from inside Prowl itself: start a freshly built debug app, point it at a temporary CLI socket, and use `prowl` from the current session to drive that separate app instance. -Prefer `prowl` CLI operations because they are scriptable and leave useful evidence. When CLI coverage is not enough, use screenshots and macOS Accessibility inspection as secondary checks. +Prefer `prowl` CLI operations because they are scriptable and leave useful evidence. When CLI coverage is not enough, fall back to a single-window screenshot of the debug app as a secondary check. ## When To Use @@ -58,7 +58,7 @@ Then operate the debug app through the custom socket: ```bash PROWL_CLI_SOCKET="$socket" "$cli" open . PROWL_CLI_SOCKET="$socket" "$cli" list --json -PROWL_CLI_SOCKET="$socket" "$cli" tab create --title "Self Verify" --json +PROWL_CLI_SOCKET="$socket" "$cli" tab create --json PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable --json ``` @@ -98,27 +98,29 @@ If the scenario uses another agent, keep it scoped and reversible. Short non-int ## Fallback Checks -`prowl` is the primary control surface, but it cannot verify every visual or accessibility detail. Use fallbacks when CLI output cannot prove the behavior. +`prowl` is the primary control surface, but it cannot verify every visual detail. Use a screenshot when CLI output cannot prove the behavior. -For screenshots: +Capture only the debug app's window, not the whole screen. The current Prowl session usually sits in front of the debug instance, so a full-screen `screencapture -x` would show the wrong window. Resolve the debug app's window id from its PID and capture just that window: ```bash mkdir -p /tmp/prowl-self-verify -screencapture -x /tmp/prowl-self-verify/prowl-screen.png +pid="$(ps aux | grep "Debug/Prowl.app/Contents/MacOS/Prowl" | grep -v grep | awk '{print $2}' | head -1)" +cat > /tmp/prowl-self-verify/winid.swift <<'SWIFT' +import CoreGraphics +import Foundation +let pid = Int32(CommandLine.arguments[1]) ?? -1 +let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] +for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { + if let n = w[kCGWindowNumber as String] as? Int { print(n); break } +} +SWIFT +wid="$(swift /tmp/prowl-self-verify/winid.swift "$pid" | head -1)" +screencapture -o -l"$wid" /tmp/prowl-self-verify/prowl-debug-window.png ``` -Use `view_image` or another available image inspection tool when visual review matters. +Match on the `Debug/Prowl.app` path so you target the debug instance, never the installed app. `screencapture -o -l` grabs just that window without its shadow. Then use `view_image` or another image inspection tool to review it. -For a shallow macOS Accessibility inspection: - -```bash -osascript -e 'tell application "System Events" to tell process "Prowl" to get name of windows' -osascript -e 'tell application "System Events" to tell process "Prowl" to get {role, subrole, name} of UI elements of window 1' -``` - -Accessibility inspection may require macOS Accessibility permission for the controlling process, and SwiftUI often exposes broad `AXHostingView` nodes rather than a rich semantic tree. Treat AX output as supporting evidence, not a complete substitute for CLI checks or direct screenshots. - -Browser or computer-use skills are useful for web and localhost targets, but they do not automatically provide reliable control over arbitrary macOS apps. Prefer Prowl CLI first, then screenshots or AX inspection for gaps. +Browser or computer-use skills are useful for web and localhost targets, but do not provide reliable control over arbitrary macOS apps. Prefer the Prowl CLI first, then a single-window screenshot for visual gaps. ## Cleanup @@ -128,9 +130,18 @@ Close tabs or panes created for verification: PROWL_CLI_SOCKET="$socket" "$cli" tab close --tab "$tab" --force --json ``` -Stop the `make run-app` session when validation is done. If you must terminate manually, target only the debug app launched from DerivedData and do not kill `/Applications/Prowl.app`. +Stop the `make run-app` session when validation is done. If you must terminate manually, target only the debug app launched from DerivedData and do not kill `/Applications/Prowl.app`: -Remove temporary screenshots or socket files when they are no longer useful. +```bash +pkill -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/Prowl" +``` + +Remove the temporary files this skill created. The app never cleans up the custom socket or its lock: `CLISocketServer` only `unlink`s the socket on a clean `stop()` and never unlinks the `.lock` file at all, so a killed debug app leaves both `$socket` and `$socket.lock` behind. Delete them yourself, along with any screenshots: + +```bash +rm -f "$socket" "$socket.lock" +rm -rf /tmp/prowl-self-verify +``` ## Report Results @@ -140,5 +151,5 @@ In the final report, include: - Which CLI binary was used and why. - The concrete CLI operations and agent tasks performed. - The relevant observed app or agent states. -- Any screenshot or AX fallback evidence. +- Any single-window screenshot evidence. - Cleanup performed and any remaining limitations. diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 12ab91c0..7056f43f 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -100,6 +100,40 @@ prowl tab close --tab "$tab" --json prowl pane close --pane "$pane" --force --json ``` +## Parsing JSON Output + +Do not guess field names. Every `--json` response is `{ "ok", "command", "schema_version", "data": {...} }`, and the terminal text lives at `.data.text` — not `.content`, `.output`, or `.stdout`. Always parse with `jq` against the fields below; the authoritative, per-command field reference lives in `docs/components/cli.md`. + +```bash +# read: rendered terminal text is .data.text +prowl read --pane "$pane" --last 80 --wait-stable --json | jq -r '.data.text' + +# send --capture: captured output is .data.capture.text; exit code is .data.wait.exit_code +out="$(prowl send --pane "$pane" 'git status --short' --capture --timeout 30 --json)" +echo "$out" | jq -r '.data.capture.text' +echo "$out" | jq -r '.data.wait.exit_code' + +# tab create / open: new pane and tab ids +created="$(prowl tab create --worktree "$worktree" --json)" +echo "$created" | jq -r '.data.target.pane.id' +echo "$created" | jq -r '.data.target.tab.id' + +# list / agents: ids and status +prowl list --json | jq -r '.data.items[].pane.id' +prowl list --json | jq -r '.data.items[] | select(.pane.focused) | .pane.id' +prowl agents --json | jq -r '.data.agents[] | "\(.status)\t\(.pane.id)"' + +# guard before trusting data: bail if ok is not true +prowl list --json | jq -e '.ok == true' >/dev/null || echo "command failed" +``` + +Key fields by command (see `docs/components/cli.md` for the full contract): + +- `read` → `.data.text`, `.data.line_count`, `.data.truncated`, `.data.mode` (`snapshot`|`last`), `.data.source` (`screen`|`scrollback`|`mixed`), plus `.data.stabilized` / `.data.waited_ms` / `.data.samples` when `--wait-stable`. +- `send` → `.data.input` (source/characters/bytes/trailing_enter_sent); `.data.wait.exit_code` and `.data.wait.duration_ms` when waiting; `.data.capture.text` / `.data.capture.line_count` / `.data.capture.truncated` when `--capture`. +- `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, `.worktree.{id,name,path}`, `.task.status`. +- `tab create` / `open` → `.data.target.{pane,tab,worktree}`. + ## Reading Agent Output `task.status` is useful for coordination but is not enough to prove the screen finished rendering. `idle` can arrive before a TUI has painted its final response. @@ -218,6 +252,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. - `read` returning fewer lines than `--last` requested is normally `truncated: false` — the pane simply has less history and you already have it all, so do not retry for more. `truncated: true` flags a possibly-incomplete result (the full scrollback could not be read). - `send --capture` captures a screen diff; multiline input may include command echo. +- Do not guess JSON field names. Terminal text is `.data.text` (read) and `.data.capture.text` (`send --capture`); see "Parsing JSON Output" and `docs/components/cli.md`. - `prowl list --json | jq ...` snippets should pass shell values with `--arg`. - In zsh, do not name variables `status`; it is readonly. - Parser errors are not JSON even if `--json` is present, because parsing happens before command execution. -- 2.51.2 From 7943fcada125c1228e86cabdeb0b844447899204 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 01:05:24 +0900 Subject: [PATCH 425/528] Improve Prowl self-verification skill --- .claude/skills/self-verify-prowl/SKILL.md | 91 +++++++++++++++++------ 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 738c50e9..17700bd6 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -36,7 +36,8 @@ Do not use this as a replacement for unit tests, `make check`, `make build-app`, Start the debug app with a dedicated socket so it does not fight the normal installed Prowl instance for the default socket: ```bash -socket="/tmp/prowl-self-verify-$$.sock" +socket="$(mktemp -u /tmp/prowl-self-verify.XXXXXX).sock" +printf '%s\n' "$socket" > /tmp/prowl-self-verify.socket PROWL_CLI_SOCKET="$socket" make run-app ``` @@ -44,6 +45,35 @@ Keep that command running in its own shell session. If plain `make run-app` repo When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. +If your shell does not preserve variables across separate command invocations (many agent tool harnesses start a fresh shell each call), `$socket`, `$cli`, `$pane`, and `$tab` will not survive between steps. Persist the socket path as shown above and re-run the setup block below at the top of every later command. The debug app shares the installed app's `~/Library` data, so it loads the real repository list and looks identical to the installed window; never identify the debug instance by appearance. Target it by socket plus pane or tab UUID. + +## Reusable Shell Setup + +Use this block in each shell command after the debug app has started: + +```bash +socket="$(cat /tmp/prowl-self-verify.socket)" +cli="./.build/debug/prowl" + +prowl_debug() { + PROWL_CLI_SOCKET="$socket" "$cli" "$@" +} + +debug_pids() { + for pid in $(pgrep -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/ProwlApp"); do + [ "$(ps -p "$pid" -o comm= 2>/dev/null | sed 's#.*/##')" = "ProwlApp" ] && echo "$pid" + done +} + +for attempt in 1 2 3 4 5 6 7 8 9 10; do + [ -S "$socket" ] && break + sleep 1 +done +test -S "$socket" +``` + +Use `prowl_debug ...` for all CLI commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. + ## Drive With Prowl CLI Use the new CLI when CLI behavior changed: @@ -53,18 +83,25 @@ make build-cli cli="./.build/debug/prowl" ``` -Then operate the debug app through the custom socket: +Then seed the debug app and create a deterministic temporary pane: ```bash -PROWL_CLI_SOCKET="$socket" "$cli" open . -PROWL_CLI_SOCKET="$socket" "$cli" list --json -PROWL_CLI_SOCKET="$socket" "$cli" tab create --json -PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json -PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable --json +opened="$(prowl_debug open . --json)" +worktree="$(echo "$opened" | jq -r '.data.target.worktree.id')" +created="$(prowl_debug tab create --worktree "$worktree" --json)" +pane="$(echo "$created" | jq -r '.data.target.pane.id')" +tab="$(echo "$created" | jq -r '.data.target.tab.id')" + +prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json +prowl_debug read --pane "$pane" --last 80 --wait-stable --json ``` Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. +Always seed the debug instance with `prowl_debug open . --json` before expecting panes. A fresh debug app can start windowless and return an empty `list`; `open .` creates or focuses a worktree tab that later commands can target. Prefer creating an extra temporary tab for the scenario, then close that tab during cleanup. + +`send --timeout` is in seconds (1–300, default 30): the maximum time to wait for the command to finish. The `wait.duration_ms` in the response is how long the command actually took, not the timeout — do not read a small `duration_ms` as the timeout being ignored. + ## Run Observable Scenarios Turn the change into one or more observable scenarios. Prefer small checks that prove the behavior directly: @@ -77,21 +114,21 @@ Turn the change into one or more observable scenarios. Prefer small checks that Example command scenario: ```bash -PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" \ +prowl_debug send --pane "$pane" \ 'printf "SELF_VERIFY:%s\n" "$PWD"' \ --capture --timeout 30 --json -PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 80 --wait-stable --json +prowl_debug read --pane "$pane" --last 80 --wait-stable --json ``` Example long-running scenario: ```bash -PROWL_CLI_SOCKET="$socket" "$cli" send --pane "$pane" \ +prowl_debug send --pane "$pane" \ 'for i in 1 2 3; do echo "SELF_VERIFY_STEP:$i"; sleep 1; done' \ --no-wait --json -PROWL_CLI_SOCKET="$socket" "$cli" read --pane "$pane" --last 120 --json +prowl_debug read --pane "$pane" --last 120 --json ``` If the scenario uses another agent, keep it scoped and reversible. Short non-interactive agent tasks can finish before they are sampled; use an interactive session only when the behavior under test requires observing an active retained pane. @@ -104,7 +141,8 @@ Capture only the debug app's window, not the whole screen. The current Prowl ses ```bash mkdir -p /tmp/prowl-self-verify -pid="$(ps aux | grep "Debug/Prowl.app/Contents/MacOS/Prowl" | grep -v grep | awk '{print $2}' | head -1)" +debug_pid="$(debug_pids | head -1)" +test -n "$debug_pid" cat > /tmp/prowl-self-verify/winid.swift <<'SWIFT' import CoreGraphics import Foundation @@ -114,7 +152,8 @@ for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { if let n = w[kCGWindowNumber as String] as? Int { print(n); break } } SWIFT -wid="$(swift /tmp/prowl-self-verify/winid.swift "$pid" | head -1)" +wid="$(swift /tmp/prowl-self-verify/winid.swift "$debug_pid" | head -1)" +test -n "$wid" screencapture -o -l"$wid" /tmp/prowl-self-verify/prowl-debug-window.png ``` @@ -127,29 +166,35 @@ Browser or computer-use skills are useful for web and localhost targets, but do Close tabs or panes created for verification: ```bash -PROWL_CLI_SOCKET="$socket" "$cli" tab close --tab "$tab" --force --json +prowl_debug tab close --tab "$tab" --force --json ``` -Stop the `make run-app` session when validation is done. If you must terminate manually, target only the debug app launched from DerivedData and do not kill `/Applications/Prowl.app`: +Stop the `make run-app` session when validation is done. If you cannot stop that shell directly, terminate only the debug app launched from DerivedData and never the installed `/Applications/Prowl.app`. The Mach-O executable is named `ProwlApp` (not `Prowl`), and a plain `SIGTERM` is enough. + +Do not use `pkill -f ""`: `-f` can also match the shell or helper process carrying that pattern. Use `debug_pids` from the reusable setup block so only processes whose executable basename is `ProwlApp` are signaled: ```bash -pkill -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/Prowl" +for pid in $(debug_pids); do kill "$pid"; done +sleep 2 +alive="$(debug_pids | tr '\n' ' ')" +[ -n "$alive" ] && echo "debug still alive:$alive" || echo "debug app stopped" ``` -Remove the temporary files this skill created. The app never cleans up the custom socket or its lock: `CLISocketServer` only `unlink`s the socket on a clean `stop()` and never unlinks the `.lock` file at all, so a killed debug app leaves both `$socket` and `$socket.lock` behind. Delete them yourself, along with any screenshots: +`SIGTERM` does not run the app's normal socket teardown, so the custom socket and its lock can remain. Remove them yourself, along with any screenshots: ```bash rm -f "$socket" "$socket.lock" +rm -f /tmp/prowl-self-verify.socket rm -rf /tmp/prowl-self-verify ``` ## Report Results -In the final report, include: +In the final report, include the essentials: - The socket path used for the debug app. -- Which CLI binary was used and why. -- The concrete CLI operations and agent tasks performed. -- The relevant observed app or agent states. -- Any single-window screenshot evidence. -- Cleanup performed and any remaining limitations. +- The CLI binary used. +- The scenario performed and the concrete observed result. +- Cleanup status. + +Add optional details only when they matter: pane or tab UUIDs, agent task state, screenshot path, build/check output, or remaining limitations. -- 2.51.2 From cdccb0a956cfb3fd259fabf321305d89c54c362f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 01:16:51 +0900 Subject: [PATCH 426/528] Add correct JSON field references and prowl-cli cross-reference to self-verify skill The self-verify skill showed CLI commands without demonstrating how to parse their JSON output, causing agents to guess field names wrong (.data.content instead of .data.text, .data.worktrees instead of .data.items). Add explicit jq parsing to all examples, a field quick- reference table, a jq control-character fallback note, and elevate the prowl-cli skill from an optional hint to a mandatory prerequisite. --- .claude/skills/self-verify-prowl/SKILL.md | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 17700bd6..2d0297f3 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -29,7 +29,7 @@ Do not use this as a replacement for unit tests, `make check`, `make build-app`, - Preserve unrelated user changes. Do not close or kill the user's normal Prowl app. - If the CLI changed, build and use the repo CLI, usually `./.build/debug/prowl`. - If the CLI did not change, an installed `prowl` may be usable, but the repo-built CLI keeps the app/CLI protocol aligned. -- For detailed CLI targeting and recipes, consult the dedicated `prowl-cli` skill first when needed. +- **Read the `prowl-cli` skill before writing any CLI commands.** It is the authoritative reference for JSON field names, targeting rules, quoting, argument semantics, error codes, and common pitfalls. This skill covers the self-verify workflow; `prowl-cli` covers how to use `prowl` correctly. Do not guess field names — get them from `prowl-cli`. ## Launch A Separate App @@ -92,12 +92,22 @@ created="$(prowl_debug tab create --worktree "$worktree" --json)" pane="$(echo "$created" | jq -r '.data.target.pane.id')" tab="$(echo "$created" | jq -r '.data.target.tab.id')" -prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json -prowl_debug read --pane "$pane" --last 80 --wait-stable --json +prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json \ + | jq -r '.data.capture.text' +prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ + | jq -r '.data.text' ``` Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. +Key JSON fields (see `prowl-cli` skill for the full reference): + +- `read --json` → terminal text is `.data.text`, not `.content` or `.output`. +- `send --capture --json` → captured output is `.data.capture.text`; exit code is `.data.wait.exit_code`. +- `list --json` → pane list is `.data.items[]`, not `.worktrees[]`; each item has `.pane.id`, `.tab.id`, `.worktree.id`, `.task.status`. + +CLI JSON responses can contain terminal control characters (in `.pane.title` or `.data.text`) that cause `jq` to fail with a parse error. When this happens, use `python3 -c "import sys,json; d=json.loads(sys.stdin.read()); ..."` instead — Python's JSON parser tolerates embedded control characters. + Always seed the debug instance with `prowl_debug open . --json` before expecting panes. A fresh debug app can start windowless and return an empty `list`; `open .` creates or focuses a worktree tab that later commands can target. Prefer creating an extra temporary tab for the scenario, then close that tab during cleanup. `send --timeout` is in seconds (1–300, default 30): the maximum time to wait for the command to finish. The `wait.duration_ms` in the response is how long the command actually took, not the timeout — do not read a small `duration_ms` as the timeout being ignored. @@ -114,11 +124,14 @@ Turn the change into one or more observable scenarios. Prefer small checks that Example command scenario: ```bash -prowl_debug send --pane "$pane" \ +result="$(prowl_debug send --pane "$pane" \ 'printf "SELF_VERIFY:%s\n" "$PWD"' \ - --capture --timeout 30 --json + --capture --timeout 30 --json)" +echo "$result" | jq -r '.data.capture.text' +echo "$result" | jq -r '.data.wait.exit_code' -prowl_debug read --pane "$pane" --last 80 --wait-stable --json +prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ + | jq -r '.data.text' ``` Example long-running scenario: @@ -128,7 +141,8 @@ prowl_debug send --pane "$pane" \ 'for i in 1 2 3; do echo "SELF_VERIFY_STEP:$i"; sleep 1; done' \ --no-wait --json -prowl_debug read --pane "$pane" --last 120 --json +prowl_debug read --pane "$pane" --last 120 --json \ + | jq -r '.data.text' ``` If the scenario uses another agent, keep it scoped and reversible. Short non-interactive agent tasks can finish before they are sampled; use an interactive session only when the behavior under test requires observing an active retained pane. -- 2.51.2 From 1a0a0dc563f73a1ab0f729c864cef0532d604a0e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 01:37:14 +0900 Subject: [PATCH 427/528] Harden self-verify skill: fix PID selection, add prowl_jq, agent launch mode - Add debug_pid_with_window() to find the window-owning PID instead of blindly picking head -1 (helper processes have no windows) - Add prowl_jq() wrapper that tolerates terminal control characters in JSON output (workaround for #444) - Replace all jq calls on terminal-content fields with prowl_jq - Add "Agent mode" launch recipe for headless/background environments - Use fixed socket path instead of mktemp for simpler cross-invocation state - Improve screenshot window lookup: drop optionOnScreenOnly, filter by non-empty window name --- .claude/skills/self-verify-prowl/SKILL.md | 109 ++++++++++++++++------ 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 2d0297f3..eceb506e 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -23,48 +23,103 @@ Use this after changes that affect: Do not use this as a replacement for unit tests, `make check`, `make build-app`, or CLI integration tests. It is an end-to-end manual validation layer on top of those checks. +## First Step — Read the prowl-cli Skill + +**Before writing any `prowl` command, load and read the `prowl-cli` skill.** This is not optional. `prowl-cli` is the authoritative reference for JSON field names, targeting rules, quoting, argument semantics, error codes, and common pitfalls. This skill covers the self-verify workflow; `prowl-cli` covers how to use `prowl` correctly. Do not guess field names — get them from `prowl-cli`. + +Concretely: invoke the `prowl-cli` skill (or read its SKILL.md) before proceeding to the Launch step. If you skip this, you will likely use wrong JSON paths, miss quoting rules, or hit avoidable pitfalls. + ## Preconditions - Work from the Prowl repository root. - Preserve unrelated user changes. Do not close or kill the user's normal Prowl app. - If the CLI changed, build and use the repo CLI, usually `./.build/debug/prowl`. - If the CLI did not change, an installed `prowl` may be usable, but the repo-built CLI keeps the app/CLI protocol aligned. -- **Read the `prowl-cli` skill before writing any CLI commands.** It is the authoritative reference for JSON field names, targeting rules, quoting, argument semantics, error codes, and common pitfalls. This skill covers the self-verify workflow; `prowl-cli` covers how to use `prowl` correctly. Do not guess field names — get them from `prowl-cli`. ## Launch A Separate App -Start the debug app with a dedicated socket so it does not fight the normal installed Prowl instance for the default socket: +The debug app uses a fixed socket at `/tmp/prowl-self-verify.sock`. This path is stable across shell invocations, so every step can reference it directly without intermediate files or re-sourcing. + +Clean up any stale socket from a previous run, then start the debug app: ```bash -socket="$(mktemp -u /tmp/prowl-self-verify.XXXXXX).sock" -printf '%s\n' "$socket" > /tmp/prowl-self-verify.socket -PROWL_CLI_SOCKET="$socket" make run-app +rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock +PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app ``` Keep that command running in its own shell session. If plain `make run-app` reports a socket ownership problem, relaunch with a custom `PROWL_CLI_SOCKET`; the installed app may already own the standard socket. +**Agent mode** — CLI agents do not have a persistent foreground shell, so `make run-app` cannot block. After building, launch the binary directly in the background: + +```bash +rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock +make build-app +app_bin="$(xcodebuild -project supacode.xcodeproj -scheme supacode \ + -configuration Debug -showBuildSettings -json 2>/dev/null \ + | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR + "/" + + .[0].buildSettings.FULL_PRODUCT_NAME + "/Contents/MacOS/" + + .[0].buildSettings.EXECUTABLE_NAME')" +PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock "$app_bin" & +disown +``` + +Then wait for the socket in the reusable setup block as usual. + When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. -If your shell does not preserve variables across separate command invocations (many agent tool harnesses start a fresh shell each call), `$socket`, `$cli`, `$pane`, and `$tab` will not survive between steps. Persist the socket path as shown above and re-run the setup block below at the top of every later command. The debug app shares the installed app's `~/Library` data, so it loads the real repository list and looks identical to the installed window; never identify the debug instance by appearance. Target it by socket plus pane or tab UUID. +The debug app shares the installed app's `~/Library` data, so it loads the real repository list and looks identical to the installed window; never identify the debug instance by appearance. Target it by socket plus pane or tab UUID. ## Reusable Shell Setup -Use this block in each shell command after the debug app has started: +Paste this block at the top of each shell command after the debug app has started. Because the socket path is fixed, no file reads or variable recovery are needed: ```bash -socket="$(cat /tmp/prowl-self-verify.socket)" +socket="/tmp/prowl-self-verify.sock" cli="./.build/debug/prowl" prowl_debug() { PROWL_CLI_SOCKET="$socket" "$cli" "$@" } +# List all debug-build ProwlApp PIDs (may include helper/child processes). debug_pids() { for pid in $(pgrep -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/ProwlApp"); do [ "$(ps -p "$pid" -o comm= 2>/dev/null | sed 's#.*/##')" = "ProwlApp" ] && echo "$pid" done } +# Return the single debug PID that owns a visible window. +# macOS apps can fork helper processes that match the same path but have no +# windows; always use this for screenshot and window-level operations. +debug_pid_with_window() { + local script='/tmp/prowl-self-verify/winid_check.swift' + mkdir -p /tmp/prowl-self-verify + cat > "$script" <<'SWIFT' +import CoreGraphics +import Foundation +let pid = Int32(CommandLine.arguments[1]) ?? -1 +let list = (CGWindowListCopyWindowInfo([.excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] +for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { + if w[kCGWindowNumber as String] is Int { print(pid); break } +} +SWIFT + for pid in $(debug_pids); do + swift "$script" "$pid" 2>/dev/null | grep -q . && echo "$pid" && return + done +} + +# Parse prowl --json output tolerantly. Terminal text can contain raw control +# characters (U+0000–U+001F) that break jq. This helper accepts a jq filter +# and uses Python to absorb the control characters before passing to jq. +# Usage: echo "$json" | prowl_jq '.data.text' +prowl_jq() { + python3 -c " +import sys, json, subprocess +d = json.JSONDecoder(strict=False).decode(sys.stdin.read()) +subprocess.run(['jq'] + sys.argv[1:], input=json.dumps(d, ensure_ascii=False), text=True) +" "$@" +} + for attempt in 1 2 3 4 5 6 7 8 9 10; do [ -S "$socket" ] && break sleep 1 @@ -74,6 +129,8 @@ test -S "$socket" Use `prowl_debug ...` for all CLI commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. +When parsing `--json` output that may contain terminal text, use `prowl_jq` instead of `jq` to avoid control-character parse failures (see [#444](https://github.com/onevcat/Prowl/issues/444)). + ## Drive With Prowl CLI Use the new CLI when CLI behavior changed: @@ -87,15 +144,15 @@ Then seed the debug app and create a deterministic temporary pane: ```bash opened="$(prowl_debug open . --json)" -worktree="$(echo "$opened" | jq -r '.data.target.worktree.id')" +worktree="$(echo "$opened" | prowl_jq -r '.data.target.worktree.id')" created="$(prowl_debug tab create --worktree "$worktree" --json)" -pane="$(echo "$created" | jq -r '.data.target.pane.id')" -tab="$(echo "$created" | jq -r '.data.target.tab.id')" +pane="$(echo "$created" | prowl_jq -r '.data.target.pane.id')" +tab="$(echo "$created" | prowl_jq -r '.data.target.tab.id')" prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json \ - | jq -r '.data.capture.text' + | prowl_jq -r '.data.capture.text' prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ - | jq -r '.data.text' + | prowl_jq -r '.data.text' ``` Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. @@ -127,11 +184,11 @@ Example command scenario: result="$(prowl_debug send --pane "$pane" \ 'printf "SELF_VERIFY:%s\n" "$PWD"' \ --capture --timeout 30 --json)" -echo "$result" | jq -r '.data.capture.text' -echo "$result" | jq -r '.data.wait.exit_code' +echo "$result" | prowl_jq -r '.data.capture.text' +echo "$result" | prowl_jq -r '.data.wait.exit_code' prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ - | jq -r '.data.text' + | prowl_jq -r '.data.text' ``` Example long-running scenario: @@ -142,7 +199,7 @@ prowl_debug send --pane "$pane" \ --no-wait --json prowl_debug read --pane "$pane" --last 120 --json \ - | jq -r '.data.text' + | prowl_jq -r '.data.text' ``` If the scenario uses another agent, keep it scoped and reversible. Short non-interactive agent tasks can finish before they are sampled; use an interactive session only when the behavior under test requires observing an active retained pane. @@ -151,19 +208,21 @@ If the scenario uses another agent, keep it scoped and reversible. Short non-int `prowl` is the primary control surface, but it cannot verify every visual detail. Use a screenshot when CLI output cannot prove the behavior. -Capture only the debug app's window, not the whole screen. The current Prowl session usually sits in front of the debug instance, so a full-screen `screencapture -x` would show the wrong window. Resolve the debug app's window id from its PID and capture just that window: +Capture only the debug app's window, not the whole screen. The current Prowl session usually sits in front of the debug instance, so a full-screen `screencapture -x` would show the wrong window. Use `debug_pid_with_window` from the reusable setup block to find the PID that actually owns a window, then resolve its window id: ```bash mkdir -p /tmp/prowl-self-verify -debug_pid="$(debug_pids | head -1)" +debug_pid="$(debug_pid_with_window)" test -n "$debug_pid" cat > /tmp/prowl-self-verify/winid.swift <<'SWIFT' import CoreGraphics import Foundation let pid = Int32(CommandLine.arguments[1]) ?? -1 -let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] +let list = (CGWindowListCopyWindowInfo([.excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { - if let n = w[kCGWindowNumber as String] as? Int { print(n); break } + if let n = w[kCGWindowNumber as String] as? Int, let name = w[kCGWindowName as String] as? String, !name.isEmpty { + print(n); break + } } SWIFT wid="$(swift /tmp/prowl-self-verify/winid.swift "$debug_pid" | head -1)" @@ -171,7 +230,7 @@ test -n "$wid" screencapture -o -l"$wid" /tmp/prowl-self-verify/prowl-debug-window.png ``` -Match on the `Debug/Prowl.app` path so you target the debug instance, never the installed app. `screencapture -o -l` grabs just that window without its shadow. Then use `view_image` or another image inspection tool to review it. +`debug_pid_with_window` iterates all debug PIDs and returns only the one that owns a visible window, avoiding helper/child processes that match the same binary path. `screencapture -o -l` grabs just that window without its shadow. Then use `view_image` or another image inspection tool to review it. Browser or computer-use skills are useful for web and localhost targets, but do not provide reliable control over arbitrary macOS apps. Prefer the Prowl CLI first, then a single-window screenshot for visual gaps. @@ -197,8 +256,7 @@ alive="$(debug_pids | tr '\n' ' ')" `SIGTERM` does not run the app's normal socket teardown, so the custom socket and its lock can remain. Remove them yourself, along with any screenshots: ```bash -rm -f "$socket" "$socket.lock" -rm -f /tmp/prowl-self-verify.socket +rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock rm -rf /tmp/prowl-self-verify ``` @@ -206,8 +264,7 @@ rm -rf /tmp/prowl-self-verify In the final report, include the essentials: -- The socket path used for the debug app. -- The CLI binary used. +- The CLI binary used (repo-built or installed). - The scenario performed and the concrete observed result. - Cleanup status. -- 2.51.2 From 33d5aacf3178db7807308b2c1176c58a4e38e751 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 01:59:20 +0900 Subject: [PATCH 428/528] Improve self-verify skill helpers --- .claude/skills/self-verify-prowl/SKILL.md | 89 ++++------------ .../self-verify-prowl/scripts/helpers.sh | 100 ++++++++++++++++++ 2 files changed, 120 insertions(+), 69 deletions(-) create mode 100755 .claude/skills/self-verify-prowl/scripts/helpers.sh diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index eceb506e..48055b3f 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -65,69 +65,33 @@ disown Then wait for the socket in the reusable setup block as usual. +If the socket appears but `prowl_debug list --json` returns `APP_NOT_RUNNING`, the app likely exited and left a stale socket/lock behind. Remove both socket files, confirm no debug `ProwlApp` PID is still alive, then relaunch. In agent sessions where the direct binary exits immediately, use a PTY-backed `PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app` session instead. + When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. The debug app shares the installed app's `~/Library` data, so it loads the real repository list and looks identical to the installed window; never identify the debug instance by appearance. Target it by socket plus pane or tab UUID. ## Reusable Shell Setup -Paste this block at the top of each shell command after the debug app has started. Because the socket path is fixed, no file reads or variable recovery are needed: +Available script: + +- `scripts/helpers.sh` — Source-only helpers for the dedicated socket, tolerant JSON parsing, debug PID lookup, health checks, and PID-scoped screenshots. + +Use the bundled helper script instead of re-pasting shell functions. Source it after the debug app has started, then run its health check before driving the app: ```bash -socket="/tmp/prowl-self-verify.sock" -cli="./.build/debug/prowl" +. .claude/skills/self-verify-prowl/scripts/helpers.sh +wait_for_prowl_debug +``` + +For each later shell command, source the helper and rerun the health check before driving the app: -prowl_debug() { - PROWL_CLI_SOCKET="$socket" "$cli" "$@" -} - -# List all debug-build ProwlApp PIDs (may include helper/child processes). -debug_pids() { - for pid in $(pgrep -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/ProwlApp"); do - [ "$(ps -p "$pid" -o comm= 2>/dev/null | sed 's#.*/##')" = "ProwlApp" ] && echo "$pid" - done -} - -# Return the single debug PID that owns a visible window. -# macOS apps can fork helper processes that match the same path but have no -# windows; always use this for screenshot and window-level operations. -debug_pid_with_window() { - local script='/tmp/prowl-self-verify/winid_check.swift' - mkdir -p /tmp/prowl-self-verify - cat > "$script" <<'SWIFT' -import CoreGraphics -import Foundation -let pid = Int32(CommandLine.arguments[1]) ?? -1 -let list = (CGWindowListCopyWindowInfo([.excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] -for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { - if w[kCGWindowNumber as String] is Int { print(pid); break } -} -SWIFT - for pid in $(debug_pids); do - swift "$script" "$pid" 2>/dev/null | grep -q . && echo "$pid" && return - done -} - -# Parse prowl --json output tolerantly. Terminal text can contain raw control -# characters (U+0000–U+001F) that break jq. This helper accepts a jq filter -# and uses Python to absorb the control characters before passing to jq. -# Usage: echo "$json" | prowl_jq '.data.text' -prowl_jq() { - python3 -c " -import sys, json, subprocess -d = json.JSONDecoder(strict=False).decode(sys.stdin.read()) -subprocess.run(['jq'] + sys.argv[1:], input=json.dumps(d, ensure_ascii=False), text=True) -" "$@" -} - -for attempt in 1 2 3 4 5 6 7 8 9 10; do - [ -S "$socket" ] && break - sleep 1 -done -test -S "$socket" +```bash +. .claude/skills/self-verify-prowl/scripts/helpers.sh +wait_for_prowl_debug ``` -Use `prowl_debug ...` for all CLI commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. +Use `prowl_debug ...`, `prowl_jq ...`, and `debug_window_id` from `scripts/helpers.sh` for the commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. When parsing `--json` output that may contain terminal text, use `prowl_jq` instead of `jq` to avoid control-character parse failures (see [#444](https://github.com/onevcat/Prowl/issues/444)). @@ -208,29 +172,16 @@ If the scenario uses another agent, keep it scoped and reversible. Short non-int `prowl` is the primary control surface, but it cannot verify every visual detail. Use a screenshot when CLI output cannot prove the behavior. -Capture only the debug app's window, not the whole screen. The current Prowl session usually sits in front of the debug instance, so a full-screen `screencapture -x` would show the wrong window. Use `debug_pid_with_window` from the reusable setup block to find the PID that actually owns a window, then resolve its window id: +Capture only the debug app's window, not the whole screen. The current Prowl session usually sits in front of the debug instance, so a full-screen `screencapture -x` would show the wrong window. Use `debug_window_id` from the reusable setup block to resolve a PID-scoped window id: ```bash -mkdir -p /tmp/prowl-self-verify -debug_pid="$(debug_pid_with_window)" -test -n "$debug_pid" -cat > /tmp/prowl-self-verify/winid.swift <<'SWIFT' -import CoreGraphics -import Foundation -let pid = Int32(CommandLine.arguments[1]) ?? -1 -let list = (CGWindowListCopyWindowInfo([.excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] -for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { - if let n = w[kCGWindowNumber as String] as? Int, let name = w[kCGWindowName as String] as? String, !name.isEmpty { - print(n); break - } -} -SWIFT -wid="$(swift /tmp/prowl-self-verify/winid.swift "$debug_pid" | head -1)" +. .claude/skills/self-verify-prowl/scripts/helpers.sh +wid="$(debug_window_id)" test -n "$wid" screencapture -o -l"$wid" /tmp/prowl-self-verify/prowl-debug-window.png ``` -`debug_pid_with_window` iterates all debug PIDs and returns only the one that owns a visible window, avoiding helper/child processes that match the same binary path. `screencapture -o -l` grabs just that window without its shadow. Then use `view_image` or another image inspection tool to review it. +`debug_window_id` compiles its tiny CoreGraphics helper on first screenshot use and reuses it for later screenshots in the same run. `screencapture -o -l` grabs just that window without its shadow. Then use `view_image` or another image inspection tool to review it. Browser or computer-use skills are useful for web and localhost targets, but do not provide reliable control over arbitrary macOS apps. Prefer the Prowl CLI first, then a single-window screenshot for visual gaps. diff --git a/.claude/skills/self-verify-prowl/scripts/helpers.sh b/.claude/skills/self-verify-prowl/scripts/helpers.sh new file mode 100755 index 00000000..3814901f --- /dev/null +++ b/.claude/skills/self-verify-prowl/scripts/helpers.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# Source this file from the Prowl repository root while using the +# self-verify-prowl skill in zsh or bash. It intentionally does not execute +# a scenario when sourced. + +socket="${PROWL_SELF_VERIFY_SOCKET:-/tmp/prowl-self-verify.sock}" +cli="${PROWL_SELF_VERIFY_CLI:-./.build/debug/prowl}" +scratch_dir="${PROWL_SELF_VERIFY_DIR:-/tmp/prowl-self-verify}" + +prowl_debug() { + PROWL_CLI_SOCKET="$socket" "$cli" "$@" +} + +# List all debug-build ProwlApp PIDs (may include helper/child processes). +debug_pids() { + for pid in $(pgrep -f "DerivedData/.*/Debug/Prowl.app/Contents/MacOS/ProwlApp"); do + [ "$(ps -p "$pid" -o comm= 2>/dev/null | sed 's#.*/##')" = "ProwlApp" ] && echo "$pid" + done +} + +# Compile a PID-scoped CoreGraphics window lookup helper on first screenshot use. +# Keep this CoreGraphics-based instead of osascript by default: it targets the +# debug app by PID instead of frontmost app state, window title, or UI scripting. +debug_window_id_tool() { + local tool="$scratch_dir/window-id" + local src="$scratch_dir/window-id.swift" + mkdir -p "$scratch_dir" + if [ ! -x "$tool" ]; then + cat > "$src" <<'SWIFT' +import CoreGraphics +import Foundation +let pid = Int32(CommandLine.arguments[1]) ?? -1 +let list = (CGWindowListCopyWindowInfo([.excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? [] +for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { + if let n = w[kCGWindowNumber as String] as? Int, let name = w[kCGWindowName as String] as? String, !name.isEmpty { + print(n) + exit(0) + } +} +for w in list where (w[kCGWindowOwnerPID as String] as? Int32) == pid { + if let n = w[kCGWindowNumber as String] as? Int { + print(n) + exit(0) + } +} +exit(1) +SWIFT + swiftc "$src" -o "$tool" + fi + printf '%s\n' "$tool" +} + +# Return the single debug PID that owns a visible window. +# macOS apps can fork helper processes that match the same path but have no +# windows; always use this for screenshot and window-level operations. +debug_pid_with_window() { + local tool + tool="$(debug_window_id_tool)" || return + for pid in $(debug_pids); do + "$tool" "$pid" >/dev/null 2>&1 && echo "$pid" && return + done +} + +debug_window_id() { + local tool pid + tool="$(debug_window_id_tool)" || return + pid="${1:-$(debug_pid_with_window)}" + [ -n "$pid" ] || return + "$tool" "$pid" | head -1 +} + +# Parse prowl --json output tolerantly. Terminal text can contain raw control +# characters (U+0000-U+001F) that break jq. This helper accepts a jq filter +# and uses Python to absorb the control characters before passing to jq. +# Usage: echo "$json" | prowl_jq '.data.text' +prowl_jq() { + python3 -c " +import sys, json, subprocess +d = json.JSONDecoder(strict=False).decode(sys.stdin.read()) +subprocess.run(['jq'] + sys.argv[1:], input=json.dumps(d, ensure_ascii=False), text=True) +" "$@" +} + +wait_for_prowl_debug() { + for attempt in 1 2 3 4 5 6 7 8 9 10; do + [ -S "$socket" ] && break + sleep 1 + done + test -S "$socket" || return 1 + + # A socket file alone can be stale. Require a live debug app and a successful + # no-op CLI round trip before running scenario commands. + test -n "$(debug_pids | head -1)" || return 1 + health="$(prowl_debug list --json)" + test "$(echo "$health" | prowl_jq -r '.ok')" = "true" || { + echo "$health" | prowl_jq -r '.error.code? // "unknown"' + return 1 + } +} -- 2.51.2 From ebf193db673c78f4d0628c2e2c65e555f1a96ab3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 02:11:16 +0900 Subject: [PATCH 429/528] Fix CLI JSON control character output --- ProwlCLI/CLIRunner.swift | 14 ++++- ProwlCLITests/ProwlCLIIntegrationTests.swift | 58 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ProwlCLI/CLIRunner.swift b/ProwlCLI/CLIRunner.swift index ca197fe8..2241e048 100644 --- a/ProwlCLI/CLIRunner.swift +++ b/ProwlCLI/CLIRunner.swift @@ -13,7 +13,12 @@ enum CLIRunner { let responseData = try SocketTransportClient.send(envelope) let decoder = JSONDecoder() let response = try decoder.decode(CommandResponse.self, from: responseData) - OutputRenderer.render(response, mode: envelope.output) + switch envelope.output { + case .json: + renderJSONData(responseData) + case .text: + OutputRenderer.render(response, mode: .text) + } if !response.ok { throw ExitCode.failure } @@ -37,4 +42,11 @@ enum CLIRunner { throw ExitCode.failure } } + + private static func renderJSONData(_ data: Data) { + FileHandle.standardOutput.write(data) + if data.last != UInt8(ascii: "\n") { + FileHandle.standardOutput.write(Data([UInt8(ascii: "\n")])) + } + } } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 095c8726..5bd87f00 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -38,6 +38,41 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(error["code"] as? String, CLIErrorCode.appNotRunning) } + func testJSONModePreservesEscapedControlCharactersFromAppResponse() throws { + let socketPath = temporarySocketPath(suffix: "json-control") + let responseJSON = [ + #"{"ok":true,"command":"read","schema_version":"prowl.cli.read.v1","data":{"#, + #""target":{"#, + #""worktree":{"id":"wt-1","name":"main","path":"/Projects/App","root_path":"/Projects/App","kind":"git"},"#, + #""tab":{"id":"tab-1","title":"Tab\u0007Title","selected":true},"#, + #""pane":{"id":"pane-1","title":"zsh\u001b[31m","cwd":"/Projects/App","focused":true}"#, + #"},"#, + #""mode":"last","last":80,"source":"scrollback","truncated":false,"#, + #""line_count":1,"text":"alpha\u001b[31mbeta\u0000end"}}"#, + ].joined() + let responseData = try XCTUnwrap(responseJSON.data(using: .utf8)) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + responseData: responseData, + args: ["read", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, "\(responseJSON)\n") + assertNoRawJSONControlCharacters(in: result.stdout) + + let payload = try jsonObject(from: result.stdout) + let data = try XCTUnwrap(payload["data"] as? [String: Any]) + XCTAssertEqual(data["text"] as? String, "alpha\u{1B}[31mbeta\u{0}end") + + let target = try XCTUnwrap(data["target"] as? [String: Any]) + let tab = try XCTUnwrap(target["tab"] as? [String: Any]) + let pane = try XCTUnwrap(target["pane"] as? [String: Any]) + XCTAssertEqual(tab["title"] as? String, "Tab\u{7}Title") + XCTAssertEqual(pane["title"] as? String, "zsh\u{1B}[31m") + } + func testOpenCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "open") let response = try CommandResponse( @@ -1329,6 +1364,14 @@ final class ProwlCLIIntegrationTests: XCTestCase { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let responseData = try encoder.encode(response) + return try runWithMockServer(socketPath: socketPath, responseData: responseData, args: args) + } + + private func runWithMockServer( + socketPath: String, + responseData: Data, + args: [String] + ) throws -> (Data, CommandResult) { let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) try server.start() @@ -1445,6 +1488,21 @@ final class ProwlCLIIntegrationTests: XCTestCase { return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) } + private func assertNoRawJSONControlCharacters( + in text: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let data = text.data(using: .utf8) else { + XCTFail("Output is not UTF-8", file: file, line: line) + return + } + + for byte in data where byte < 0x20 && byte != 0x09 && byte != 0x0A && byte != 0x0D { + XCTFail("Output contains raw control byte 0x\(String(byte, radix: 16))", file: file, line: line) + } + } + private func temporarySocketPath(suffix: String) -> String { let uuid = UUID().uuidString.lowercased() let filename = "prowl-cli-\(suffix)-\(uuid).sock" -- 2.51.2 From 6a53144e9c76c75c70828dd2a264bcf72ddf04ea Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 02:27:19 +0900 Subject: [PATCH 430/528] Use jq directly in Prowl self verification --- .claude/skills/self-verify-prowl/SKILL.md | 24 +++++++++---------- .../self-verify-prowl/scripts/helpers.sh | 16 ++----------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 48055b3f..c20c0b27 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -91,9 +91,9 @@ For each later shell command, source the helper and rerun the health check befor wait_for_prowl_debug ``` -Use `prowl_debug ...`, `prowl_jq ...`, and `debug_window_id` from `scripts/helpers.sh` for the commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. +Use `prowl_debug ...` and `debug_window_id` from `scripts/helpers.sh` for the commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. -When parsing `--json` output that may contain terminal text, use `prowl_jq` instead of `jq` to avoid control-character parse failures (see [#444](https://github.com/onevcat/Prowl/issues/444)). +Parse `--json` output with ordinary `jq`. If `jq` reports invalid control characters for CLI JSON, treat it as a CLI regression rather than working around it in this skill. ## Drive With Prowl CLI @@ -108,15 +108,15 @@ Then seed the debug app and create a deterministic temporary pane: ```bash opened="$(prowl_debug open . --json)" -worktree="$(echo "$opened" | prowl_jq -r '.data.target.worktree.id')" +worktree="$(echo "$opened" | jq -r '.data.target.worktree.id')" created="$(prowl_debug tab create --worktree "$worktree" --json)" -pane="$(echo "$created" | prowl_jq -r '.data.target.pane.id')" -tab="$(echo "$created" | prowl_jq -r '.data.target.tab.id')" +pane="$(echo "$created" | jq -r '.data.target.pane.id')" +tab="$(echo "$created" | jq -r '.data.target.tab.id')" prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json \ - | prowl_jq -r '.data.capture.text' + | jq -r '.data.capture.text' prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ - | prowl_jq -r '.data.text' + | jq -r '.data.text' ``` Prefer targeting by pane or tab UUIDs from JSON output. Avoid relying on titles when multiple Prowl instances or similar tabs exist. @@ -127,8 +127,6 @@ Key JSON fields (see `prowl-cli` skill for the full reference): - `send --capture --json` → captured output is `.data.capture.text`; exit code is `.data.wait.exit_code`. - `list --json` → pane list is `.data.items[]`, not `.worktrees[]`; each item has `.pane.id`, `.tab.id`, `.worktree.id`, `.task.status`. -CLI JSON responses can contain terminal control characters (in `.pane.title` or `.data.text`) that cause `jq` to fail with a parse error. When this happens, use `python3 -c "import sys,json; d=json.loads(sys.stdin.read()); ..."` instead — Python's JSON parser tolerates embedded control characters. - Always seed the debug instance with `prowl_debug open . --json` before expecting panes. A fresh debug app can start windowless and return an empty `list`; `open .` creates or focuses a worktree tab that later commands can target. Prefer creating an extra temporary tab for the scenario, then close that tab during cleanup. `send --timeout` is in seconds (1–300, default 30): the maximum time to wait for the command to finish. The `wait.duration_ms` in the response is how long the command actually took, not the timeout — do not read a small `duration_ms` as the timeout being ignored. @@ -148,11 +146,11 @@ Example command scenario: result="$(prowl_debug send --pane "$pane" \ 'printf "SELF_VERIFY:%s\n" "$PWD"' \ --capture --timeout 30 --json)" -echo "$result" | prowl_jq -r '.data.capture.text' -echo "$result" | prowl_jq -r '.data.wait.exit_code' +echo "$result" | jq -r '.data.capture.text' +echo "$result" | jq -r '.data.wait.exit_code' prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ - | prowl_jq -r '.data.text' + | jq -r '.data.text' ``` Example long-running scenario: @@ -163,7 +161,7 @@ prowl_debug send --pane "$pane" \ --no-wait --json prowl_debug read --pane "$pane" --last 120 --json \ - | prowl_jq -r '.data.text' + | jq -r '.data.text' ``` If the scenario uses another agent, keep it scoped and reversible. Short non-interactive agent tasks can finish before they are sampled; use an interactive session only when the behavior under test requires observing an active retained pane. diff --git a/.claude/skills/self-verify-prowl/scripts/helpers.sh b/.claude/skills/self-verify-prowl/scripts/helpers.sh index 3814901f..f1db9693 100755 --- a/.claude/skills/self-verify-prowl/scripts/helpers.sh +++ b/.claude/skills/self-verify-prowl/scripts/helpers.sh @@ -70,18 +70,6 @@ debug_window_id() { "$tool" "$pid" | head -1 } -# Parse prowl --json output tolerantly. Terminal text can contain raw control -# characters (U+0000-U+001F) that break jq. This helper accepts a jq filter -# and uses Python to absorb the control characters before passing to jq. -# Usage: echo "$json" | prowl_jq '.data.text' -prowl_jq() { - python3 -c " -import sys, json, subprocess -d = json.JSONDecoder(strict=False).decode(sys.stdin.read()) -subprocess.run(['jq'] + sys.argv[1:], input=json.dumps(d, ensure_ascii=False), text=True) -" "$@" -} - wait_for_prowl_debug() { for attempt in 1 2 3 4 5 6 7 8 9 10; do [ -S "$socket" ] && break @@ -93,8 +81,8 @@ wait_for_prowl_debug() { # no-op CLI round trip before running scenario commands. test -n "$(debug_pids | head -1)" || return 1 health="$(prowl_debug list --json)" - test "$(echo "$health" | prowl_jq -r '.ok')" = "true" || { - echo "$health" | prowl_jq -r '.error.code? // "unknown"' + test "$(echo "$health" | jq -r '.ok')" = "true" || { + echo "$health" | jq -r '.error.code? // "unknown"' return 1 } } -- 2.51.2 From 3eaf7ab399228a24399e6da3e0cd81a5bc38f9d0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 10:45:11 +0900 Subject: [PATCH 431/528] Update self-verify launch guidance --- .claude/skills/self-verify-prowl/SKILL.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index c20c0b27..fdeebb95 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -40,32 +40,20 @@ Concretely: invoke the `prowl-cli` skill (or read its SKILL.md) before proceedin The debug app uses a fixed socket at `/tmp/prowl-self-verify.sock`. This path is stable across shell invocations, so every step can reference it directly without intermediate files or re-sourcing. -Clean up any stale socket from a previous run, then start the debug app: +Clean up any stale socket from a previous run, then start the debug app in a persistent shell session: ```bash rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app ``` -Keep that command running in its own shell session. If plain `make run-app` reports a socket ownership problem, relaunch with a custom `PROWL_CLI_SOCKET`; the installed app may already own the standard socket. +Keep that command running in its own shell or PTY session for the whole validation run. -**Agent mode** — CLI agents do not have a persistent foreground shell, so `make run-app` cannot block. After building, launch the binary directly in the background: +Do not launch the built `ProwlApp` binary directly in the background from an agent shell. Once that shell exits, the app can exit and leave a stale socket behind. -```bash -rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock -make build-app -app_bin="$(xcodebuild -project supacode.xcodeproj -scheme supacode \ - -configuration Debug -showBuildSettings -json 2>/dev/null \ - | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR + "/" + - .[0].buildSettings.FULL_PRODUCT_NAME + "/Contents/MacOS/" + - .[0].buildSettings.EXECUTABLE_NAME')" -PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock "$app_bin" & -disown -``` - -Then wait for the socket in the reusable setup block as usual. +If plain `make run-app` reports a socket ownership problem, relaunch with the custom `PROWL_CLI_SOCKET`; the installed app may already own the standard socket. -If the socket appears but `prowl_debug list --json` returns `APP_NOT_RUNNING`, the app likely exited and left a stale socket/lock behind. Remove both socket files, confirm no debug `ProwlApp` PID is still alive, then relaunch. In agent sessions where the direct binary exits immediately, use a PTY-backed `PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app` session instead. +If the socket appears but `prowl_debug list --json` returns `APP_NOT_RUNNING`, the app likely exited and left a stale socket/lock behind. Remove both socket files, confirm no debug `ProwlApp` PID is still alive, then relaunch with `PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app` in a persistent shell session. When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. -- 2.51.2 From 194c8ba54c5ae1aabce96fb42b6748bf8fcbe8d1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 10:54:13 +0900 Subject: [PATCH 432/528] Document self-verify log capture --- .claude/skills/self-verify-prowl/SKILL.md | 39 +++++++++++++++++++---- skills/prowl-cli/SKILL.md | 10 +++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index fdeebb95..9c2f481b 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -44,7 +44,8 @@ Clean up any stale socket from a previous run, then start the debug app in a per ```bash rm -f /tmp/prowl-self-verify.sock /tmp/prowl-self-verify.sock.lock -PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app +mkdir -p /tmp/prowl-self-verify +PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app >/tmp/prowl-self-verify/run-app.log 2>&1 ``` Keep that command running in its own shell or PTY session for the whole validation run. @@ -59,6 +60,28 @@ When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and e The debug app shares the installed app's `~/Library` data, so it loads the real repository list and looks identical to the installed window; never identify the debug instance by appearance. Target it by socket plus pane or tab UUID. +## Use The Run-App Log + +The `make run-app` log is the third verification surface, alongside `prowl read` and screenshots. Capture it to `/tmp/prowl-self-verify/run-app.log` as shown above instead of streaming the full output into the agent context. + +Before a scenario, record the current log line count. After the scenario, inspect only the new lines: + +```bash +log=/tmp/prowl-self-verify/run-app.log +before="$(wc -l <"$log" | tr -d ' ')" + +# Run prowl_debug open/tab/send/read operations here. + +sleep 3 +after="$(wc -l <"$log" | tr -d ' ')" +sed -n "$((before + 1)),${after}p" "$log" \ + | rg "CLIService|WindowLifecycle|Terminal\\]|SurfaceFocus|terminalEvent|tabCreated|tabClosed|taskStatusChanged|Shell|error|warning" +``` + +Use the log to corroborate app-side behavior: socket startup, window surfacing, terminal state creation, focus changes, tab creation/closure, task status changes, and warnings/errors. Logs can arrive a few seconds after the CLI command returns, so wait briefly and re-check before concluding an event is missing. + +Do not treat the log as the primary proof of terminal contents or CLI response shape. Use `prowl read` for terminal text, ordinary `jq` for CLI JSON, and screenshots for visual gaps. + ## Reusable Shell Setup Available script: @@ -81,7 +104,9 @@ wait_for_prowl_debug Use `prowl_debug ...` and `debug_window_id` from `scripts/helpers.sh` for the commands below. Keep `PROWL_CLI_SOCKET` explicit if you inline commands instead of using the helper. -Parse `--json` output with ordinary `jq`. If `jq` reports invalid control characters for CLI JSON, treat it as a CLI regression rather than working around it in this skill. +Parse `--json` output with ordinary `jq`. Do not pipe shell variables through `echo "$json" | jq`: zsh can interpret JSON escape sequences such as `\u001B` and turn them back into raw control characters. Use direct CLI pipes, files, or `printf '%s\n' "$json" | jq`. + +If `jq` reports invalid control characters while parsing direct CLI stdout or a captured file, treat it as a CLI regression rather than working around it in this skill. ## Drive With Prowl CLI @@ -96,10 +121,10 @@ Then seed the debug app and create a deterministic temporary pane: ```bash opened="$(prowl_debug open . --json)" -worktree="$(echo "$opened" | jq -r '.data.target.worktree.id')" +worktree="$(printf '%s\n' "$opened" | jq -r '.data.target.worktree.id')" created="$(prowl_debug tab create --worktree "$worktree" --json)" -pane="$(echo "$created" | jq -r '.data.target.pane.id')" -tab="$(echo "$created" | jq -r '.data.target.tab.id')" +pane="$(printf '%s\n' "$created" | jq -r '.data.target.pane.id')" +tab="$(printf '%s\n' "$created" | jq -r '.data.target.tab.id')" prowl_debug send --pane "$pane" 'printf "SELF_VERIFY:%s\n" "$PWD"' --capture --timeout 30 --json \ | jq -r '.data.capture.text' @@ -134,8 +159,8 @@ Example command scenario: result="$(prowl_debug send --pane "$pane" \ 'printf "SELF_VERIFY:%s\n" "$PWD"' \ --capture --timeout 30 --json)" -echo "$result" | jq -r '.data.capture.text' -echo "$result" | jq -r '.data.wait.exit_code' +printf '%s\n' "$result" | jq -r '.data.capture.text' +printf '%s\n' "$result" | jq -r '.data.wait.exit_code' prowl_debug read --pane "$pane" --last 80 --wait-stable --json \ | jq -r '.data.text' diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 7056f43f..9ae27020 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -104,19 +104,21 @@ prowl pane close --pane "$pane" --force --json Do not guess field names. Every `--json` response is `{ "ok", "command", "schema_version", "data": {...} }`, and the terminal text lives at `.data.text` — not `.content`, `.output`, or `.stdout`. Always parse with `jq` against the fields below; the authoritative, per-command field reference lives in `docs/components/cli.md`. +When JSON is stored in a shell variable, use `printf '%s\n' "$json" | jq ...`. Do not use `echo "$json" | jq`: zsh can interpret JSON escape sequences such as `\u001B` and turn them back into raw control characters. + ```bash # read: rendered terminal text is .data.text prowl read --pane "$pane" --last 80 --wait-stable --json | jq -r '.data.text' # send --capture: captured output is .data.capture.text; exit code is .data.wait.exit_code out="$(prowl send --pane "$pane" 'git status --short' --capture --timeout 30 --json)" -echo "$out" | jq -r '.data.capture.text' -echo "$out" | jq -r '.data.wait.exit_code' +printf '%s\n' "$out" | jq -r '.data.capture.text' +printf '%s\n' "$out" | jq -r '.data.wait.exit_code' # tab create / open: new pane and tab ids created="$(prowl tab create --worktree "$worktree" --json)" -echo "$created" | jq -r '.data.target.pane.id' -echo "$created" | jq -r '.data.target.tab.id' +printf '%s\n' "$created" | jq -r '.data.target.pane.id' +printf '%s\n' "$created" | jq -r '.data.target.tab.id' # list / agents: ids and status prowl list --json | jq -r '.data.items[].pane.id' -- 2.51.2 From b520c1b822a35d66e3bb2fc4423eca1a2dcb8aa5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 11:05:03 +0900 Subject: [PATCH 433/528] Generalize self-verify log inspection as agent-driven workflow --- .claude/skills/self-verify-prowl/SKILL.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 9c2f481b..03ec0660 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -64,7 +64,11 @@ The debug app shares the installed app's `~/Library` data, so it loads the real The `make run-app` log is the third verification surface, alongside `prowl read` and screenshots. Capture it to `/tmp/prowl-self-verify/run-app.log` as shown above instead of streaming the full output into the agent context. -Before a scenario, record the current log line count. After the scenario, inspect only the new lines: +This is especially useful when you add your own logging during development or debugging. The recommended workflow is: + +1. Add `SupaLogger` calls in the code you are changing to emit markers you can search for later. +2. Rebuild and relaunch the debug app so the new logs take effect. +3. Before running a scenario, record the current log line count. After the scenario, inspect only the new lines, filtering for the markers you added: ```bash log=/tmp/prowl-self-verify/run-app.log @@ -75,10 +79,12 @@ before="$(wc -l <"$log" | tr -d ' ')" sleep 3 after="$(wc -l <"$log" | tr -d ' ')" sed -n "$((before + 1)),${after}p" "$log" \ - | rg "CLIService|WindowLifecycle|Terminal\\]|SurfaceFocus|terminalEvent|tabCreated|tabClosed|taskStatusChanged|Shell|error|warning" + | rg "YourMarker|error|warning" ``` -Use the log to corroborate app-side behavior: socket startup, window surfacing, terminal state creation, focus changes, tab creation/closure, task status changes, and warnings/errors. Logs can arrive a few seconds after the CLI command returns, so wait briefly and re-check before concluding an event is missing. +Replace `YourMarker` with whatever log prefix or keyword you introduced in step 1. Do not hard-code a fixed set of log patterns — choose search terms that match the specific behavior you are verifying. + +Logs can arrive a few seconds after the CLI command returns, so wait briefly and re-check before concluding an event is missing. Do not treat the log as the primary proof of terminal contents or CLI response shape. Use `prowl read` for terminal text, ordinary `jq` for CLI JSON, and screenshots for visual gaps. -- 2.51.2 From 99b13d552d46a8687a496a561461c007e28055bd Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 16:13:18 +0900 Subject: [PATCH 434/528] Add configurable external diff tools --- docs/components/diff-view.md | 30 +++- docs/components/settings.md | 2 +- docs/reference/settings-fields.md | 2 + .../ExternalDiffSnapshotClient.swift | 125 +++++++++++++ .../ExternalDiff/ExternalDiffToolClient.swift | 150 ++++++++++++++++ supacode/Domain/ExternalDiffTool.swift | 146 ++++++++++++++++ .../Reducer/AppFeature+CommandPalette.swift | 27 +-- .../Features/App/Reducer/AppFeature.swift | 7 + .../Reducer/RepositoriesFeature.swift | 1 + .../Repositories/Views/WorktreeRowsView.swift | 7 +- .../Settings/Models/GlobalSettings.swift | 18 ++ .../Settings/Reducer/SettingsFeature.swift | 11 +- .../Views/AppearanceSettingsView.swift | 25 +++ .../AppFeatureCommandPaletteTests.swift | 45 +++++ supacodeTests/ExternalDiffToolTests.swift | 164 ++++++++++++++++++ 15 files changed, 735 insertions(+), 25 deletions(-) create mode 100644 supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift create mode 100644 supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift create mode 100644 supacode/Domain/ExternalDiffTool.swift create mode 100644 supacodeTests/ExternalDiffToolTests.swift diff --git a/docs/components/diff-view.md b/docs/components/diff-view.md index 758458f8..2ff836e5 100644 --- a/docs/components/diff-view.md +++ b/docs/components/diff-view.md @@ -9,15 +9,28 @@ ## What it is -The Diff window shows all changes in the selected worktree's working directory -compared against **HEAD** — exactly what an agent has modified. It's a fast way to -review before committing or merging. +The default Diff window shows all changes in the selected worktree's working +directory compared against **HEAD** — exactly what an agent has modified. It's a +fast way to review before committing or merging. -**Open:** `⌘⇧Y` (`show_diff`), or Command Palette → "Show Diff". The window is a -persistent singleton (remembers size/position) and auto-refreshes when it regains -focus. `⌘W` closes it. +**Open:** click a worktree's diff badge, press `⌘⇧Y` (`show_diff`), or use +Command Palette → "Show Diff". -## What it shows +By default Prowl opens its built-in YiTong-based diff window. In Settings → +General → Diff Tool, you can choose an external tool instead: + +- **Built-in** — opens Prowl's diff window. The window is a persistent singleton + (remembers size/position) and auto-refreshes when it regains focus. `⌘W` + closes it. +- **Hunk** — opens a new Prowl terminal tab and runs `hunk diff` in the worktree. +- **FileMerge** — creates HEAD/worktree snapshot folders and runs `opendiff`. +- **Kaleidoscope** — creates HEAD/worktree snapshot folders and runs + `ksdiff --diff`. +- **Custom Command** — creates HEAD/worktree snapshot folders and runs your + command in the worktree directory. Supported placeholders: + `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. + +## What the built-in window shows - A **file list** sidebar of changed files, each with a colored status badge: - **M** Modified (orange) · **A** Added/untracked (green) · **D** Deleted (red) · @@ -47,5 +60,8 @@ Diff is a **git-only** feature — it's unavailable for plain (non-git) folders. - The diff is **working-tree vs HEAD**, not vs the base branch — it reflects uncommitted changes in that worktree. +- External GUI tools receive snapshot folders so untracked files are included + without changing the git index. +- The Hunk integration runs in a terminal tab because Hunk is terminal-native. - For changes already in a pull request (vs the base branch, with CI), see [github-pull-requests](github-pull-requests.md). diff --git a/docs/components/settings.md b/docs/components/settings.md index abeb6d17..01e0c282 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -16,7 +16,7 @@ window is a sidebar of tabs plus a detail pane. | Tab | Controls | |-----|----------| -| **General** | Appearance (system/light/dark), default app for opening worktrees, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & tab titles. | +| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & tab titles. | | **Notifications** | In-app alerts, sound, macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index f9368b06..a91d7736 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -63,6 +63,8 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `showNotificationDotOnDock` | Bool | `false` | Numeric unread badge on the Dock icon. | | `shelfSpineTintFallback` | enum (`neutral`/`systemTint`) | `neutral` | Shelf spine color when a repo has no color. | | `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | +| `externalDiffToolID` | String | `built-in` | Tool used by diff badges and Show Diff: `built-in`, `hunk`, `filemerge`, `kaleidoscope`, or `custom`. | +| `externalDiffCustomCommand` | String | `""` | Command template for `externalDiffToolID = custom`; supports `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. | ## Per-repository settings (`RepositorySettings`) diff --git a/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift new file mode 100644 index 00000000..c07f4efa --- /dev/null +++ b/supacode/Clients/ExternalDiff/ExternalDiffSnapshotClient.swift @@ -0,0 +1,125 @@ +import ComposableArchitecture +import Foundation + +nonisolated struct ExternalDiffSnapshotPair: Equatable, Sendable { + let leftURL: URL + let rightURL: URL +} + +nonisolated struct ExternalDiffSnapshotClient: Sendable { + var makeSnapshotPair: @Sendable (Worktree) async throws -> ExternalDiffSnapshotPair +} + +extension ExternalDiffSnapshotClient: DependencyKey { + static let liveValue = ExternalDiffSnapshotClient { worktree in + try await ExternalDiffSnapshotBuilder().makeSnapshotPair(for: worktree) + } + + static let testValue = ExternalDiffSnapshotClient { _ in + ExternalDiffSnapshotPair( + leftURL: URL(fileURLWithPath: "/tmp/prowl-diff-left"), + rightURL: URL(fileURLWithPath: "/tmp/prowl-diff-right") + ) + } +} + +extension DependencyValues { + var externalDiffSnapshotClient: ExternalDiffSnapshotClient { + get { self[ExternalDiffSnapshotClient.self] } + set { self[ExternalDiffSnapshotClient.self] = newValue } + } +} + +private nonisolated struct ExternalDiffSnapshotBuilder { + func makeSnapshotPair(for worktree: Worktree) async throws -> ExternalDiffSnapshotPair { + let gitClient = GitClient() + async let trackedOutput = gitClient.diffNameStatus(at: worktree.workingDirectory) + async let untrackedPaths = gitClient.untrackedFilePaths(at: worktree.workingDirectory) + let trackedFiles = DiffChangedFile.parseNameStatus(await trackedOutput) + let untrackedFiles = await untrackedPaths.map { + DiffChangedFile(status: .added, oldPath: nil, newPath: $0) + } + let files = trackedFiles + untrackedFiles + + return try await Task.detached { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "ProwlDiffSnapshots") + .appending(path: UUID().uuidString) + let leftURL = rootURL.appending(path: "HEAD") + let rightURL = rootURL.appending(path: "Worktree") + try FileManager.default.createDirectory(at: leftURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: rightURL, withIntermediateDirectories: true) + + for file in files { + try copySnapshotFile(file, from: worktree.workingDirectory, leftURL: leftURL, rightURL: rightURL) + } + + return ExternalDiffSnapshotPair(leftURL: leftURL, rightURL: rightURL) + }.value + } + + private func copySnapshotFile( + _ file: DiffChangedFile, + from worktreeURL: URL, + leftURL: URL, + rightURL: URL + ) throws { + if let oldPath = file.oldPath { + try writeHeadFile(oldPath, worktreeURL: worktreeURL, destinationRoot: leftURL) + } + if let newPath = file.newPath { + let sourceURL = worktreeURL.appending(path: newPath) + guard FileManager.default.fileExists(atPath: sourceURL.path(percentEncoded: false)) else { + return + } + let destinationURL = rightURL.appending(path: newPath) + try createParentDirectory(for: destinationURL) + if FileManager.default.fileExists(atPath: destinationURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + } + + private func writeHeadFile( + _ relativePath: String, + worktreeURL: URL, + destinationRoot: URL + ) throws { + let destinationURL = destinationRoot.appending(path: relativePath) + try createParentDirectory(for: destinationURL) + if FileManager.default.fileExists(atPath: destinationURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: destinationURL) + } + FileManager.default.createFile(atPath: destinationURL.path(percentEncoded: false), contents: nil) + let outputHandle = try FileHandle(forWritingTo: destinationURL) + defer { try? outputHandle.close() } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = [ + "-C", + worktreeURL.path(percentEncoded: false), + "show", + "HEAD:\(relativePath)", + ] + process.standardOutput = outputHandle + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw ExternalDiffSnapshotError.gitShowFailed(relativePath) + } + } + + private func createParentDirectory(for fileURL: URL) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } +} + +private nonisolated enum ExternalDiffSnapshotError: Error { + case gitShowFailed(String) +} diff --git a/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift new file mode 100644 index 00000000..933f697e --- /dev/null +++ b/supacode/Clients/ExternalDiff/ExternalDiffToolClient.swift @@ -0,0 +1,150 @@ +import ComposableArchitecture +import Foundation + +nonisolated struct ExternalDiffToolClient: Sendable { + var open: + @MainActor @Sendable ( + _ settings: ExternalDiffSettings, + _ worktree: Worktree, + _ resolvedKeybindings: ResolvedKeybindingMap, + _ onError: @escaping @MainActor @Sendable (OpenActionError) -> Void + ) async -> Void +} + +extension ExternalDiffToolClient: DependencyKey { + static let liveValue = ExternalDiffToolClient { settings, worktree, resolvedKeybindings, onError in + @Dependency(TerminalClient.self) var terminalClient + @Dependency(ShellClient.self) var shellClient + @Dependency(ExternalDiffSnapshotClient.self) var snapshotClient + + switch settings.tool { + case .builtIn: + DiffWindowManager.shared.show( + worktreeURL: worktree.workingDirectory, + branchName: worktree.name, + resolvedKeybindings: resolvedKeybindings + ) + + case .hunk: + await terminalClient.send( + .createTabWithInput( + worktree, + input: "hunk diff", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Hunk Diff", + customCommandIcon: "square.split.2x1" + ) + ) + + case .fileMerge: + await runGUICommand( + ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "opendiff", arguments: []), + worktree: worktree, + shellClient: shellClient, + snapshotClient: snapshotClient, + onError: onError + ) + + case .kaleidoscope: + await runGUICommand( + ExternalDiffGUICommandRequest(tool: settings.tool, executableName: "ksdiff", arguments: ["--diff"]), + worktree: worktree, + shellClient: shellClient, + snapshotClient: snapshotClient, + onError: onError + ) + + case .custom: + await runCustomCommand( + settings: settings, + worktree: worktree, + shellClient: shellClient, + snapshotClient: snapshotClient, + onError: onError + ) + } + } + + static let testValue = ExternalDiffToolClient { _, _, _, _ in } +} + +extension DependencyValues { + var externalDiffToolClient: ExternalDiffToolClient { + get { self[ExternalDiffToolClient.self] } + set { self[ExternalDiffToolClient.self] = newValue } + } +} + +private struct ExternalDiffGUICommandRequest { + let tool: ExternalDiffTool + let executableName: String + let arguments: [String] +} + +private func runGUICommand( + _ request: ExternalDiffGUICommandRequest, + worktree: Worktree, + shellClient: ShellClient, + snapshotClient: ExternalDiffSnapshotClient, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void +) async { + do { + let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let executableURL = URL(fileURLWithPath: "/usr/bin/env") + _ = try await shellClient.runLogin( + executableURL, + [request.executableName] + request.arguments + [ + snapshot.leftURL.path(percentEncoded: false), + snapshot.rightURL.path(percentEncoded: false), + ], + worktree.workingDirectory + ) + } catch { + onError(openError(for: request.tool, error: error)) + } +} + +private func runCustomCommand( + settings: ExternalDiffSettings, + worktree: Worktree, + shellClient: ShellClient, + snapshotClient: ExternalDiffSnapshotClient, + onError: @escaping @MainActor @Sendable (OpenActionError) -> Void +) async { + let template = settings.customCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !template.isEmpty else { + onError( + OpenActionError( + title: "Custom diff command is empty", + message: "Add a custom diff command in Settings before opening the diff." + ) + ) + return + } + do { + let snapshot = try await snapshotClient.makeSnapshotPair(worktree) + let context = ExternalDiffCommandContext( + worktreePath: worktree.workingDirectory.path(percentEncoded: false), + repoPath: worktree.repositoryRootURL.path(percentEncoded: false), + branch: worktree.name, + leftPath: snapshot.leftURL.path(percentEncoded: false), + rightPath: snapshot.rightURL.path(percentEncoded: false) + ) + let command = ExternalDiffCommandTemplate.render(template, context: context) + _ = try await shellClient.runLogin( + URL(fileURLWithPath: "/bin/zsh"), + ["-lc", command], + worktree.workingDirectory + ) + } catch { + onError(openError(for: settings.tool, error: error)) + } +} + +private func openError(for tool: ExternalDiffTool, error: Error) -> OpenActionError { + OpenActionError( + title: "Unable to open diff in \(tool.title)", + message: error.localizedDescription + ) +} diff --git a/supacode/Domain/ExternalDiffTool.swift b/supacode/Domain/ExternalDiffTool.swift new file mode 100644 index 00000000..e0ca5d0b --- /dev/null +++ b/supacode/Domain/ExternalDiffTool.swift @@ -0,0 +1,146 @@ +import Foundation + +nonisolated enum ExternalDiffLaunchMode: Equatable, Sendable { + case builtIn + case terminal + case gui +} + +nonisolated enum ExternalDiffTool: String, CaseIterable, Identifiable, Codable, Sendable { + case builtIn = "built-in" + case hunk + case fileMerge = "filemerge" + case kaleidoscope + case custom + + var id: String { settingsID } + + var settingsID: String { rawValue } + + var title: String { + switch self { + case .builtIn: "Built-in" + case .hunk: "Hunk" + case .fileMerge: "FileMerge" + case .kaleidoscope: "Kaleidoscope" + case .custom: "Custom Command" + } + } + + var launchMode: ExternalDiffLaunchMode { + switch self { + case .builtIn: + .builtIn + case .hunk: + .terminal + case .fileMerge, .kaleidoscope, .custom: + .gui + } + } + + var defaultCommandName: String? { + switch self { + case .fileMerge: + "opendiff" + case .kaleidoscope: + "ksdiff" + case .hunk: + "hunk" + case .builtIn, .custom: + nil + } + } + + var isInstalled: Bool { + switch self { + case .builtIn, .custom: + true + case .fileMerge: + ExternalDiffToolPathResolver.executableExists("opendiff") + case .kaleidoscope: + ExternalDiffToolPathResolver.executableExists("ksdiff") + case .hunk: + ExternalDiffToolPathResolver.executableExists("hunk") + } + } + + static var menuOrder: [ExternalDiffTool] { + [.builtIn, .hunk, .fileMerge, .kaleidoscope, .custom] + } + + static var availableCases: [ExternalDiffTool] { + menuOrder.filter(\.isInstalled) + } + + static func fromSettingsID(_ settingsID: String?) -> ExternalDiffTool { + guard let settingsID, + let tool = Self(rawValue: settingsID), + tool.isInstalled + else { + return .builtIn + } + return tool + } + + static func normalizedSettingsID(_ settingsID: String?) -> String { + fromSettingsID(settingsID).settingsID + } +} + +nonisolated struct ExternalDiffSettings: Equatable, Sendable { + var toolID: String + var customCommand: String + + var tool: ExternalDiffTool { + ExternalDiffTool.fromSettingsID(toolID) + } +} + +nonisolated struct ExternalDiffCommandContext: Equatable, Sendable { + let worktreePath: String + let repoPath: String + let branch: String + let leftPath: String + let rightPath: String +} + +nonisolated enum ExternalDiffCommandTemplate { + static func render(_ template: String, context: ExternalDiffCommandContext) -> String { + let replacements = [ + "{worktreePath}": shellQuoted(context.worktreePath), + "{repoPath}": shellQuoted(context.repoPath), + "{branch}": shellQuoted(context.branch), + "{leftPath}": shellQuoted(context.leftPath), + "{rightPath}": shellQuoted(context.rightPath), + ] + return replacements.reduce(template) { partial, replacement in + partial.replacing(replacement.key, with: replacement.value) + } + } + + static func shellQuoted(_ value: String) -> String { + "'\(value.replacing("'", with: "'\"'\"'"))'" + } +} + +nonisolated enum ExternalDiffToolPathResolver { + static func executableExists(_ name: String) -> Bool { + executableURL(named: name) != nil + } + + static func executableURL(named name: String) -> URL? { + for directory in executableSearchPaths { + let url = URL(fileURLWithPath: directory).appending(path: name) + if FileManager.default.isExecutableFile(atPath: url.path(percentEncoded: false)) { + return url + } + } + return nil + } + + private static var executableSearchPaths: [String] { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + let environmentPaths = path.split(separator: ":").map(String.init) + return environmentPaths + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + } +} diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index c7fc339a..ac0a10bf 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -147,16 +147,7 @@ extension AppFeature { else { return .none } - let keybindings = state.resolvedKeybindings - return .run { _ in - await MainActor.run { - DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - resolvedKeybindings: keybindings - ) - } - } + return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) case .revealInFinder: return .send(.openWorktree(.finder)) @@ -185,6 +176,22 @@ extension AppFeature { } } + func openDiffEffect( + worktree: Worktree, + resolvedKeybindings: ResolvedKeybindingMap + ) -> Effect { + @Shared(.settingsFile) var settingsFile + let settings = ExternalDiffSettings( + toolID: settingsFile.global.externalDiffToolID, + customCommand: settingsFile.global.externalDiffCustomCommand + ) + return .run { send in + await externalDiffToolClient.open(settings, worktree, resolvedKeybindings) { error in + send(.openWorktreeFailed(error)) + } + } + } + func reduceCommandPaletteWorktreeActionDelegate( _ delegate: CommandPaletteFeature.Delegate, state: inout State diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index d19b4c2f..463b34d1 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -102,6 +102,7 @@ struct AppFeature { @Dependency(TerminalClient.self) var terminalClient @Dependency(WorktreeInfoWatcherClient.self) var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) var customShortcutRegistryClient + @Dependency(ExternalDiffToolClient.self) var externalDiffToolClient var body: some Reducer { let core = Reduce { state, action in @@ -346,6 +347,12 @@ struct AppFeature { } ) + case .repositories(.delegate(.showDiff(let worktreeID))): + guard let worktree = state.repositories.worktree(for: worktreeID) else { + return .none + } + return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + case .settings(.setSelection(let selection)): let resolvedSelection = selection ?? .general switch resolvedSelection { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 5bbeac2f..cc6f3eab 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -434,6 +434,7 @@ struct RepositoriesFeature { case selectedWorktreeChanged(Worktree?) case repositoriesChanged(IdentifiedArrayOf) case openRepositorySettings(Repository.ID) + case showDiff(Worktree.ID) case worktreeCreated(Worktree) } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index fda93b2a..652e0672 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -240,12 +240,7 @@ struct WorktreeRowsView: View { private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { { - guard let worktree = store.state.worktree(for: worktreeID) else { return } - DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - resolvedKeybindings: resolvedKeybindings - ) + store.send(.delegate(.showDiff(worktreeID))) } } diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index 820e3d81..bb304832 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -39,6 +39,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var showNotificationDotOnDock: Bool var shelfSpineTintFallback: ShelfSpineTintFallback var shelfSpineTintFollowsRepositoryColor: Bool + var externalDiffToolID: String = ExternalDiffTool.builtIn.settingsID + var externalDiffCustomCommand: String = "" static let `default` = GlobalSettings( appearanceMode: .dark, @@ -209,6 +211,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(showNotificationDotOnDock, forKey: .showNotificationDotOnDock) try container.encode(shelfSpineTintFallback, forKey: .shelfSpineTintFallback) try container.encode(shelfSpineTintFollowsRepositoryColor, forKey: .shelfSpineTintFollowsRepositoryColor) + try container.encode(externalDiffToolID, forKey: .externalDiffToolID) + try container.encode(externalDiffCustomCommand, forKey: .externalDiffCustomCommand) } private enum CodingKeys: String, CodingKey { @@ -252,6 +256,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case showNotificationDotOnDock case shelfSpineTintFallback case shelfSpineTintFollowsRepositoryColor + case externalDiffToolID + case externalDiffCustomCommand // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -350,6 +356,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { ?? Self.default.showActiveAgentStatusInShelf (windowTintMode, windowTintCustomColor) = try Self.decodeWindowTint(from: container) (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = try Self.decodeShelfSpineTint(from: container) + (externalDiffToolID, externalDiffCustomCommand) = try Self.decodeExternalDiffSettings(from: container) let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) showRunButtonInToolbar = toolbarAndDock.showRunButtonInToolbar showDefaultEditorInToolbar = toolbarAndDock.showDefaultEditorInToolbar @@ -381,6 +388,17 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { return (fallback, followsRepositoryColor) } + private static func decodeExternalDiffSettings( + from container: KeyedDecodingContainer + ) throws -> (String, String) { + let toolID = + ExternalDiffTool.normalizedSettingsID(try container.decodeIfPresent(String.self, forKey: .externalDiffToolID)) + let customCommand = + try container.decodeIfPresent(String.self, forKey: .externalDiffCustomCommand) + ?? Self.default.externalDiffCustomCommand + return (toolID, customCommand) + } + private static func decodeMergedWorktreeAction( from container: KeyedDecodingContainer ) throws -> MergedWorktreeAction? { diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index ad7e291f..47df67f0 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -49,6 +49,8 @@ struct SettingsFeature { var showDefaultEditorInToolbar: Bool var dockBounceMode: DockBounceMode var showNotificationDotOnDock: Bool + var externalDiffToolID: String + var externalDiffCustomCommand: String var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true /// Whether macOS will render the Dock notification badge (notification @@ -102,10 +104,12 @@ struct SettingsFeature { showDefaultEditorInToolbar = settings.showDefaultEditorInToolbar dockBounceMode = settings.dockBounceMode showNotificationDotOnDock = settings.showNotificationDotOnDock + externalDiffToolID = settings.externalDiffToolID + externalDiffCustomCommand = settings.externalDiffCustomCommand } var globalSettings: GlobalSettings { - GlobalSettings( + var settings = GlobalSettings( appearanceMode: appearanceMode, defaultEditorID: defaultEditorID, confirmBeforeQuit: confirmBeforeQuit, @@ -149,6 +153,9 @@ struct SettingsFeature { shelfSpineTintFallback: shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor: shelfSpineTintFollowsRepositoryColor ) + settings.externalDiffToolID = externalDiffToolID + settings.externalDiffCustomCommand = externalDiffCustomCommand + return settings } } @@ -262,6 +269,8 @@ struct SettingsFeature { state.showDefaultEditorInToolbar = normalizedSettings.showDefaultEditorInToolbar state.dockBounceMode = normalizedSettings.dockBounceMode state.showNotificationDotOnDock = normalizedSettings.showNotificationDotOnDock + state.externalDiffToolID = normalizedSettings.externalDiffToolID + state.externalDiffCustomCommand = normalizedSettings.externalDiffCustomCommand state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index d49d127b..dab828bc 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -6,6 +6,7 @@ struct AppearanceSettingsView: View { var body: some View { let openActionOptions = OpenWorktreeAction.availableCases + let externalDiffToolOptions = ExternalDiffTool.availableCases VStack(alignment: .leading) { Form { Section("Appearance") { @@ -137,6 +138,30 @@ struct AppearanceSettingsView: View { + "Automatic prefers an app matching the project type, e.g. Xcode for Swift projects." ) } + Section("Diff Tool") { + Picker( + "Open diff with", + selection: $store.externalDiffToolID + ) { + ForEach(externalDiffToolOptions) { tool in + Text(tool.title) + .tag(tool.settingsID) + } + } + .help("Choose what opens when you click a diff badge or run Show Diff.") + if store.externalDiffToolID == ExternalDiffTool.custom.settingsID { + TextField( + "Command", + text: $store.externalDiffCustomCommand, + prompt: Text("my-diff {leftPath} {rightPath}") + ) + .textFieldStyle(.roundedBorder) + .help( + "Runs in the worktree directory. Supports {leftPath}, {rightPath}, " + + "{worktreePath}, {repoPath}, and {branch}." + ) + } + } Section("Run") { Toggle( "Show in toolbar", diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index bb10cd56..59fadc2c 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -797,6 +797,51 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.runCustomCommand) } + @Test(.dependencies) func showDiffDelegateUsesConfiguredExternalDiffTool() async { + let worktree = makeWorktree( + id: "/tmp/repo-diff/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-diff" + ) + let repository = makeRepository(id: "/tmp/repo-diff", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + var settings = GlobalSettings.default + settings.externalDiffToolID = ExternalDiffTool.hunk.settingsID + let storage = SettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.terminalClient.send = { _ in } + $0.externalDiffToolClient.open = { settings, worktree, _, _ in + launched.withValue { $0.append((settings, worktree)) } + } + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + return TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State(settings: settings) + ) + ) { + AppFeature() + } + } + + await store.send(.commandPalette(.delegate(.showDiff))) + await store.finish() + + #expect( + launched.value.map(\.0) == [ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: "")]) + #expect(launched.value.map(\.1) == [worktree]) + } + @Test(.dependencies) func closePullRequestDispatchesAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift new file mode 100644 index 00000000..71bb0049 --- /dev/null +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -0,0 +1,164 @@ +import Dependencies +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct ExternalDiffToolTests { + @Test func defaultSettingsUseBuiltInDiffTool() { + #expect(GlobalSettings.default.externalDiffToolID == ExternalDiffTool.builtIn.settingsID) + #expect(GlobalSettings.default.externalDiffCustomCommand == "") + } + + @Test func launchModesMatchSupportedTools() { + #expect(ExternalDiffTool.builtIn.launchMode == .builtIn) + #expect(ExternalDiffTool.hunk.launchMode == .terminal) + #expect(ExternalDiffTool.fileMerge.launchMode == .gui) + #expect(ExternalDiffTool.kaleidoscope.launchMode == .gui) + #expect(ExternalDiffTool.custom.launchMode == .gui) + } + + @Test func commandTemplateReplacesPlaceholdersWithShellQuotedValues() { + let context = ExternalDiffCommandContext( + worktreePath: "/tmp/My Repo/work tree", + repoPath: "/tmp/My Repo", + branch: "feature/quote's", + leftPath: "/tmp/left snapshot", + rightPath: "/tmp/right snapshot" + ) + + let rendered = ExternalDiffCommandTemplate.render( + "tool {leftPath} {rightPath} --repo {repoPath} --branch {branch} --worktree {worktreePath}", + context: context + ) + + let expected = + "tool '/tmp/left snapshot' '/tmp/right snapshot' --repo '/tmp/My Repo' " + + "--branch 'feature/quote'\"'\"'s' --worktree '/tmp/My Repo/work tree'" + #expect(rendered == expected) + } + + @Test func hunkLaunchesInTerminalTab() async { + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let worktree = Worktree( + id: "/tmp/repo", + name: "feature", + detail: "feature", + workingDirectory: URL(fileURLWithPath: "/tmp/repo"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + + await withDependencies { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } operation: { + await ExternalDiffToolClient.liveValue.open( + ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: ""), + worktree, + .appDefaults + ) { _ in } + } + + #expect( + sentCommands.value == [ + .createTabWithInput( + worktree, + input: "hunk diff", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Hunk Diff", + customCommandIcon: "square.split.2x1" + ) + ] + ) + } + + @Test func customCommandRunsRenderedShellCommandInWorktree() async throws { + let runs = LockIsolated<[ShellRun]>([]) + let worktree = Worktree( + id: "/tmp/repo", + name: "feature", + detail: "feature", + workingDirectory: URL(fileURLWithPath: "/tmp/repo"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/root") + ) + let snapshot = ExternalDiffSnapshotPair( + leftURL: URL(fileURLWithPath: "/tmp/left"), + rightURL: URL(fileURLWithPath: "/tmp/right") + ) + + await withDependencies { + $0.externalDiffSnapshotClient.makeSnapshotPair = { _ in snapshot } + $0.shellClient.runLoginImpl = { executable, arguments, cwd, _ in + runs.withValue { + $0.append(ShellRun(executable: executable, arguments: arguments, currentDirectoryURL: cwd)) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + } operation: { + await ExternalDiffToolClient.liveValue.open( + ExternalDiffSettings( + toolID: ExternalDiffTool.custom.settingsID, + customCommand: "my-diff {leftPath} {rightPath} --repo {repoPath}" + ), + worktree, + .appDefaults + ) { _ in } + } + + let run = try #require(runs.value.first) + #expect(run.executable.path(percentEncoded: false) == "/bin/zsh") + #expect(run.arguments == ["-lc", "my-diff '/tmp/left' '/tmp/right' --repo '/tmp/root'"]) + #expect(run.currentDirectoryURL == worktree.workingDirectory) + } + + @Test func snapshotPairIncludesModifiedAndUntrackedFiles() async throws { + let repoURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-external-diff-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repoURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repoURL) } + try runGit(["init"], in: repoURL) + try runGit(["config", "user.email", "test@example.com"], in: repoURL) + try runGit(["config", "user.name", "Test User"], in: repoURL) + try "one\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) + try runGit(["add", "tracked.txt"], in: repoURL) + try runGit(["commit", "-m", "Initial"], in: repoURL) + try "two\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) + try "new\n".write(to: repoURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8) + + let worktree = Worktree( + id: repoURL.path(percentEncoded: false), + name: "main", + detail: "main", + workingDirectory: repoURL, + repositoryRootURL: repoURL + ) + + let snapshot = try await ExternalDiffSnapshotClient.liveValue.makeSnapshotPair(worktree) + + #expect(try String(contentsOf: snapshot.leftURL.appending(path: "tracked.txt"), encoding: .utf8) == "one\n") + #expect(try String(contentsOf: snapshot.rightURL.appending(path: "tracked.txt"), encoding: .utf8) == "two\n") + #expect(!FileManager.default.fileExists(atPath: snapshot.leftURL.appending(path: "untracked.txt").path())) + #expect(try String(contentsOf: snapshot.rightURL.appending(path: "untracked.txt"), encoding: .utf8) == "new\n") + } +} + +private struct ShellRun: Equatable { + let executable: URL + let arguments: [String] + let currentDirectoryURL: URL? +} + +private func runGit(_ arguments: [String], in directory: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + process.standardOutput = Pipe() + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) +} -- 2.51.2 From a415d35eca5f7b0d2b96ac2090e74d254336b9c2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 16:13:57 +0900 Subject: [PATCH 435/528] Add prebuilt GhosttyKit artifact downloader --- .github/actions/setup-macos/action.yml | 6 +- Makefile | 45 +++-- doc-onevcat/fork-sync-ghostty.md | 36 ++++ ...6-14-ghosttykit-prebuilt-artifacts-plan.md | 175 ++++++++++++++++ scripts/ensure-ghosttykit-artifacts.sh | 187 ++++++++++++++++++ scripts/ghosttykit-checksums.txt | 4 + scripts/package-ghosttykit-artifacts.sh | 68 +++++++ scripts/validate-ghosttykit-artifacts.py | 70 +++++++ 8 files changed, 565 insertions(+), 26 deletions(-) create mode 100644 doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md create mode 100755 scripts/ensure-ghosttykit-artifacts.sh create mode 100644 scripts/ghosttykit-checksums.txt create mode 100755 scripts/package-ghosttykit-artifacts.sh create mode 100755 scripts/validate-ghosttykit-artifacts.py diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index b2fe6ada..765fa39a 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -22,7 +22,7 @@ runs: shell: bash run: | set -euo pipefail - GHOSTTY_SHA="$(git -C ThirdParty/ghostty rev-parse HEAD)" + GHOSTTY_SHA="$(git rev-parse HEAD:ThirdParty/ghostty)" printf '%s\n' "GHOSTTY_SHA=$GHOSTTY_SHA" >> "$GITHUB_ENV" - name: Ghostty cache id: ghostty_cache @@ -35,12 +35,12 @@ runs: .ghostty_hash .ghostty_build_stamp key: ${{ runner.os }}-${{ runner.arch }}-ghostty-v1-${{ env.GHOSTTY_SHA }} - - name: Build ghostty + - name: Ensure ghostty if: steps.ghostty_cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail - make build-ghostty-xcframework + make ensure-ghostty - name: Sync ghostty marker files shell: bash run: | diff --git a/Makefile b/Makefile index 08bcda32..a2d64589 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _check-ghostty-hash _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -41,6 +41,7 @@ build-ghostty-xcframework: $(GHOSTTY_BUILD_STAMP) # Build ghostty framework # Internal: actually rebuild ghostty. $(GHOSTTY_BUILD_STAMP): + git submodule update --init --recursive ThirdParty/ghostty @cd $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty && mise exec -- zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false rsync -a ThirdParty/ghostty/macos/GhosttyKit.xcframework Frameworks @src="$(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty/zig-out/share/ghostty"; \ @@ -53,36 +54,34 @@ $(GHOSTTY_BUILD_STAMP): rsync -a --delete "$$terminfo_src/" "$$terminfo_dst/" touch "$(GHOSTTY_BUILD_STAMP)" -# Public entry point: only rebuilds ghostty if submodule SHA changed (or outputs are missing). -ensure-ghostty: _check-ghostty-hash # Ensure GhosttyKit is up-to-date (fast path when unchanged) - -# Internal: compare current submodule SHA against the recorded one. -_check-ghostty-hash: - @current_sha="$$(git -C $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty rev-parse HEAD)"; \ +# Public entry point: downloads pinned prebuilt artifacts when available, then +# falls back to a local Ghostty source build. +ensure-ghostty: # Ensure GhosttyKit is up-to-date (fast path when unchanged) + @set +e; \ + "$(CURRENT_MAKEFILE_DIR)/scripts/ensure-ghosttykit-artifacts.sh"; \ + status="$$?"; \ + set -e; \ + if [ "$$status" -eq 0 ]; then \ + exit 0; \ + fi; \ + if [ "$$status" -ne 2 ]; then \ + exit "$$status"; \ + fi; \ + current_sha="$$(git -C "$(CURRENT_MAKEFILE_DIR)" rev-parse HEAD:ThirdParty/ghostty)"; \ last_sha=""; \ if [ -f "$(GHOSTTY_HASH_FILE)" ]; then \ last_sha="$$(cat "$(GHOSTTY_HASH_FILE)")"; \ fi; \ - artifacts_ok=1; \ - for path in "$(GHOSTTY_XCFRAMEWORK_PATH)" "$(GHOSTTY_RESOURCE_PATH)" "$(GHOSTTY_TERMINFO_PATH)"; do \ - if [ ! -e "$$path" ]; then \ - artifacts_ok=0; \ - fi; \ - done; \ - if [ "$$current_sha" != "$$last_sha" ] || [ "$$artifacts_ok" -ne 1 ]; then \ - echo "Syncing GhosttyKit for submodule $$current_sha"; \ - $(MAKE) -B build-ghostty-xcframework || exit $$?; \ - if [ "$$current_sha" != "$$last_sha" ]; then \ - rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-*; \ - echo "Cleared Xcode DerivedData for ghostty header/module changes"; \ - fi; \ - else \ - echo "GhosttyKit up-to-date (SHA unchanged)"; \ + echo "Building GhosttyKit locally for $$current_sha"; \ + $(MAKE) -B build-ghostty-xcframework; \ + if [ "$$current_sha" != "$$last_sha" ]; then \ + rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-*; \ + echo "Cleared Xcode DerivedData for ghostty header/module changes"; \ fi # Internal: record the current submodule SHA after a successful build. _record-ghostty-hash: - @git -C $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty rev-parse HEAD > "$(GHOSTTY_HASH_FILE)" + @git -C "$(CURRENT_MAKEFILE_DIR)" rev-parse HEAD:ThirdParty/ghostty > "$(GHOSTTY_HASH_FILE)" # Force a clean rebuild of GhosttyKit (ignores cached SHA, useful after submodule updates). sync-ghostty: # Force sync GhosttyKit to current submodule HEAD (always rebuilds) diff --git a/doc-onevcat/fork-sync-ghostty.md b/doc-onevcat/fork-sync-ghostty.md index 99a46742..d66a83a7 100644 --- a/doc-onevcat/fork-sync-ghostty.md +++ b/doc-onevcat/fork-sync-ghostty.md @@ -57,6 +57,42 @@ make sync-ghostty make build-app ``` +## Prebuilt Artifact Publishing + +Prowl's default `make ensure-ghostty` path downloads pinned prebuilt artifacts +from `onevcat/ghostty` before falling back to a local source build. + +Release tag format: + +```text +xcframework--prowl-v1 +``` + +Assets: + +```text +GhosttyKit.xcframework.tar.gz +GhosttyKit-resources.tar.gz +``` + +After a local `make sync-ghostty`, package the current artifacts with: + +```bash +scripts/package-ghosttykit-artifacts.sh +``` + +Upload both generated assets to the matching `onevcat/ghostty` release, then add +the emitted manifest line to `scripts/ghosttykit-checksums.txt` in Prowl. The +manifest is reviewed source of truth for downloaded artifact integrity. + +Verify the prebuilt path from a clean artifact state: + +```bash +rm -rf Frameworks/GhosttyKit.xcframework Resources/ghostty Resources/terminfo .ghostty_hash .ghostty_build_stamp +make ensure-ghostty +make build-app +``` + ## Force Push Policy Do not force-push `release/v*-patched` branches. If a cherry-pick needs repair, use a temporary fix branch, validate it, then fast-forward the patched branch. diff --git a/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md b/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md new file mode 100644 index 00000000..7d178a7a --- /dev/null +++ b/doc-onevcat/plans/2026-06-14-ghosttykit-prebuilt-artifacts-plan.md @@ -0,0 +1,175 @@ +# GhosttyKit Prebuilt Artifact Plan + +## Goal + +Make prebuilt GhosttyKit artifacts the default acquisition path for Prowl, while +keeping local Ghostty source builds available for fork maintenance and emergency +fallbacks. + +This is a long-term integration decision for Prowl: + +- Prowl pins `ThirdParty/ghostty` to the `onevcat/ghostty` fork. +- Each pinned Ghostty commit may have a matching GitHub Release artifact. +- Normal Prowl builds download and verify that artifact instead of compiling + Ghostty from Zig source. +- Ghostty source builds remain explicit maintenance operations. + +We are intentionally not moving GhosttyKit to a SwiftPM binary target for now. +Prowl's app target currently links `Frameworks/GhosttyKit.xcframework` directly +from the Xcode project and separately bundles `Resources/ghostty` and +`Resources/terminfo`. A Makefile downloader matches that shape with less churn. + +## Current State + +Prowl currently: + +1. Pins `ThirdParty/ghostty` as a submodule to `onevcat/ghostty`. +2. Runs `zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false` + from the Ghostty submodule. +3. Copies `ThirdParty/ghostty/macos/GhosttyKit.xcframework` to `Frameworks/`. +4. Copies Ghostty runtime resources from `zig-out/share/ghostty` and + `zig-out/share/terminfo` to `Resources/`. +5. Tracks `.ghostty_hash` and `.ghostty_build_stamp` locally to skip unchanged + rebuilds. + +The expensive part is step 2. Cold worktrees also frequently lack the generated +framework/resources, so `make build-app` currently triggers a full Ghostty build. + +## Artifact Model + +Publish artifacts from `onevcat/ghostty` GitHub Releases. + +Release tag format: + +```text +xcframework--prowl-v1 +``` + +Assets: + +```text +GhosttyKit.xcframework.tar.gz +GhosttyKit-resources.tar.gz +``` + +`GhosttyKit-resources.tar.gz` contains exactly: + +```text +ghostty/ +terminfo/ +``` + +The Prowl repository stores a reviewed checksum manifest: + +```text +scripts/ghosttykit-checksums.txt +``` + +Each non-comment line uses: + +```text + +``` + +The commit SHA is the gitlink recorded in Prowl, not "whatever the submodule +working tree currently reports". This allows cold worktrees to download +artifacts before the heavy Ghostty submodule is initialized. + +## Build Flow + +`make ensure-ghostty`: + +1. Read the pinned Ghostty gitlink with: + + ```bash + git rev-parse HEAD:ThirdParty/ghostty + ``` + +2. If `Frameworks/GhosttyKit.xcframework`, `Resources/ghostty`, and + `Resources/terminfo` already exist and `.ghostty_hash` matches, do nothing. +3. If a checksum entry exists, download both release assets for the pinned SHA. +4. Verify SHA256 for both assets. +5. Validate archive shape before extraction. +6. Extract into `Frameworks/` and `Resources/`. +7. Refresh `libghostty.a`'s archive index with `xcrun ranlib`. +8. Write `.ghostty_hash` and `.ghostty_build_stamp`. +9. If no pinned artifact exists or the download is unavailable, fall back to the + existing local Ghostty build. + +Checksum mismatch or unsafe archive shape is a hard failure. That indicates a +broken or suspicious artifact and should not silently fall back. + +`make sync-ghostty`: + +- Remains the explicit "force local rebuild from source" command. +- Requires the Ghostty submodule to be initialized. +- Continues to clear Xcode DerivedData after rebuilding. + +## Publishing Flow + +For a new Ghostty commit: + +1. Build from source on a machine with the required Xcode: + + ```bash + DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer make sync-ghostty + ``` + +2. Package artifacts: + + ```bash + scripts/package-ghosttykit-artifacts.sh + ``` + +3. Create the matching `onevcat/ghostty` release and upload both assets. +4. Add the emitted checksums to `scripts/ghosttykit-checksums.txt`. +5. Verify a clean acquisition path: + + ```bash + rm -rf Frameworks/GhosttyKit.xcframework Resources/ghostty Resources/terminfo .ghostty_hash .ghostty_build_stamp + make ensure-ghostty + make build-app + ``` + +## CI Flow + +The macOS setup action should: + +1. Use the pinned gitlink SHA for its cache key. +2. Restore the existing GitHub Actions cache when available. +3. Run `make ensure-ghostty` on cache miss. +4. Continue caching generated framework/resources and marker files. + +This keeps CI deterministic while making cache misses much faster. + +## Risks + +- **Artifact drift:** mitigated by pinned tag names and checked-in SHA256 values. +- **Unsafe archive extraction:** mitigated by validating tar entries and archive + roots before extraction. +- **Missing artifact for a new Ghostty commit:** local source build remains the + fallback, so development is not blocked. +- **Stale module caches after header changes:** `ensure-ghostty` clears + DerivedData when the pinned Ghostty SHA changes, preserving the current + behavior. + +## Non-Goals + +- No SwiftPM binary target migration in this phase. +- No dependency on upstream Ghostty release assets. +- No "latest release" behavior. +- No committed generated `GhosttyKit.xcframework` or runtime resources. + +## Implementation Checklist + +- Add artifact checksum manifest. +- Add archive validator. +- Add artifact packaging script. +- Add artifact ensure/download script. +- Wire `make ensure-ghostty` to the downloader with local build fallback. +- Update CI setup action to use the downloader. +- Update Ghostty fork sync documentation. +- Build/package/upload current `48365577c1ae8e422c0dd90489921f07b9f79171` + artifact. +- Verify `make ensure-ghostty` from missing local artifacts. +- Verify `make build-app`. diff --git a/scripts/ensure-ghosttykit-artifacts.sh b/scripts/ensure-ghosttykit-artifacts.sh new file mode 100755 index 00000000..0db6cecb --- /dev/null +++ b/scripts/ensure-ghosttykit-artifacts.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +GHOSTTY_REPOSITORY="${PROWL_GHOSTTY_ARTIFACT_REPOSITORY:-onevcat/ghostty}" +GHOSTTY_ARTIFACT_FLAVOR="${PROWL_GHOSTTY_ARTIFACT_FLAVOR:-prowl-v1}" +CHECKSUMS_FILE="${PROWL_GHOSTTY_CHECKSUMS_FILE:-$SCRIPT_DIR/ghosttykit-checksums.txt}" +VALIDATOR="${PROWL_GHOSTTY_ARTIFACT_VALIDATOR:-$SCRIPT_DIR/validate-ghosttykit-artifacts.py}" + +XCFRAMEWORK_PATH="$PROJECT_DIR/Frameworks/GhosttyKit.xcframework" +GHOSTTY_RESOURCE_PATH="$PROJECT_DIR/Resources/ghostty" +TERMINFO_RESOURCE_PATH="$PROJECT_DIR/Resources/terminfo" +GHOSTTY_HASH_FILE="$PROJECT_DIR/.ghostty_hash" +GHOSTTY_BUILD_STAMP="$PROJECT_DIR/.ghostty_build_stamp" + +hash_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +lookup_checksums() { + local ghostty_sha="$1" + awk -v sha="$ghostty_sha" ' + $1 == sha { + print $2 " " $3 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$CHECKSUMS_FILE" +} + +pinned_ghostty_sha() { + git rev-parse HEAD:ThirdParty/ghostty +} + +artifacts_exist() { + [[ -d "$XCFRAMEWORK_PATH" && -d "$GHOSTTY_RESOURCE_PATH" && -d "$TERMINFO_RESOURCE_PATH" ]] +} + +stamp_matches() { + [[ -f "$GHOSTTY_HASH_FILE" ]] && [[ "$(cat "$GHOSTTY_HASH_FILE")" == "$GHOSTTY_SHA" ]] +} + +refresh_archive_index() { + local archive="$XCFRAMEWORK_PATH/macos-arm64_x86_64/libghostty.a" + if [[ ! -f "$archive" ]]; then + return 0 + fi + + if ! command -v xcrun >/dev/null 2>&1; then + echo "error: xcrun is required to refresh libghostty archive index." >&2 + exit 1 + fi + + local ranlib + ranlib="$(xcrun --find ranlib)" + "$ranlib" "$archive" +} + +install_artifacts() { + local xcframework_archive="$1" + local resources_archive="$2" + local tmp_extract="$3" + + mkdir -p "$tmp_extract/xcframework" "$tmp_extract/resources" "$PROJECT_DIR/Frameworks" "$PROJECT_DIR/Resources" + + tar -xzf "$xcframework_archive" -C "$tmp_extract/xcframework" + tar -xzf "$resources_archive" -C "$tmp_extract/resources" + + if [[ ! -d "$tmp_extract/xcframework/GhosttyKit.xcframework" ]]; then + echo "error: xcframework archive did not contain GhosttyKit.xcframework" >&2 + exit 1 + fi + if [[ ! -d "$tmp_extract/resources/ghostty" || ! -d "$tmp_extract/resources/terminfo" ]]; then + echo "error: resources archive did not contain ghostty and terminfo directories" >&2 + exit 1 + fi + + rm -rf "$XCFRAMEWORK_PATH" "$GHOSTTY_RESOURCE_PATH" "$TERMINFO_RESOURCE_PATH" + mv "$tmp_extract/xcframework/GhosttyKit.xcframework" "$XCFRAMEWORK_PATH" + mv "$tmp_extract/resources/ghostty" "$GHOSTTY_RESOURCE_PATH" + mv "$tmp_extract/resources/terminfo" "$TERMINFO_RESOURCE_PATH" + + refresh_archive_index + printf '%s\n' "$GHOSTTY_SHA" > "$GHOSTTY_HASH_FILE" + touch "$GHOSTTY_BUILD_STAMP" +} + +try_fetch_prebuilt() { + if [[ "${PROWL_GHOSTTY_NO_PREBUILT:-0}" == "1" ]]; then + echo "GhosttyKit prebuilt download disabled; falling back to local build." + return 2 + fi + + if [[ ! -f "$CHECKSUMS_FILE" ]]; then + echo "Missing GhosttyKit checksum manifest; falling back to local build." >&2 + return 2 + fi + + local checksums expected_xcframework_sha expected_resources_sha + if ! checksums="$(lookup_checksums "$GHOSTTY_SHA" 2>/dev/null)"; then + echo "No pinned GhosttyKit artifact for ${GHOSTTY_SHA:0:12}; falling back to local build." + return 2 + fi + read -r expected_xcframework_sha expected_resources_sha <<< "$checksums" + + local tag="xcframework-$GHOSTTY_SHA-$GHOSTTY_ARTIFACT_FLAVOR" + local base_url="https://github.com/$GHOSTTY_REPOSITORY/releases/download/$tag" + local tmp_dir="$PROJECT_DIR/.ghostty-download.$$" + local xcframework_archive="$tmp_dir/GhosttyKit.xcframework.tar.gz" + local resources_archive="$tmp_dir/GhosttyKit-resources.tar.gz" + + rm -rf "$tmp_dir" + mkdir -p "$tmp_dir" + trap 'rm -rf "$tmp_dir"' RETURN + + echo "Fetching prebuilt GhosttyKit artifacts for ${GHOSTTY_SHA:0:12}..." + if ! curl -fsSL --connect-timeout 10 --max-time 300 --retry 3 --retry-delay 2 --retry-all-errors \ + -o "$xcframework_archive" "$base_url/GhosttyKit.xcframework.tar.gz"; then + echo "Prebuilt GhosttyKit.xcframework unavailable; falling back to local build." + return 2 + fi + if ! curl -fsSL --connect-timeout 10 --max-time 300 --retry 3 --retry-delay 2 --retry-all-errors \ + -o "$resources_archive" "$base_url/GhosttyKit-resources.tar.gz"; then + echo "Prebuilt GhosttyKit resources unavailable; falling back to local build." + return 2 + fi + + local actual_xcframework_sha actual_resources_sha + actual_xcframework_sha="$(hash_file "$xcframework_archive")" + actual_resources_sha="$(hash_file "$resources_archive")" + + if [[ "$actual_xcframework_sha" != "$expected_xcframework_sha" ]]; then + echo "error: GhosttyKit.xcframework checksum mismatch." >&2 + echo " expected: $expected_xcframework_sha" >&2 + echo " actual: $actual_xcframework_sha" >&2 + exit 1 + fi + if [[ "$actual_resources_sha" != "$expected_resources_sha" ]]; then + echo "error: GhosttyKit resources checksum mismatch." >&2 + echo " expected: $expected_resources_sha" >&2 + echo " actual: $actual_resources_sha" >&2 + exit 1 + fi + + python3 "$VALIDATOR" xcframework "$xcframework_archive" + python3 "$VALIDATOR" resources "$resources_archive" + + install_artifacts "$xcframework_archive" "$resources_archive" "$tmp_dir/extract" + echo "Installed prebuilt GhosttyKit artifacts for ${GHOSTTY_SHA:0:12}." +} + +GHOSTTY_SHA="$(pinned_ghostty_sha)" + +if artifacts_exist && stamp_matches; then + echo "GhosttyKit up-to-date (SHA unchanged)" + exit 0 +fi + +echo "Syncing GhosttyKit for submodule $GHOSTTY_SHA" + +previous_sha="" +if [[ -f "$GHOSTTY_HASH_FILE" ]]; then + previous_sha="$(cat "$GHOSTTY_HASH_FILE")" +fi + +if try_fetch_prebuilt; then + if [[ "$previous_sha" != "$GHOSTTY_SHA" ]]; then + rm -rf ~/Library/Developer/Xcode/DerivedData/supacode-* + echo "Cleared Xcode DerivedData for ghostty header/module changes" + fi + exit 0 +fi + +exit 2 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt new file mode 100644 index 00000000..c7a93460 --- /dev/null +++ b/scripts/ghosttykit-checksums.txt @@ -0,0 +1,4 @@ +# Pinned GhosttyKit artifact checksums keyed by the Ghostty submodule gitlink. +# Release tags use: xcframework--prowl-v1 +# Format: +48365577c1ae8e422c0dd90489921f07b9f79171 69c2c06ed0f90c1e1fd86c607641f31239b374ea57d6cb59c61f543ac4528cd5 bfd2645d742670aa42e6bfab97f62d99f73a51fb8d922a091e51ba38ef5bd4d2 diff --git a/scripts/package-ghosttykit-artifacts.sh b/scripts/package-ghosttykit-artifacts.sh new file mode 100755 index 00000000..eec9e1cd --- /dev/null +++ b/scripts/package-ghosttykit-artifacts.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DIST_DIR="${PROWL_GHOSTTY_ARTIFACT_DIST_DIR:-$PROJECT_DIR/dist/ghosttykit}" + +XCFRAMEWORK_PATH="$PROJECT_DIR/Frameworks/GhosttyKit.xcframework" +GHOSTTY_RESOURCE_PATH="$PROJECT_DIR/Resources/ghostty" +TERMINFO_RESOURCE_PATH="$PROJECT_DIR/Resources/terminfo" + +hash_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +require_dir() { + local path="$1" + if [[ ! -d "$path" ]]; then + echo "error: missing required directory: $path" >&2 + exit 1 + fi +} + +require_dir "$XCFRAMEWORK_PATH" +require_dir "$GHOSTTY_RESOURCE_PATH" +require_dir "$TERMINFO_RESOURCE_PATH" + +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +XCFRAMEWORK_ARCHIVE="$DIST_DIR/GhosttyKit.xcframework.tar.gz" +RESOURCES_ARCHIVE="$DIST_DIR/GhosttyKit-resources.tar.gz" + +( + cd "$PROJECT_DIR/Frameworks" + COPYFILE_DISABLE=1 tar czf "$XCFRAMEWORK_ARCHIVE" GhosttyKit.xcframework +) + +( + cd "$PROJECT_DIR/Resources" + COPYFILE_DISABLE=1 tar czf "$RESOURCES_ARCHIVE" ghostty terminfo +) + +python3 "$SCRIPT_DIR/validate-ghosttykit-artifacts.py" xcframework "$XCFRAMEWORK_ARCHIVE" +python3 "$SCRIPT_DIR/validate-ghosttykit-artifacts.py" resources "$RESOURCES_ARCHIVE" + +GHOSTTY_SHA="$(git -C "$PROJECT_DIR" rev-parse HEAD:ThirdParty/ghostty)" +XCFRAMEWORK_SHA="$(hash_file "$XCFRAMEWORK_ARCHIVE")" +RESOURCES_SHA="$(hash_file "$RESOURCES_ARCHIVE")" + +cat < str: + while name.startswith("./"): + name = name[2:] + return name.rstrip("/") + + +def is_safe_member(name: str) -> bool: + path = PurePosixPath(name) + return not path.is_absolute() and ".." not in path.parts + + +def is_allowed(name: str, roots: tuple[str, ...]) -> bool: + return any(name == root or name.startswith(root + "/") for root in roots) + + +def validate(archive_kind: str, archive_path: str) -> None: + roots = ARCHIVES[archive_kind] + seen_roots: set[str] = set() + + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + name = normalize(member.name) + if not name: + continue + if not is_safe_member(name): + raise SystemExit(f"unsafe archive entry: {member.name}") + if not is_allowed(name, roots): + raise SystemExit(f"unexpected archive entry: {member.name}") + + for root in roots: + if name == root or name.startswith(root + "/"): + seen_roots.add(root) + + if member.islnk() or member.issym(): + target = normalize(member.linkname) + if not target or not is_safe_member(target): + raise SystemExit(f"unsafe archive link target: {member.linkname}") + elif not (member.isfile() or member.isdir()): + raise SystemExit(f"unsupported archive member: {member.name}") + + missing = set(roots) - seen_roots + if missing: + raise SystemExit(f"archive missing roots: {', '.join(sorted(missing))}") + + +def main() -> None: + if len(sys.argv) != 3 or sys.argv[1] not in ARCHIVES: + kinds = "|".join(sorted(ARCHIVES)) + raise SystemExit(f"usage: validate-ghosttykit-artifacts.py <{kinds}> ") + + validate(sys.argv[1], sys.argv[2]) + + +if __name__ == "__main__": + main() -- 2.51.2 From 33e730172c797418f7713872049c24e0fd0f779b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 16:40:28 +0900 Subject: [PATCH 436/528] Stabilize external diff tool tests --- supacode/Domain/ExternalDiffTool.swift | 2 +- supacodeTests/ExternalDiffToolTests.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/supacode/Domain/ExternalDiffTool.swift b/supacode/Domain/ExternalDiffTool.swift index e0ca5d0b..a08fba20 100644 --- a/supacode/Domain/ExternalDiffTool.swift +++ b/supacode/Domain/ExternalDiffTool.swift @@ -92,7 +92,7 @@ nonisolated struct ExternalDiffSettings: Equatable, Sendable { var customCommand: String var tool: ExternalDiffTool { - ExternalDiffTool.fromSettingsID(toolID) + ExternalDiffTool(rawValue: toolID) ?? .builtIn } } diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index 71bb0049..e365cbaa 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -19,6 +19,10 @@ struct ExternalDiffToolTests { #expect(ExternalDiffTool.custom.launchMode == .gui) } + @Test func explicitSettingsResolveWithoutCheckingInstallation() { + #expect(ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: "").tool == .hunk) + } + @Test func commandTemplateReplacesPlaceholdersWithShellQuotedValues() { let context = ExternalDiffCommandContext( worktreePath: "/tmp/My Repo/work tree", -- 2.51.2 From 35ddba32696c5d19ccef7b204f4f18df7f0c0343 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 16:49:19 +0900 Subject: [PATCH 437/528] Use install-independent diff settings test --- supacodeTests/AppFeatureCommandPaletteTests.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 59fadc2c..221e5835 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -808,7 +808,8 @@ struct AppFeatureCommandPaletteTests { repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) var settings = GlobalSettings.default - settings.externalDiffToolID = ExternalDiffTool.hunk.settingsID + settings.externalDiffToolID = ExternalDiffTool.custom.settingsID + settings.externalDiffCustomCommand = "my-diff {leftPath} {rightPath}" let storage = SettingsTestStorage() let settingsFileURL = URL( fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" @@ -838,7 +839,13 @@ struct AppFeatureCommandPaletteTests { await store.finish() #expect( - launched.value.map(\.0) == [ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: "")]) + launched.value.map(\.0) == [ + ExternalDiffSettings( + toolID: ExternalDiffTool.custom.settingsID, + customCommand: "my-diff {leftPath} {rightPath}" + ) + ] + ) #expect(launched.value.map(\.1) == [worktree]) } -- 2.51.2 From 13921255a0c91d003fb5f4639fbfce1cb9ac740d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 17:33:10 +0900 Subject: [PATCH 438/528] Route show diff shortcut through external diff settings --- supacode/Commands/SidebarCommands.swift | 10 +--- .../Reducer/AppFeature+CommandPalette.swift | 16 +++--- .../Features/App/Reducer/AppFeature.swift | 4 ++ .../AppFeatureCommandPaletteTests.swift | 52 +++++++++++++++++++ 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index e164d686..330b53f2 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -72,15 +72,7 @@ struct SidebarCommands: Commands { .help(helpText(title: "Select Previous Book", commandID: AppShortcuts.CommandID.selectPreviousShelfBook)) shelfBookMenuButtons Button("Show Diff", systemImage: "plusminus.circle") { - let repos = store.repositories - guard let worktreeID = repos.selectedWorktreeID, - let worktree = repos.worktree(for: worktreeID) - else { return } - DiffWindowManager.shared.show( - worktreeURL: worktree.workingDirectory, - branchName: worktree.name, - resolvedKeybindings: store.resolvedKeybindings - ) + store.send(.showSelectedWorktreeDiff) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) .help(helpText(title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff)) diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index ac0a10bf..0bc1ddaa 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -142,12 +142,7 @@ extension AppFeature { ) -> Effect? { switch delegate { case .showDiff: - guard let worktreeID = state.repositories.selectedWorktreeID, - let worktree = state.repositories.worktree(for: worktreeID) - else { - return .none - } - return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + return openSelectedWorktreeDiffEffect(state: state) case .revealInFinder: return .send(.openWorktree(.finder)) @@ -192,6 +187,15 @@ extension AppFeature { } } + func openSelectedWorktreeDiffEffect(state: State) -> Effect { + guard let worktreeID = state.repositories.selectedWorktreeID, + let worktree = state.repositories.worktree(for: worktreeID) + else { + return .none + } + return openDiffEffect(worktree: worktree, resolvedKeybindings: state.resolvedKeybindings) + } + func reduceCommandPaletteWorktreeActionDelegate( _ delegate: CommandPaletteFeature.Delegate, state: inout State diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 463b34d1..b32897d1 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -57,6 +57,7 @@ struct AppFeature { case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) case worktreeUserSettingsLoaded(UserRepositorySettings, worktreeID: Worktree.ID) case openSelectedWorktree + case showSelectedWorktreeDiff case openWorktree(OpenWorktreeAction) case openWorktreeFailed(OpenActionError) case requestQuit @@ -539,6 +540,9 @@ struct AppFeature { case .openSelectedWorktree: return .send(.openWorktree(OpenWorktreeAction.availableSelection(state.openActionSelection))) + case .showSelectedWorktreeDiff: + return openSelectedWorktreeDiffEffect(state: state) + case .openWorktree(let action): guard let worktree = state.repositories.selectedTerminalWorktree else { return .none diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 221e5835..5f80733c 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -849,6 +849,58 @@ struct AppFeatureCommandPaletteTests { #expect(launched.value.map(\.1) == [worktree]) } + @Test(.dependencies) func showSelectedWorktreeDiffUsesConfiguredExternalDiffTool() async { + let worktree = makeWorktree( + id: "/tmp/repo-shortcut-diff/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-shortcut-diff" + ) + let repository = makeRepository(id: "/tmp/repo-shortcut-diff", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + var settings = GlobalSettings.default + settings.externalDiffToolID = ExternalDiffTool.custom.settingsID + settings.externalDiffCustomCommand = "my-diff {leftPath} {rightPath}" + let storage = SettingsTestStorage() + let settingsFileURL = URL( + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" + ) + let launched = LockIsolated<[(ExternalDiffSettings, Worktree)]>([]) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.terminalClient.send = { _ in } + $0.externalDiffToolClient.open = { settings, worktree, _, _ in + launched.withValue { $0.append((settings, worktree)) } + } + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + return TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State(settings: settings) + ) + ) { + AppFeature() + } + } + + await store.send(.showSelectedWorktreeDiff) + await store.finish() + + #expect( + launched.value.map(\.0) == [ + ExternalDiffSettings( + toolID: ExternalDiffTool.custom.settingsID, + customCommand: "my-diff {leftPath} {rightPath}" + ) + ] + ) + #expect(launched.value.map(\.1) == [worktree]) + } + @Test(.dependencies) func closePullRequestDispatchesAction() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() -- 2.51.2 From feda7d6d040b497c947d499927f0e9885e0cb335 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 14 Jun 2026 17:37:00 +0900 Subject: [PATCH 439/528] Show unavailable diff tools as disabled --- docs/components/diff-view.md | 2 ++ supacode/Domain/ExternalDiffTool.swift | 4 ++++ .../Settings/Views/AppearanceSettingsView.swift | 6 +++++- supacodeTests/ExternalDiffToolTests.swift | 12 ++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/components/diff-view.md b/docs/components/diff-view.md index 2ff836e5..ba2169e8 100644 --- a/docs/components/diff-view.md +++ b/docs/components/diff-view.md @@ -30,6 +30,8 @@ General → Diff Tool, you can choose an external tool instead: command in the worktree directory. Supported placeholders: `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. +Tools that are not installed on the Mac are shown disabled in the Diff Tool menu. + ## What the built-in window shows - A **file list** sidebar of changed files, each with a colored status badge: diff --git a/supacode/Domain/ExternalDiffTool.swift b/supacode/Domain/ExternalDiffTool.swift index a08fba20..64650658 100644 --- a/supacode/Domain/ExternalDiffTool.swift +++ b/supacode/Domain/ExternalDiffTool.swift @@ -72,6 +72,10 @@ nonisolated enum ExternalDiffTool: String, CaseIterable, Identifiable, Codable, menuOrder.filter(\.isInstalled) } + static var settingsMenuCases: [ExternalDiffTool] { + menuOrder + } + static func fromSettingsID(_ settingsID: String?) -> ExternalDiffTool { guard let settingsID, let tool = Self(rawValue: settingsID), diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index dab828bc..3e88ccea 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -6,7 +6,7 @@ struct AppearanceSettingsView: View { var body: some View { let openActionOptions = OpenWorktreeAction.availableCases - let externalDiffToolOptions = ExternalDiffTool.availableCases + let externalDiffToolOptions = ExternalDiffTool.settingsMenuCases VStack(alignment: .leading) { Form { Section("Appearance") { @@ -146,9 +146,13 @@ struct AppearanceSettingsView: View { ForEach(externalDiffToolOptions) { tool in Text(tool.title) .tag(tool.settingsID) + .disabled(!tool.isInstalled) } } .help("Choose what opens when you click a diff badge or run Show Diff.") + Text("Tools not installed on this Mac appear disabled.") + .font(.callout) + .foregroundStyle(.secondary) if store.externalDiffToolID == ExternalDiffTool.custom.settingsID { TextField( "Command", diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index e365cbaa..97b57b02 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -19,6 +19,18 @@ struct ExternalDiffToolTests { #expect(ExternalDiffTool.custom.launchMode == .gui) } + @Test func settingsMenuListsAllSupportedTools() { + #expect( + ExternalDiffTool.settingsMenuCases == [ + .builtIn, + .hunk, + .fileMerge, + .kaleidoscope, + .custom, + ] + ) + } + @Test func explicitSettingsResolveWithoutCheckingInstallation() { #expect(ExternalDiffSettings(toolID: ExternalDiffTool.hunk.settingsID, customCommand: "").tool == .hunk) } -- 2.51.2 From ad8957fec9d568244170c102da682b24f8adcf58 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 19:22:34 +0800 Subject: [PATCH 440/528] Add workspace metadata mode --- docs/README.md | 2 + docs/components/cli.md | 2 +- docs/components/workspaces.md | 111 ++++++++++ docs/concepts.md | 12 +- .../ListRuntimeSnapshotBuilder.swift | 11 +- .../Shared/ListCommandPayload.swift | 1 + .../RepositoryPersistenceClient.swift | 5 +- supacode/Domain/ProjectWorkspace.swift | 196 ++++++++++++++++++ supacode/Domain/Repository.swift | 9 +- ...epositoriesFeature+RepositoryLoading.swift | 9 +- ...sitoriesFeature+RepositoryManagement.swift | 10 + .../Views/DetailToolbarTitle.swift | 8 +- .../Views/RepositoryDetailView.swift | 8 + .../Views/RepositorySectionView.swift | 2 +- .../Views/WorkspaceDetailView.swift | 107 ++++++++++ .../RepositoryPersistenceKeys.swift | 3 + supacodeTests/DetailToolbarTitleTests.swift | 20 ++ supacodeTests/ProjectWorkspaceTests.swift | 161 ++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 71 ++++++- 19 files changed, 735 insertions(+), 13 deletions(-) create mode 100644 docs/components/workspaces.md create mode 100644 supacode/Domain/ProjectWorkspace.swift create mode 100644 supacode/Features/Repositories/Views/WorkspaceDetailView.swift create mode 100644 supacodeTests/ProjectWorkspaceTests.swift diff --git a/docs/README.md b/docs/README.md index 70f26b1e..5e79e597 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. | File | What it covers | |------|----------------| | [`components/repositories-and-worktrees.md`](components/repositories-and-worktrees.md) | The sidebar: adding repositories, creating / opening / archiving / deleting git worktrees, plain (non-git) folders, pinning, ordering, repository icons & colors. | +| [`components/workspaces.md`](components/workspaces.md) | Multi-repo workspaces: one agent terminal rooted at a shared folder, with `.prowl/workspace.json` metadata describing the repositories in scope. | | [`components/terminal.md`](components/terminal.md) | Terminal tabs, splits/panes, surfaces, the Ghostty engine, tab titles & icons, shell integration, scrollback, font size. | | [`components/canvas.md`](components/canvas.md) | Canvas view — a zoomable board of live terminal cards, multi-select, and **broadcast a command to every agent at once**. | | [`components/shelf.md`](components/shelf.md) | Shelf view — worktrees as vertical "book spines" you flip through from the keyboard. | @@ -70,6 +71,7 @@ its keyboard shortcuts, detailed behavior, settings, and gotchas. | I want to… | Read | |------------|------| | Run many agents side by side and see them all | [`components/canvas.md`](components/canvas.md), [`components/shelf.md`](components/shelf.md) | +| Give one agent a task that spans several repos | [`components/workspaces.md`](components/workspaces.md) | | Send one command to every agent simultaneously | [`components/canvas.md`](components/canvas.md) (broadcast) | | Spin up a new branch/worktree for a new agent | [`components/repositories-and-worktrees.md`](components/repositories-and-worktrees.md) | | Find / run any action by name | [`components/command-palette.md`](components/command-palette.md) | diff --git a/docs/components/cli.md b/docs/components/cli.md index 863fbe9f..e35b11db 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -66,7 +66,7 @@ Snapshot of all worktrees → tabs → panes. No selectors. prowl list --json ``` Each item contains: -- `worktree`: `id`, `name`, `path`, `root_path`, `kind` (`git`|`plain`) +- `worktree`: `id`, `name`, `path`, `root_path`, `kind` (`git`|`plain`|`workspace`) - `tab`: `id`, `title`, `selected` - `pane`: `id`, `title`, `cwd`, `focused` - `task`: `status` (`running` | `idle` | null) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md new file mode 100644 index 00000000..d20e89e9 --- /dev/null +++ b/docs/components/workspaces.md @@ -0,0 +1,111 @@ +# Workspaces + +**Keywords:** workspace, multi-repo, many repositories, agent cwd, `.prowl/workspace.json`, workspace metadata, `prowl list` + +Workspaces let one agent work on a task that spans several repositories. A +workspace is a folder added to Prowl as a runnable project, with metadata at +`.prowl/workspace.json` describing the repositories inside it. + +When you open a workspace in Prowl: + +- The terminal starts in the workspace root. +- The sidebar/detail view uses the workspace title and repository list from + `.prowl/workspace.json`. +- The `prowl` CLI reports the runnable target's `worktree.kind` as `workspace`. +- Git worktree, branch, diff, and PR controls remain per-repository features; a + workspace is intentionally a multi-repo working directory rather than a single + git repository. + +## Folder layout + +Create or clone the repositories into a shared folder first, then add that +folder to Prowl. + +```text +my-feature-workspace/ +├─ .prowl/ +│ └─ workspace.json +├─ app/ +├─ api/ +└─ shared-package/ +``` + +Prowl currently reads the metadata and opens the workspace root. It does not yet +clone remote repositories or materialize git worktrees from the metadata by +itself, so those directories should already exist when the agent starts. + +## Metadata + +Example `.prowl/workspace.json`: + +```json +{ + "title": "Checkout Flow", + "description": "Update app UI, API contract, and shared package together.", + "task_links": [ + "https://github.com/onevcat/Prowl/issues/123" + ], + "repositories": [ + { + "name": "App", + "role": "macOS app", + "path": "app", + "source_kind": "local_repository", + "source_location": "/Users/mikoto/Documents/Repos/github/Prowl", + "branch_name": "codex/checkout-flow" + }, + { + "name": "API", + "role": "backend", + "path": "api", + "source_kind": "remote", + "source_location": "git@github.com:onevcat/api.git", + "base_ref": "main" + }, + { + "name": "Shared Package", + "role": "library", + "path": "shared-package", + "source_kind": "bare_repository", + "source_location": "/Users/mikoto/Documents/Repos/bare/shared-package.git", + "branch_name": "codex/checkout-flow" + } + ] +} +``` + +Top-level fields: + +- `id` — optional stable identifier. Defaults to the workspace root path. +- `title` — display title. Defaults to the folder name. +- `description` — optional task summary shown in the detail view. +- `task_links` — optional links or identifiers for the work item. +- `repositories` — repo entries that belong to the workspace. +- `created_at` / `updated_at` — optional ISO-8601 timestamps. + +Repository entry fields: + +- `id` — optional stable identifier. Defaults to `path`. +- `name` — display name. Defaults to the last path component. +- `role` — optional short role such as `app`, `backend`, or `docs`. +- `path` — relative path under the workspace root, or an absolute path. +- `source_kind` — `existing_path`, `remote`, `local_repository`, or + `bare_repository`. +- `source_location` — optional remote URL, local repository path, or bare repo + path. +- `branch_name` — optional branch/worktree name expected for the task. +- `base_ref` — optional base branch or ref. + +## Agent usage + +Because the terminal cwd is the workspace root, agents can inspect and modify +all listed repositories in one session: + +```bash +git -C app status +git -C api status +git -C shared-package status +``` + +Use the metadata as the contract: it tells the agent which repos are in scope, +where they are on disk, and what role each repo plays in the task. diff --git a/docs/concepts.md b/docs/concepts.md index 5503ab59..d44f215a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -10,16 +10,21 @@ Prowl nests four levels. Everything in the app refers back to these. ``` -Repository a git repo (or a plain folder) you've added to Prowl +Repository a git repo, workspace, or plain folder added to Prowl └─ Worktree a git worktree = one branch checked out in its own directory └─ Tab a terminal tab inside that worktree └─ Pane one terminal surface; a tab can be split into several panes ``` -- **Repository** — a project you added to the sidebar. Two kinds: +- **Repository** — a project you added to the sidebar. Two persisted kinds, plus + a workspace overlay: - **`git`** — a real git repository; supports worktrees, branches, diff, PRs. - **`plain`** — a non-git folder; you can open a terminal and run scripts in it, but there are no worktrees, branches, diff, or PR features. + - **Workspace** — a plain runnable folder with `.prowl/workspace.json` + metadata. One agent starts in the workspace root and can work across several + repositories listed in that file. The `prowl` CLI reports this runnable + target's `worktree.kind` as `workspace`. - **Worktree** — a git *worktree*: a branch checked out into its own working directory, so several branches are live on disk at once. This is the unit you hand to an agent. The repository's root directory is the **main worktree** @@ -73,6 +78,7 @@ all agents and their statuses is the - **Global settings:** `~/.prowl/settings.json` - **Per-repository settings:** `~/.prowl/repo//prowl.json` - **Per-repository user custom commands:** `~/.prowl/repo//prowl.onevcat.json` +- **Per-workspace metadata:** `/.prowl/workspace.json` - **CLI socket:** `~/Library/Application Support/com.onevcat.prowl/cli.sock` (overridable with `PROWL_CLI_SOCKET`) - Legacy `~/.supacode` is migrated to `~/.prowl` on first launch. (Prowl is a fork @@ -94,6 +100,8 @@ tabs, worktrees, views — are Prowl's. See - **Worktree** — a git branch checked out in its own directory; the unit you give an agent. +- **Workspace** — a folder whose terminal root contains several repositories for + one agent to handle together, described by `.prowl/workspace.json`. - **Book / spine** — Shelf-view name for a worktree shown as a vertical strip. - **Card** — Canvas-view name for one terminal tab shown as a floating tile. - **Broadcast** — typing into one Canvas card and mirroring it to all selected diff --git a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift index 720a2fc1..3adc1b19 100644 --- a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift +++ b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift @@ -86,7 +86,7 @@ enum ListRuntimeSnapshotBuilder { name: worktree.name, path: worktree.workingDirectory.path(percentEncoded: false), rootPath: worktree.repositoryRootURL.path(percentEncoded: false), - kind: repository.kind == .git ? ListCommandWorktree.Kind.git : .plain + kind: listCommandKind(for: repository) ) ) } @@ -101,7 +101,7 @@ enum ListRuntimeSnapshotBuilder { name: repository.name, path: rootPath, rootPath: rootPath, - kind: repository.kind == .git ? ListCommandWorktree.Kind.git : .plain + kind: listCommandKind(for: repository) ) ) } @@ -114,4 +114,11 @@ enum ListRuntimeSnapshotBuilder { guard let value else { return nil } return value.hasPrefix("/") ? value : nil } + + private static func listCommandKind(for repository: Repository) -> ListCommandWorktree.Kind { + if repository.isWorkspace { + return .workspace + } + return repository.kind == .git ? .git : .plain + } } diff --git a/supacode/CLIService/Shared/ListCommandPayload.swift b/supacode/CLIService/Shared/ListCommandPayload.swift index 381d6249..251bb2a8 100644 --- a/supacode/CLIService/Shared/ListCommandPayload.swift +++ b/supacode/CLIService/Shared/ListCommandPayload.swift @@ -33,6 +33,7 @@ public struct ListCommandWorktree: Codable, Equatable { public enum Kind: String, Codable, Equatable { case git case plain + case workspace } public let id: String diff --git a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift index 6bf37c0c..858f7e34 100644 --- a/supacode/Clients/Repositories/RepositoryPersistenceClient.swift +++ b/supacode/Clients/Repositories/RepositoryPersistenceClient.swift @@ -270,12 +270,14 @@ extension RepositorySnapshotCachePayload { let name: String let kind: Repository.Kind let worktrees: [SnapshotWorktree] + let workspace: ProjectWorkspace? init(repository: Repository) { rootPath = repository.rootURL.path(percentEncoded: false) name = repository.name kind = repository.kind worktrees = repository.worktrees.map { SnapshotWorktree(worktree: $0) } + workspace = repository.workspace } func restore( @@ -310,7 +312,8 @@ extension RepositorySnapshotCachePayload { rootURL: rootURL, name: repositoryName.isEmpty ? Repository.name(for: rootURL) : repositoryName, kind: kind, - worktrees: IdentifiedArray(restoredWorktrees, uniquingIDsWith: { current, _ in current }) + worktrees: IdentifiedArray(restoredWorktrees, uniquingIDsWith: { current, _ in current }), + workspace: workspace?.normalized(relativeTo: rootURL) ) } } diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift new file mode 100644 index 00000000..fbfab371 --- /dev/null +++ b/supacode/Domain/ProjectWorkspace.swift @@ -0,0 +1,196 @@ +import Foundation + +nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, Sendable { + case remote + case localRepository = "local_repository" + case bareRepository = "bare_repository" + case existingPath = "existing_path" +} + +nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, Identifiable { + var id: String + var name: String + var role: String? + var path: String + var sourceKind: ProjectWorkspaceRepositorySourceKind + var sourceLocation: String? + var branchName: String? + var baseRef: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case role + case path + case sourceKind = "source_kind" + case sourceLocation = "source_location" + case branchName = "branch_name" + case baseRef = "base_ref" + } + + init( + id: String = "", + name: String = "", + role: String? = nil, + path: String = "", + sourceKind: ProjectWorkspaceRepositorySourceKind = .existingPath, + sourceLocation: String? = nil, + branchName: String? = nil, + baseRef: String? = nil + ) { + self.id = id + self.name = name + self.role = role + self.path = path + self.sourceKind = sourceKind + self.sourceLocation = sourceLocation + self.branchName = branchName + self.baseRef = baseRef + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + role = try container.decodeIfPresent(String.self, forKey: .role) + path = + try container.decodeIfPresent(String.self, forKey: .path) + ?? name.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? id.trimmingCharacters(in: .whitespacesAndNewlines) + sourceKind = + try container.decodeIfPresent(ProjectWorkspaceRepositorySourceKind.self, forKey: .sourceKind) + ?? .existingPath + sourceLocation = try container.decodeIfPresent(String.self, forKey: .sourceLocation) + branchName = try container.decodeIfPresent(String.self, forKey: .branchName) + baseRef = try container.decodeIfPresent(String.self, forKey: .baseRef) + } + + func resolvedURL(relativeTo workspaceRootURL: URL) -> URL { + let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedPath.hasPrefix("/") { + return URL(fileURLWithPath: trimmedPath).standardizedFileURL + } + return workspaceRootURL.appending(path: trimmedPath).standardizedFileURL + } +} + +nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { + typealias RepositorySourceKind = ProjectWorkspaceRepositorySourceKind + typealias RepositoryEntry = ProjectWorkspaceRepositoryEntry + + nonisolated static let metadataDirectoryName = ".prowl" + nonisolated static let metadataFileName = "workspace.json" + + var id: String + var title: String + var description: String + var taskLinks: [String] + var repositories: [RepositoryEntry] + var createdAt: Date? + var updatedAt: Date? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case taskLinks = "task_links" + case repositories + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + init( + id: String = "", + title: String = "", + description: String = "", + taskLinks: [String] = [], + repositories: [RepositoryEntry] = [], + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.title = title + self.description = description + self.taskLinks = taskLinks + self.repositories = repositories + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + taskLinks = try container.decodeIfPresent([String].self, forKey: .taskLinks) ?? [] + repositories = try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + } + + static func metadataURL(for rootURL: URL) -> URL { + rootURL + .appending(path: metadataDirectoryName) + .appending(path: metadataFileName) + } + + static func load(from rootURL: URL) -> ProjectWorkspace? { + let metadataURL = metadataURL(for: rootURL) + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard var workspace = try? decoder.decode(ProjectWorkspace.self, from: data) else { + return nil + } + let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) + if workspace.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + workspace.id = normalizedRoot + } + if workspace.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + workspace.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent + } + return workspace.normalized(relativeTo: rootURL) + } + + func normalized(relativeTo rootURL: URL) -> ProjectWorkspace { + var copy = self + let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) + if copy.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + copy.id = normalizedRoot + } + copy.title = copy.title.trimmingCharacters(in: .whitespacesAndNewlines) + if copy.title.isEmpty { + copy.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent + } + copy.description = copy.description.trimmingCharacters(in: .whitespacesAndNewlines) + copy.taskLinks = copy.taskLinks.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + copy.repositories = copy.repositories.map { entry in + var entry = entry + entry.id = entry.id.trimmingCharacters(in: .whitespacesAndNewlines) + entry.name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines) + entry.path = entry.path.trimmingCharacters(in: .whitespacesAndNewlines) + if entry.id.isEmpty { + entry.id = entry.path.isEmpty ? entry.name : entry.path + } + if entry.name.isEmpty { + let resolvedURL = entry.resolvedURL(relativeTo: rootURL) + entry.name = resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent + } + entry.role = entry.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.sourceLocation = entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.branchName = entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.baseRef = entry.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + return entry + } + return copy + } +} + +extension String { + nonisolated fileprivate var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/supacode/Domain/Repository.swift b/supacode/Domain/Repository.swift index 5292cb98..c7f0f664 100644 --- a/supacode/Domain/Repository.swift +++ b/supacode/Domain/Repository.swift @@ -45,19 +45,22 @@ nonisolated struct Repository: Identifiable, Hashable, Sendable { let name: String let kind: Kind let worktrees: IdentifiedArrayOf + let workspace: ProjectWorkspace? init( id: String, rootURL: URL, name: String, kind: Kind = .git, - worktrees: IdentifiedArrayOf + worktrees: IdentifiedArrayOf, + workspace: ProjectWorkspace? = nil ) { self.id = id self.rootURL = rootURL self.name = name self.kind = kind self.worktrees = worktrees + self.workspace = workspace } var initials: String { @@ -73,6 +76,10 @@ nonisolated struct Repository: Identifiable, Hashable, Sendable { } } + var isWorkspace: Bool { + workspace != nil + } + static func name(for rootURL: URL) -> String { let name = rootURL.lastPathComponent if name == ".bare" || name == ".git" { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 31d85b71..4d8c7d1e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -64,6 +64,9 @@ extension RepositoriesFeature { let normalizedPath = URL(fileURLWithPath: entry.path) .standardizedFileURL .path(percentEncoded: false) + if ProjectWorkspace.load(from: URL(fileURLWithPath: normalizedPath)) != nil { + return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + } do { let repoRoot = try await gitClient.repoRoot(URL(fileURLWithPath: normalizedPath)) let normalizedRepoRoot = repoRoot.standardizedFileURL.path(percentEncoded: false) @@ -183,14 +186,16 @@ extension RepositoriesFeature { ) } case .plain: + let workspace = ProjectWorkspace.load(from: rootURL) return WorktreesFetchResult( entry: entry, repository: Repository( id: rootURL.path(percentEncoded: false), rootURL: rootURL, - name: Repository.name(for: rootURL), + name: workspace?.title ?? Repository.name(for: rootURL), kind: .plain, - worktrees: IdentifiedArray() + worktrees: IdentifiedArray(), + workspace: workspace ), errorMessage: nil ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 98d6f7b4..dff64e5b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -17,6 +17,16 @@ extension RepositoriesFeature { var invalidRoots: [String] = [] var openFailures: [String] = [] for url in urls { + let normalizedURL = url.standardizedFileURL + if ProjectWorkspace.load(from: normalizedURL) != nil { + resolvedEntries.append( + PersistedRepositoryEntry( + path: normalizedURL.path(percentEncoded: false), + kind: .plain + ) + ) + continue + } do { let root = try await gitClient.repoRoot(url) resolvedEntries.append( diff --git a/supacode/Features/Repositories/Views/DetailToolbarTitle.swift b/supacode/Features/Repositories/Views/DetailToolbarTitle.swift index 03155221..cde8daa7 100644 --- a/supacode/Features/Repositories/Views/DetailToolbarTitle.swift +++ b/supacode/Features/Repositories/Views/DetailToolbarTitle.swift @@ -4,13 +4,14 @@ struct DetailToolbarTitle: Equatable { enum Kind: Equatable { case branch(name: String) case folder(name: String) + case workspace(name: String) } let kind: Kind var text: String { switch kind { - case .branch(let name), .folder(let name): + case .branch(let name), .folder(let name), .workspace(let name): return name } } @@ -19,6 +20,8 @@ struct DetailToolbarTitle: Equatable { switch kind { case .branch: return "arrow.trianglehead.branch" + case .workspace: + return "folder.badge.person.crop" case .folder: return "folder" } @@ -41,6 +44,9 @@ struct DetailToolbarTitle: Equatable { guard let repository, repository.kind == .plain else { return nil } + if repository.isWorkspace { + return DetailToolbarTitle(kind: .workspace(name: repository.name)) + } return DetailToolbarTitle(kind: .folder(name: repository.name)) } } diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index 8ddfcb55..41742a07 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -7,6 +7,14 @@ struct RepositoryDetailView: View { var customTitle: String? var body: some View { + if let workspace = repository.workspace { + WorkspaceDetailView(repository: repository, workspace: workspace) + } else { + repositoryDetail + } + } + + private var repositoryDetail: some View { VStack(spacing: 12) { Image(systemName: repository.kind == .git ? "folder.badge.gearshape" : "folder") .font(.largeTitle) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index a9d1bc1e..45103c7d 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -54,7 +54,7 @@ struct RepositorySectionView: View { repositoryRootURL: repository.rootURL, nameTooltip: repository.capabilities.supportsWorktrees ? (isExpanded ? "Collapse" : "Expand") - : "Open terminal in folder" + : (repository.isWorkspace ? "Open terminal in workspace" : "Open terminal in folder") ) RepoHeaderTabCountBadge( repository: repository, diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift new file mode 100644 index 00000000..48043e83 --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct WorkspaceDetailView: View { + let repository: Repository + let workspace: ProjectWorkspace + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + if !workspace.description.isEmpty { + Text(workspace.description) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !workspace.taskLinks.isEmpty { + taskLinks + } + repositoriesTable + Spacer(minLength: 0) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "folder.badge.person.crop") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(workspace.title) + .font(.title3.weight(.semibold)) + .textSelection(.enabled) + Text(repository.rootURL.path(percentEncoded: false)) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text("\(workspace.repositories.count) repositories") + .font(.subheadline) + .foregroundStyle(.tertiary) + } + } + } + + private var taskLinks: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Task Links") + .font(.headline) + ForEach(workspace.taskLinks, id: \.self) { link in + Text(link) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + private var repositoriesTable: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Repositories") + .font(.headline) + if workspace.repositories.isEmpty { + Text("No repositories are declared in this workspace metadata.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { + GridRow { + tableHeader("Name") + tableHeader("Role") + tableHeader("Branch") + tableHeader("Path") + } + Divider() + .gridCellUnsizedAxes(.horizontal) + ForEach(workspace.repositories) { entry in + GridRow(alignment: .firstTextBaseline) { + Text(entry.name) + .font(.subheadline.weight(.medium)) + Text(entry.role ?? " ") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(entry.branchName ?? entry.baseRef ?? " ") + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + Text(entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false)) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + } + } + } + + private func tableHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } +} diff --git a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift index 1880100d..75bb222d 100644 --- a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift @@ -205,6 +205,9 @@ nonisolated enum RepositoryEntryNormalizer { return order.compactMap { path in guard let kind = kindByPath[path] else { return nil } + if ProjectWorkspace.load(from: URL(fileURLWithPath: path)) != nil { + return PersistedRepositoryEntry(path: path, kind: .plain) + } return PersistedRepositoryEntry(path: path, kind: kind) } } diff --git a/supacodeTests/DetailToolbarTitleTests.swift b/supacodeTests/DetailToolbarTitleTests.swift index d26f0d40..7bf2b106 100644 --- a/supacodeTests/DetailToolbarTitleTests.swift +++ b/supacodeTests/DetailToolbarTitleTests.swift @@ -42,4 +42,24 @@ struct DetailToolbarTitleTests { #expect(title?.systemImage == "folder") #expect(title?.supportsRename == false) } + + @Test func workspaceSelectionUsesWorkspaceTitle() { + let repository = Repository( + id: "/tmp/workspace", + rootURL: URL(fileURLWithPath: "/tmp/workspace"), + name: "workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "workspace") + ) + + let title = DetailToolbarTitle.forSelection( + worktree: nil, + repository: repository + ) + + #expect(title?.kind == .workspace(name: "workspace")) + #expect(title?.systemImage == "folder.badge.person.crop") + #expect(title?.supportsRename == false) + } } diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift new file mode 100644 index 00000000..25219115 --- /dev/null +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -0,0 +1,161 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct ProjectWorkspaceTests { + @Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { + let rootURL = try makeTemporaryWorkspaceRoot() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try writeWorkspaceJSON( + """ + { + "title": "Multi Repo Task", + "repositories": [ + { + "role": "backend", + "path": "api", + "source_kind": "bare_repository", + "source_location": "/Users/mikoto/Repos/api.git", + "branch_name": "feature/workspace" + }, + { + "id": "web", + "name": "Web", + "path": "/tmp/web", + "source_kind": "remote", + "source_location": "git@github.com:onevcat/web.git" + }, + { + "name": "Shared", + "path": "shared" + } + ] + } + """, + to: rootURL + ) + + let workspace = try #require(ProjectWorkspace.load(from: rootURL)) + let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false) + + #expect(workspace.id == rootPath) + #expect(workspace.title == "Multi Repo Task") + #expect(workspace.description == "") + #expect(workspace.taskLinks == []) + try #require(workspace.repositories.count == 3) + + let api = workspace.repositories[0] + #expect(api.id == "api") + #expect(api.name == "api") + #expect(api.role == "backend") + #expect(api.sourceKind == .bareRepository) + #expect(api.sourceLocation == "/Users/mikoto/Repos/api.git") + #expect(api.branchName == "feature/workspace") + #expect( + api.resolvedURL(relativeTo: rootURL).path(percentEncoded: false) + == rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) + ) + + let web = workspace.repositories[1] + #expect(web.id == "web") + #expect(web.name == "Web") + #expect(web.sourceKind == .remote) + #expect( + web.resolvedURL(relativeTo: rootURL).path(percentEncoded: false) + == URL(fileURLWithPath: "/tmp/web").standardizedFileURL.path(percentEncoded: false) + ) + + let shared = workspace.repositories[2] + #expect(shared.id == "shared") + #expect(shared.name == "Shared") + #expect(shared.sourceKind == .existingPath) + } + + @Test func normalizesEmptyWorkspaceAndRepositoryFields() throws { + let rootURL = URL(fileURLWithPath: "/tmp/prowl-workspace") + + let workspace = ProjectWorkspace( + id: " ", + title: " ", + description: " Touch app and API together ", + taskLinks: [" https://github.com/onevcat/Prowl/issues/1 ", " "], + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: " ", + name: " ", + role: " ", + path: " app ", + sourceKind: .localRepository, + sourceLocation: " ", + branchName: " feature/workspace ", + baseRef: " " + ) + ] + ) + .normalized(relativeTo: rootURL) + + #expect(workspace.id == "/tmp/prowl-workspace") + #expect(workspace.title == "prowl-workspace") + #expect(workspace.description == "Touch app and API together") + #expect(workspace.taskLinks == ["https://github.com/onevcat/Prowl/issues/1"]) + + let entry = try #require(workspace.repositories.first) + #expect(entry.id == "app") + #expect(entry.name == "app") + #expect(entry.role == nil) + #expect(entry.sourceKind == .localRepository) + #expect(entry.sourceLocation == nil) + #expect(entry.branchName == "feature/workspace") + #expect(entry.baseRef == nil) + } + + @Test func repositoryEntryNormalizerKeepsWorkspacePathPlain() throws { + let rootURL = try makeTemporaryWorkspaceRoot() + defer { try? FileManager.default.removeItem(at: rootURL) } + try writeWorkspaceJSON("{}", to: rootURL) + + let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false) + let normalized = RepositoryEntryNormalizer.normalize([ + PersistedRepositoryEntry(path: rootPath, kind: .git) + ]) + + #expect(normalized == [PersistedRepositoryEntry(path: rootPath, kind: .plain)]) + } + + @Test func listRuntimeContextsReportWorkspaceKind() { + let rootURL = URL(fileURLWithPath: "/tmp/workspace") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace") + ) + var state = RepositoriesFeature.State() + state.repositories = [repository] + state.repositoryRoots = [rootURL] + + let contexts = ListRuntimeSnapshotBuilder.orderedWorktreeContexts(from: state) + + #expect(contexts.map(\.kind) == [.workspace]) + #expect(contexts.first?.id == repository.id) + } + + private func makeTemporaryWorkspaceRoot() throws -> URL { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-workspace-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + return rootURL + } + + private func writeWorkspaceJSON(_ json: String, to rootURL: URL) throws { + let metadataDirectoryURL = rootURL.appending(path: ProjectWorkspace.metadataDirectoryName) + try FileManager.default.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) + try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index c8d4f90f..47f7553a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -507,6 +507,71 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func loadPersistedRepositoriesLoadsWorkspaceMetadataAsPlainRepository() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-workspace-\(UUID().uuidString)") + .standardizedFileURL + defer { try? FileManager.default.removeItem(at: rootURL) } + + try FileManager.default.createDirectory( + at: rootURL.appending(path: ProjectWorkspace.metadataDirectoryName), + withIntermediateDirectories: true + ) + try Data( + """ + { + "title": "Multi Repo Workspace", + "repositories": [ + { + "name": "App", + "role": "client", + "path": "app", + "source_kind": "existing_path" + } + ] + } + """.utf8 + ) + .write(to: ProjectWorkspace.metadataURL(for: rootURL)) + + let rootPath = rootURL.path(percentEncoded: false) + let workspace = try #require(ProjectWorkspace.load(from: rootURL)) + let repository = makeRepository( + id: rootPath, + name: "Multi Repo Workspace", + kind: .plain, + worktrees: [], + workspace: workspace + ) + + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: rootPath, kind: .plain)] + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repoRoot = { url in + Issue.record("workspace should load as plain without git probing: \(url.path(percentEncoded: false))") + return url + } + $0.gitClient.worktrees = { url in + Issue.record("workspace should not load git worktrees: \(url.path(percentEncoded: false))") + return [] + } + } + + await store.send(.loadPersistedRepositories) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [repository] + $0.repositoryRoots = [rootURL] + $0.isInitialLoadComplete = true + $0.snapshotPersistencePhase = .active + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) @@ -5304,14 +5369,16 @@ struct RepositoriesFeatureTests { id: String, name: String = "repo", kind: Repository.Kind = .git, - worktrees: [Worktree] + worktrees: [Worktree], + workspace: ProjectWorkspace? = nil ) -> Repository { Repository( id: id, rootURL: URL(fileURLWithPath: id), name: name, kind: kind, - worktrees: IdentifiedArray(uniqueElements: worktrees) + worktrees: IdentifiedArray(uniqueElements: worktrees), + workspace: workspace ) } -- 2.51.2 From 51403b3a04a7297af72a9f24e0b4af71ae0f748b Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 20:17:39 +0800 Subject: [PATCH 441/528] Add workspace creation flow --- docs/components/workspaces.md | 13 +- supacode/App/ContentView.swift | 4 + supacode/Commands/WorktreeCommands.swift | 5 + supacode/Domain/ProjectWorkspace.swift | 223 ++++++++++++++++++ .../Reducer/AppFeature+CommandPalette.swift | 21 +- .../CommandPalette/CommandPaletteItem.swift | 3 + .../Reducer/CommandPaletteFeature.swift | 10 + .../Reducer/CommandPaletteSupport.swift | 6 + .../Views/CommandPaletteOverlayView.swift | 8 +- .../RepositoriesFeature+CoreReducer.swift | 15 +- ...epositoriesFeature+WorkspaceCreation.swift | 117 +++++++++ .../Reducer/RepositoriesFeature.swift | 19 ++ .../WorkspaceCreationPromptFeature.swift | 111 +++++++++ .../Repositories/Views/SidebarListView.swift | 9 +- .../Views/WorkspaceCreationPromptView.swift | 153 ++++++++++++ supacode/Support/SupacodePaths.swift | 4 + .../AppFeatureCommandPaletteTests.swift | 20 ++ .../CommandPaletteFeatureTests.swift | 5 +- supacodeTests/ProjectWorkspaceTests.swift | 64 +++++ supacodeTests/RepositoriesFeatureTests.swift | 102 ++++++++ 20 files changed, 898 insertions(+), 14 deletions(-) create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift create mode 100644 supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift create mode 100644 supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index d20e89e9..2f5ed2fd 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -18,8 +18,10 @@ When you open a workspace in Prowl: ## Folder layout -Create or clone the repositories into a shared folder first, then add that -folder to Prowl. +Use **New Workspace** from the sidebar toolbar, Worktrees menu, or command +palette to create a workspace from repositories already opened in Prowl. Prowl +creates the shared folder, writes `.prowl/workspace.json`, and places symlinks +to the selected repositories in the workspace root. ```text my-feature-workspace/ @@ -30,9 +32,10 @@ my-feature-workspace/ └─ shared-package/ ``` -Prowl currently reads the metadata and opens the workspace root. It does not yet -clone remote repositories or materialize git worktrees from the metadata by -itself, so those directories should already exist when the agent starts. +The current creation flow supports existing local repositories that are already +loaded in Prowl. Prowl still reads hand-authored metadata, but it does not yet +clone remote repositories or materialize git worktrees from bare repositories by +itself. ## Metadata diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 21950872..2eed9a54 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -80,6 +80,10 @@ struct ContentView: View { promptStore in WorktreeCreationPromptView(store: promptStore) } + .sheet(item: $repositoriesStore.scope(state: \.workspaceCreationPrompt, action: \.workspaceCreationPrompt)) { + promptStore in + WorkspaceCreationPromptView(store: promptStore) + } .sheet(isPresented: deleteWorktreeConfirmationPresented) { if let confirmation = repositoriesStore.deleteWorktreeConfirmation { DeleteWorktreeConfirmationView( diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 1dc7dec5..777ad4b6 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -86,6 +86,11 @@ struct WorktreeCommands: Commands { } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openRepository))) .help(helpText(title: "Open Repository", commandID: AppShortcuts.CommandID.openRepository)) + Button("New Workspace...", systemImage: "folder.badge.person.crop") { + store.send(.repositories(.workspaceCreation(.promptRequested))) + } + .help("New Workspace") + .disabled(!repositories.canCreateWorkspace) Button("Open Worktree") { openSelectedWorktreeAction?() } diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index fbfab371..71c1fe8e 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -7,6 +7,75 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl case existingPath = "existing_path" } +nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { + var id: String + var name: String + var rootURL: URL + var branchName: String? + + init( + id: String, + name: String, + rootURL: URL, + branchName: String? = nil + ) { + self.id = id + self.name = name + self.rootURL = rootURL.standardizedFileURL + self.branchName = branchName + } +} + +nonisolated struct ProjectWorkspaceCreationDraft: Equatable, Sendable { + var title: String + var rootURL: URL + var repositories: [ProjectWorkspaceCreationRepository] + + init( + title: String, + rootURL: URL, + repositories: [ProjectWorkspaceCreationRepository] + ) { + self.title = title + self.rootURL = rootURL.standardizedFileURL + self.repositories = repositories + } +} + +nonisolated struct ProjectWorkspaceCreationRequest: Equatable, Sendable { + var draft: ProjectWorkspaceCreationDraft + var createdAt: Date +} + +nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Sendable { + case missingTitle + case missingPath + case notEnoughRepositories + case destinationIsFile(String) + case workspaceAlreadyExists(String) + case repositoryDoesNotExist(String) + case linkAlreadyExists(String) + + var errorDescription: String? { + switch self { + case .missingTitle: + return "Workspace title required." + case .missingPath: + return "Workspace folder required." + case .notEnoughRepositories: + return "Select at least two repositories." + case .destinationIsFile(let path): + return "\(path) is a file. Choose a folder path instead." + case .workspaceAlreadyExists(let path): + return "\(path) already contains a Prowl workspace." + case .repositoryDoesNotExist(let path): + return "\(path) does not exist." + case .linkAlreadyExists(let path): + return "\(path) already exists." + } + } +} + nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, Identifiable { var id: String var name: String @@ -187,6 +256,160 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } return copy } + + static func create( + _ request: ProjectWorkspaceCreationRequest, + fileManager: FileManager = .default + ) throws -> ProjectWorkspace { + let title = request.draft.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw ProjectWorkspaceCreationError.missingTitle + } + guard request.draft.repositories.count >= 2 else { + throw ProjectWorkspaceCreationError.notEnoughRepositories + } + let rootPath = normalizedPath(request.draft.rootURL, resolvingSymlinks: false) + guard !rootPath.isEmpty else { + throw ProjectWorkspaceCreationError.missingPath + } + let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL + + var createdRoot = false + var createdMetadataDirectory = false + var createdURLs: [URL] = [] + do { + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: rootPath, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw ProjectWorkspaceCreationError.destinationIsFile(rootPath) + } + } else { + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + createdRoot = true + } + + let metadataDirectoryURL = rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory) + let metadataPath = metadataDirectoryURL.path(percentEncoded: false) + let metadataURL = metadataURL(for: rootURL) + if fileManager.fileExists(atPath: metadataURL.path(percentEncoded: false)) { + throw ProjectWorkspaceCreationError.workspaceAlreadyExists(rootPath) + } + if !fileManager.fileExists(atPath: metadataPath) { + try fileManager.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) + createdMetadataDirectory = true + } + + var occupiedNames: Set = [] + let entries = try request.draft.repositories.map { repository in + let repositoryPath = normalizedPath(repository.rootURL, resolvingSymlinks: true) + let repositoryURL = URL(fileURLWithPath: repositoryPath, isDirectory: true).standardizedFileURL + var repositoryIsDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), + repositoryIsDirectory.boolValue + else { + throw ProjectWorkspaceCreationError.repositoryDoesNotExist(repositoryPath) + } + + let linkName = uniqueRepositoryLinkName(for: repository, occupiedNames: &occupiedNames) + let linkURL = rootURL.appending(path: linkName, directoryHint: .isDirectory) + let linkPath = linkURL.path(percentEncoded: false) + guard !fileManager.fileExists(atPath: linkPath) else { + throw ProjectWorkspaceCreationError.linkAlreadyExists(linkPath) + } + try fileManager.createSymbolicLink(at: linkURL, withDestinationURL: repositoryURL) + createdURLs.append(linkURL) + return RepositoryEntry( + id: repository.id, + name: repository.name, + path: linkName, + sourceKind: .existingPath, + sourceLocation: repositoryPath, + branchName: repository.branchName + ) + } + + let workspace = ProjectWorkspace( + id: rootPath, + title: title, + repositories: entries, + createdAt: request.createdAt, + updatedAt: request.createdAt + ) + .normalized(relativeTo: rootURL) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + try encoder.encode(workspace).write(to: metadataURL, options: .atomic) + createdURLs.append(metadataURL) + return workspace + } catch { + for url in createdURLs.reversed() { + try? fileManager.removeItem(at: url) + } + if createdRoot { + try? fileManager.removeItem(at: rootURL) + } else if createdMetadataDirectory { + try? fileManager.removeItem(at: rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory)) + } + throw error + } + } + + static func defaultWorkspaceFolderName(for title: String) -> String { + let sanitized = sanitizedWorkspaceComponent(title) + return sanitized.isEmpty ? "workspace" : sanitized + } + + private static func uniqueRepositoryLinkName( + for repository: ProjectWorkspaceCreationRepository, + occupiedNames: inout Set + ) -> String { + var baseName = sanitizedWorkspaceComponent(repository.name) + if baseName.isEmpty { + baseName = sanitizedWorkspaceComponent(repository.rootURL.lastPathComponent) + } + if baseName.isEmpty { + baseName = "repository" + } + + var candidate = baseName + var suffix = 2 + while occupiedNames.contains(candidate.lowercased()) { + candidate = "\(baseName)-\(suffix)" + suffix += 1 + } + occupiedNames.insert(candidate.lowercased()) + return candidate + } + + private static func sanitizedWorkspaceComponent(_ value: String) -> String { + var result = "" + for scalar in value.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars { + if CharacterSet.whitespacesAndNewlines.contains(scalar) + || CharacterSet(charactersIn: "/:").contains(scalar) + { + result.append("-") + } else { + result.unicodeScalars.append(scalar) + } + } + while result.contains("--") { + result = result.replacing("--", with: "-") + } + result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if result == "." || result == ".." { + return "" + } + return result + } + + private static func normalizedPath(_ url: URL, resolvingSymlinks: Bool) -> String { + var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + return path + } } extension String { diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 0bc1ddaa..fcfa7114 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -30,6 +30,9 @@ extension AppFeature { if let effect = reduceCommandPaletteNavigationDelegate(delegate, state: &state) { return effect } + if let effect = reduceCommandPaletteRepositoryDelegate(delegate) { + return effect + } if let effect = reduceCommandPaletteCanvasDelegate(delegate) { return effect } @@ -80,9 +83,6 @@ extension AppFeature { case .newWorktree: return .send(.repositories(.worktreeCreation(.createRandomWorktree))) - case .openRepository: - return .send(.repositories(.setOpenPanelPresented(true))) - case .deleteWorktree(let worktreeID, let repositoryID): return .send(.repositories(.worktreeLifecycle(.requestDeleteWorktree(worktreeID, repositoryID)))) @@ -109,6 +109,21 @@ extension AppFeature { } } + func reduceCommandPaletteRepositoryDelegate( + _ delegate: CommandPaletteFeature.Delegate + ) -> Effect? { + switch delegate { + case .openRepository: + return .send(.repositories(.setOpenPanelPresented(true))) + + case .newWorkspace: + return .send(.repositories(.workspaceCreation(.promptRequested))) + + default: + return nil + } + } + func reduceCommandPaletteCanvasDelegate( _ delegate: CommandPaletteFeature.Delegate ) -> Effect? { diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 163313d5..07bb8397 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -43,6 +43,7 @@ struct CommandPaletteItem: Identifiable, Equatable { enum Kind: Equatable { case checkForUpdates case openRepository + case newWorkspace case worktreeSelect(Worktree.ID) case openSettings case newWorktree @@ -105,6 +106,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.checkForUpdates case .openRepository: return AppShortcuts.CommandID.openRepository + case .newWorkspace: + return nil case .openSettings: return AppShortcuts.CommandID.openSettings case .newWorktree: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 4ec6ecf7..c81153ef 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -36,6 +36,7 @@ struct CommandPaletteFeature { case openSettings case newWorktree case openRepository + case newWorkspace case deleteWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees @@ -352,6 +353,15 @@ private func globalCommandItems(showsNewWorktreeAction: Bool) -> [CommandPalette kind: .openRepository, keywords: ["repo", "add repo"] ), + CommandPaletteItem( + id: CommandPaletteItemID.globalNewWorkspace, + title: "New Workspace", + subtitle: nil, + kind: .newWorkspace, + category: .app, + defaultSuggestion: true, + keywords: ["workspace", "multi repo", "many repos"] + ), ] if showsNewWorktreeAction { items.append( diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift index cd9deae3..b32dd849 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift @@ -6,6 +6,7 @@ enum CommandPaletteItemID { static let globalCheckForUpdates = "global.check-for-updates" static let globalOpenSettings = "global.open-settings" static let globalOpenRepository = "global.open-repository" + static let globalNewWorkspace = "global.new-workspace" static let globalNewWorktree = "global.new-worktree" static let globalRefreshWorktrees = "global.refresh-worktrees" static let globalJumpToLatestUnread = "global.jump-to-latest-unread" @@ -42,6 +43,7 @@ enum CommandPaletteItemID { globalCheckForUpdates, globalOpenSettings, globalOpenRepository, + globalNewWorkspace, globalNewWorktree, globalRefreshWorktrees, globalJumpToLatestUnread, @@ -178,6 +180,7 @@ func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature. .openSettings, .newWorktree, .openRepository, + .newWorkspace, .viewArchivedWorktrees, .refreshWorktrees, .jumpToLatestUnread, @@ -217,6 +220,8 @@ func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeatu return .newWorktree case .openRepository: return .openRepository + case .newWorkspace: + return .newWorkspace case .viewArchivedWorktrees: return .viewArchivedWorktrees case .refreshWorktrees: @@ -300,6 +305,7 @@ func pullRequestDelegateAction( .openSettings, .newWorktree, .openRepository, + .newWorkspace, .viewArchivedWorktrees, .refreshWorktrees, .jumpToLatestUnread, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 206a9844..44ec7db9 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -515,7 +515,7 @@ private struct CommandPaletteRowView: View { private var badge: String? { switch row.kind { - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, + case .checkForUpdates, .openRepository, .newWorkspace, .openSettings, .newWorktree, .viewArchivedWorktrees, .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, @@ -543,6 +543,8 @@ private struct CommandPaletteRowView: View { return "arrow.down.circle" case .openRepository: return "folder" + case .newWorkspace: + return "folder.badge.person.crop" case .openSettings: return "gearshape" case .newWorktree: @@ -628,7 +630,7 @@ private struct CommandPaletteRowView: View { private var emphasis: Bool { switch row.kind { - case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, + case .checkForUpdates, .openRepository, .newWorkspace, .openSettings, .newWorktree, .viewArchivedWorktrees, .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, @@ -728,6 +730,8 @@ private struct CommandPaletteRowView: View { base = "Check for Updates" case .openRepository: base = "Open Repository" + case .newWorkspace: + base = "New Workspace" case .openSettings: base = "Open Settings" case .newWorktree: diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 63b04ec6..146137c3 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -10,7 +10,8 @@ extension RepositoriesFeature { action: Action ) -> Effect { switch action { - case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement: + case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement, + .workspaceCreation: return .none case .activeAgents(.entryTapped(let id)): @@ -717,6 +718,18 @@ extension RepositoriesFeature { case .worktreeCreationPrompt: return .none + case .workspaceCreationPrompt(.presented(.delegate(.cancel))): + return .send(.workspaceCreation(.promptCanceled)) + + case .workspaceCreationPrompt(.presented(.delegate(.submit(let draft)))): + return .send(.workspaceCreation(.createWorkspace(draft))) + + case .workspaceCreationPrompt(.dismiss): + return .send(.workspaceCreation(.promptDismissed)) + + case .workspaceCreationPrompt: + return .none + case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): return .send(.worktreeLifecycle(.archiveWorktreeConfirmed(worktreeID, repositoryID))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift new file mode 100644 index 00000000..732b76cb --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -0,0 +1,117 @@ +import ComposableArchitecture +import Foundation + +extension RepositoriesFeature.State { + var canCreateWorkspace: Bool { + workspaceCreationCandidates.count >= 2 + } + + var workspaceCreationCandidates: [ProjectWorkspaceCreationRepository] { + repositories.compactMap { repository in + guard !repository.isWorkspace, !removingRepositoryIDs.contains(repository.id) else { + return nil + } + let name = repositoryCustomTitles[repository.id] ?? repository.name + return ProjectWorkspaceCreationRepository( + id: repository.id, + name: name, + rootURL: repository.rootURL, + branchName: repository.worktrees.first(where: \.isMain)?.name + ) + } + } +} + +extension RepositoriesFeature { + func reduceWorkspaceCreation( + state: inout State, + action: WorkspaceCreationAction + ) -> Effect { + switch action { + case .promptRequested: + let candidates = state.workspaceCreationCandidates + guard candidates.count >= 2 else { + state.alert = messageAlert( + title: "Unable to create workspace", + message: "Open at least two repositories to create a workspace." + ) + return .none + } + let title = workspaceCreationDefaultTitle(candidates: candidates) + state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + candidates: candidates, + title: title, + rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), + selectedRepositoryIDs: Set(candidates.map(\.id)) + ) + return .none + + case .promptCanceled, .promptDismissed: + state.workspaceCreationPrompt = nil + return .cancel(id: CancelID.workspaceCreation) + + case .createWorkspace(let draft): + state.workspaceCreationPrompt?.isCreating = true + state.workspaceCreationPrompt?.validationMessage = nil + let request = ProjectWorkspaceCreationRequest(draft: draft, createdAt: now) + return .run { send in + do { + _ = try ProjectWorkspace.create(request) + await send(.workspaceCreation(.workspaceCreated(request.draft.rootURL))) + } catch { + await send(.workspaceCreation(.workspaceCreationFailed(error.localizedDescription))) + } + } + .cancellable(id: CancelID.workspaceCreation, cancelInFlight: true) + + case .workspaceCreated(let rootURL): + analyticsClient.capture("workspace_created", [String: Any]?.none) + state.workspaceCreationPrompt = nil + return .merge( + .send(.showToast(.success("Workspace created"))), + .send(.repositoryManagement(.openRepositories([rootURL]))) + ) + + case .workspaceCreationFailed(let message): + if state.workspaceCreationPrompt != nil { + state.workspaceCreationPrompt?.isCreating = false + state.workspaceCreationPrompt?.validationMessage = message + } else { + state.alert = messageAlert(title: "Unable to create workspace", message: message) + } + return .none + } + } + + var workspaceCreationReducer: some ReducerOf { + Reduce { state, action in + guard case .workspaceCreation(let action) = action else { + return .none + } + return reduceWorkspaceCreation(state: &state, action: action) + } + } + + private func workspaceCreationDefaultTitle(candidates: [ProjectWorkspaceCreationRepository]) -> String { + let names = candidates.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if names.count <= 3 { + return names.joined(separator: " + ") + } + guard let first = names.first else { + return "Workspace" + } + return "\(first) + \(names.count - 1) repos" + } + + private func defaultWorkspaceRootURL(title: String) -> URL { + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + let baseURL = SupacodePaths.workspacesDirectory + var candidateURL = baseURL.appending(path: folderName, directoryHint: .isDirectory) + var suffix = 2 + while FileManager.default.fileExists(atPath: candidateURL.path(percentEncoded: false)) { + candidateURL = baseURL.appending(path: "\(folderName)-\(suffix)", directoryHint: .isDirectory) + suffix += 1 + } + return candidateURL.standardizedFileURL + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index cc6f3eab..37b1ab91 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -78,6 +78,7 @@ struct RepositoriesFeature { static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" + static let workspaceCreation = "repositories.workspaceCreation" static func archiveScript(_ worktreeID: Worktree.ID) -> String { "repositories.archiveScript.\(worktreeID)" } @@ -212,6 +213,16 @@ struct RepositoriesFeature { case openRepositorySettings(Repository.ID) } + @CasePathable + enum WorkspaceCreationAction: Equatable { + case promptRequested + case promptCanceled + case promptDismissed + case createWorkspace(ProjectWorkspaceCreationDraft) + case workspaceCreated(URL) + case workspaceCreationFailed(String) + } + @ObservableState struct State: Equatable { var repositories: IdentifiedArrayOf = [] @@ -285,6 +296,7 @@ struct RepositoriesFeature { var activeAgents = ActiveAgentsFeature.State() @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? + @Presents var workspaceCreationPrompt: WorkspaceCreationPromptFeature.State? @Presents var alert: AlertState? } @@ -317,6 +329,7 @@ struct RepositoriesFeature { case worktreeOrdering(WorktreeOrderingAction) case githubIntegration(GithubIntegrationAction) case repositoryManagement(RepositoryManagementAction) + case workspaceCreation(WorkspaceCreationAction) case activeAgents(ActiveAgentsFeature.Action) case task case repositorySnapshotLoaded([Repository]?) @@ -373,6 +386,7 @@ struct RepositoriesFeature { case showToast(StatusToast) case dismissToast case worktreeCreationPrompt(PresentationAction) + case workspaceCreationPrompt(PresentationAction) case alert(PresentationAction) case delegate(Delegate) } @@ -461,6 +475,7 @@ struct RepositoriesFeature { worktreeOrderingReducer githubIntegrationReducer repositoryManagementReducer + workspaceCreationReducer Scope(state: \.activeAgents, action: \.activeAgents) { ActiveAgentsFeature() } @@ -468,6 +483,9 @@ struct RepositoriesFeature { .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { WorktreeCreationPromptFeature() } + .ifLet(\.$workspaceCreationPrompt, action: \.workspaceCreationPrompt) { + WorkspaceCreationPromptFeature() + } } } @@ -478,3 +496,4 @@ struct RepositoriesFeature { // - RepositoriesFeature+WorktreeOrdering.swift // - RepositoriesFeature+GithubIntegration.swift // - RepositoriesFeature+RepositoryManagement.swift +// - RepositoriesFeature+WorkspaceCreation.swift diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift new file mode 100644 index 00000000..2b7355d9 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -0,0 +1,111 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct WorkspaceCreationPromptFeature { + @ObservableState + struct State: Equatable { + let candidates: [ProjectWorkspaceCreationRepository] + var title: String + var rootPath: String + var selectedRepositoryIDs: Set + var validationMessage: String? + var isCreating = false + + var selectedRepositoryCount: Int { + candidates.count { selectedRepositoryIDs.contains($0.id) } + } + + var selectedRepositories: [ProjectWorkspaceCreationRepository] { + candidates.filter { selectedRepositoryIDs.contains($0.id) } + } + + var rootPathPreview: String { + PathPolicy.normalizePath(rootPath, resolvingSymlinks: false) ?? rootPath + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case repositorySelectionChanged(Repository.ID, Bool) + case rootPathChosen(String) + case cancelButtonTapped + case createButtonTapped + case setCreating(Bool) + case setValidationMessage(String?) + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case cancel + case submit(ProjectWorkspaceCreationDraft) + } + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + state.validationMessage = nil + return .none + + case .repositorySelectionChanged(let repositoryID, let isSelected): + if isSelected { + state.selectedRepositoryIDs.insert(repositoryID) + } else { + state.selectedRepositoryIDs.remove(repositoryID) + } + state.validationMessage = nil + return .none + + case .rootPathChosen(let path): + state.rootPath = path + state.validationMessage = nil + return .none + + case .cancelButtonTapped: + return .send(.delegate(.cancel)) + + case .createButtonTapped: + let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + state.validationMessage = ProjectWorkspaceCreationError.missingTitle.localizedDescription + return .none + } + guard let rootPath = PathPolicy.normalizePath(state.rootPath, resolvingSymlinks: false) else { + state.validationMessage = ProjectWorkspaceCreationError.missingPath.localizedDescription + return .none + } + let repositories = state.selectedRepositories + guard repositories.count >= 2 else { + state.validationMessage = ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription + return .none + } + state.validationMessage = nil + return .send( + .delegate( + .submit( + ProjectWorkspaceCreationDraft( + title: title, + rootURL: URL(filePath: rootPath, directoryHint: .isDirectory), + repositories: repositories + ) + ) + ) + ) + + case .setCreating(let isCreating): + state.isCreating = isCreating + return .none + + case .setValidationMessage(let message): + state.validationMessage = message + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index be5a1ac0..f4d742e7 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -194,7 +194,14 @@ struct SidebarListView: View { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { - ToolbarItem(placement: .automatic) { + ToolbarItemGroup(placement: .automatic) { + Button { + store.send(.workspaceCreation(.promptRequested)) + } label: { + Label("New Workspace", systemImage: "folder.badge.person.crop") + } + .help("New Workspace") + .disabled(!state.canCreateWorkspace) Button { store.send(.setOpenPanelPresented(true)) } label: { diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift new file mode 100644 index 00000000..717c81dd --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -0,0 +1,153 @@ +import AppKit +import ComposableArchitecture +import SwiftUI + +struct WorkspaceCreationPromptView: View { + @Bindable var store: StoreOf + @FocusState private var isTitleFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("New Workspace") + .font(.title3) + Text("\(store.selectedRepositoryCount) of \(store.candidates.count) repositories selected") + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Title") + .foregroundStyle(.secondary) + TextField("Workspace title", text: $store.title) + .textFieldStyle(.roundedBorder) + .focused($isTitleFieldFocused) + .disabled(store.isCreating) + .onSubmit { + store.send(.createButtonTapped) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Folder") + .foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("Workspace folder", text: $store.rootPath) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + Button { + chooseFolder() + } label: { + Label("Choose Folder", systemImage: "folder") + } + .help("Choose Workspace Folder") + .disabled(store.isCreating) + } + Text(store.rootPathPreview) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Repositories") + .foregroundStyle(.secondary) + ScrollView { + VStack(spacing: 0) { + ForEach(store.candidates) { repository in + repositoryToggle(repository) + if repository.id != store.candidates.last?.id { + Divider() + } + } + } + } + .frame(maxHeight: 220) + .clipShape(.rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + + if let message = store.validationMessage, !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + } + + HStack { + if store.isCreating { + ProgressView() + .controlSize(.small) + } + Spacer() + Button("Cancel") { + store.send(.cancelButtonTapped) + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + .disabled(store.isCreating) + Button("Create") { + store.send(.createButtonTapped) + } + .keyboardShortcut(.defaultAction) + .help("Create Workspace (↩)") + .disabled(store.isCreating) + } + } + .padding(20) + .frame(minWidth: 520) + .task { + isTitleFieldFocused = true + } + } + + private func repositoryToggle(_ repository: ProjectWorkspaceCreationRepository) -> some View { + Toggle( + isOn: Binding( + get: { store.selectedRepositoryIDs.contains(repository.id) }, + set: { store.send(.repositorySelectionChanged(repository.id, $0)) } + ) + ) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(repository.name) + .fontWeight(.medium) + if let branchName = repository.branchName { + Text(branchName) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + Text(repository.rootURL.path(percentEncoded: false)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(.checkbox) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.directoryURL = URL(filePath: store.rootPath).deletingLastPathComponent() + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + store.send(.rootPathChosen(url.path(percentEncoded: false))) + } + } +} diff --git a/supacode/Support/SupacodePaths.swift b/supacode/Support/SupacodePaths.swift index 2c560a34..b590c295 100644 --- a/supacode/Support/SupacodePaths.swift +++ b/supacode/Support/SupacodePaths.swift @@ -57,6 +57,10 @@ nonisolated enum SupacodePaths { baseDirectory.appending(path: "repos", directoryHint: .isDirectory) } + static var workspacesDirectory: URL { + baseDirectory.appending(path: "workspaces", directoryHint: .isDirectory) + } + static func repositoryDirectory(for rootURL: URL) -> URL { let name = repositoryDirectoryName(for: rootURL) return reposDirectory.appending(path: name, directoryHint: .isDirectory) diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 5f80733c..ea676ec8 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -369,6 +369,26 @@ struct AppFeatureCommandPaletteTests { } } + @Test(.dependencies) func newWorkspaceDispatchesPromptRequest() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + let expectedAlert = AlertState { + TextState("Unable to create workspace") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState("Open at least two repositories to create a workspace.") + } + + await store.send(.commandPalette(.delegate(.newWorkspace))) + await store.receive(\.repositories.workspaceCreation.promptRequested) { + $0.repositories.alert = expectedAlert + } + } + @Test(.dependencies) func refreshWorktreesDispatchesRefresh() async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index cd3f46f5..d5f27288 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -14,6 +14,7 @@ struct CommandPaletteFeatureTests { "global.check-for-updates", "global.open-settings", "global.open-repository", + "global.new-workspace", "global.new-worktree", "global.refresh-worktrees", "global.jump-to-latest-unread", @@ -1704,7 +1705,7 @@ private func makeItem( private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteItem.Category { switch kind { - case .checkForUpdates, .openSettings, .openRepository, .installCLI, + case .checkForUpdates, .openSettings, .openRepository, .newWorkspace, .installCLI, .openRepositorySettings: return .app case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, @@ -1733,7 +1734,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { switch kind { - case .checkForUpdates, .openSettings, .openRepository, .installCLI, + case .checkForUpdates, .openSettings, .openRepository, .newWorkspace, .installCLI, .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .jumpToLatestUnread, .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 25219115..5f50d9c0 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -126,6 +126,62 @@ struct ProjectWorkspaceTests { #expect(normalized == [PersistedRepositoryEntry(path: rootPath, kind: .plain)]) } + @Test func createWorkspaceWritesMetadataAndRepositoryLinks() throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-created-workspace-\(UUID().uuidString)") + .standardizedFileURL + let appURL = try makeTemporaryWorkspaceRoot() + let apiURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + try? FileManager.default.removeItem(at: apiURL) + } + let createdAt = Date(timeIntervalSince1970: 1_234_567) + let workspace = try ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Checkout Flow", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceCreationRepository( + id: "app", + name: "App Repo", + rootURL: appURL, + branchName: "main" + ), + ProjectWorkspaceCreationRepository( + id: "api", + name: "App Repo", + rootURL: apiURL, + branchName: "feature/api" + ), + ] + ), + createdAt: createdAt + ) + ) + + #expect(workspace.title == "Checkout Flow") + #expect(workspace.createdAt == createdAt) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.repositories.map(\.path) == ["App-Repo", "App-Repo-2"]) + #expect(loaded.repositories.map(\.sourceKind) == [.existingPath, .existingPath]) + let appPath = normalizedTestPath(appURL) + let apiPath = normalizedTestPath(apiURL) + #expect( + loaded.repositories.map(\.sourceLocation) == [ + appPath, + apiPath, + ]) + #expect(loaded.repositories.map(\.branchName) == ["main", "feature/api"]) + + let appLinkPath = rootURL.appending(path: "App-Repo").path(percentEncoded: false) + let apiLinkPath = rootURL.appending(path: "App-Repo-2").path(percentEncoded: false) + #expect(URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == appPath) + #expect(URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == apiPath) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( @@ -158,4 +214,12 @@ struct ProjectWorkspaceTests { try FileManager.default.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) } + + private func normalizedTestPath(_ url: URL) -> String { + var path = PathPolicy.normalizeURL(url).path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + return path + } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 47f7553a..3ce44367 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -572,6 +572,108 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func workspaceCreationPromptRequiresAtLeastTwoRepositories() async { + let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) + let expectedAlert = AlertState { + TextState("Unable to create workspace") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState("Open at least two repositories to create a workspace.") + } + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.workspaceCreation(.promptRequested)) { + $0.alert = expectedAlert + } + } + + @Test func workspaceCreationPromptUsesLoadedRepositories() async { + let testID = UUID().uuidString + let repoRootA = "/tmp/\(testID)-repo-a" + let repoRootB = "/tmp/\(testID)-repo-b" + let worktreeA = makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA) + let repoA = makeRepository(id: repoRootA, name: "Repo A", worktrees: [worktreeA]) + let repoB = makeRepository(id: repoRootB, name: "Repo B", worktrees: []) + var state = makeState(repositories: [repoA, repoB]) + state.repositoryCustomTitles[repoB.id] = "API" + let title = "Repo A + API" + let expectedRootPath = SupacodePaths.workspacesDirectory + .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.workspaceCreation(.promptRequested)) { + $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + candidates: [ + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + branchName: "main" + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "API", + rootURL: URL(fileURLWithPath: repoRootB) + ), + ], + title: title, + rootPath: expectedRootPath, + selectedRepositoryIDs: [repoRootA, repoRootB] + ) + } + } + + @Test func workspaceCreationPromptSubmitBuildsDraft() async { + let repoRootA = "/tmp/repo-a" + let repoRootB = "/tmp/repo-b" + let repositories = [ + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + branchName: "main" + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "Repo B", + rootURL: URL(fileURLWithPath: repoRootB) + ), + ] + let rootURL = URL(fileURLWithPath: "/tmp/multi-repo-workspace") + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + candidates: repositories, + title: " Multi Repo ", + rootPath: " /tmp/multi-repo-workspace ", + selectedRepositoryIDs: [repoRootA, repoRootB] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + ProjectWorkspaceCreationDraft( + title: "Multi Repo", + rootURL: rootURL, + repositories: repositories + ) + ) + ) + ) + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) -- 2.51.2 From 32e15f550ead82449f6e06cde454fa5f0ba59df4 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 9 Jun 2026 20:32:23 +0800 Subject: [PATCH 442/528] Complete workspace repository materialization --- docs/components/workspaces.md | 22 +- supacode/Domain/ProjectWorkspace.swift | 303 ++++++++++++++++-- ...epositoriesFeature+WorkspaceCreation.swift | 33 +- .../WorkspaceCreationPromptFeature.swift | 117 ++++++- .../Views/WorkspaceCreationPromptView.swift | 250 +++++++++++++-- .../AppFeatureCommandPaletteTests.swift | 21 +- supacodeTests/ProjectWorkspaceTests.swift | 70 +++- supacodeTests/RepositoriesFeatureTests.swift | 82 ++++- 8 files changed, 789 insertions(+), 109 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 2f5ed2fd..5697eda9 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -19,9 +19,9 @@ When you open a workspace in Prowl: ## Folder layout Use **New Workspace** from the sidebar toolbar, Worktrees menu, or command -palette to create a workspace from repositories already opened in Prowl. Prowl -creates the shared folder, writes `.prowl/workspace.json`, and places symlinks -to the selected repositories in the workspace root. +palette to create a workspace. Prowl creates the shared folder, materializes the +selected repositories, writes `.prowl/workspace.json`, and opens the workspace +as a runnable folder. ```text my-feature-workspace/ @@ -32,10 +32,18 @@ my-feature-workspace/ └─ shared-package/ ``` -The current creation flow supports existing local repositories that are already -loaded in Prowl. Prowl still reads hand-authored metadata, but it does not yet -clone remote repositories or materialize git worktrees from bare repositories by -itself. +Repository sources can be mixed in one workspace: + +- Already opened repositories are added as symlinks with `source_kind: + existing_path`. +- Local repository folders selected from disk are added as symlinks with + `source_kind: local_repository`. +- Remote repositories are cloned into the workspace folder with `source_kind: + remote`. If a branch and base ref are supplied, Prowl checks out the branch + from the base after cloning. +- Bare repositories are materialized with `git worktree add` and recorded with + `source_kind: bare_repository`. If both branch and base ref are supplied, + Prowl creates the worktree branch from that base ref. ## Metadata diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 71c1fe8e..b010bb37 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -10,19 +10,58 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { var id: String var name: String - var rootURL: URL + var path: String? + var sourceKind: ProjectWorkspaceRepositorySourceKind + var sourceLocation: String var branchName: String? + var baseRef: String? init( id: String, name: String, rootURL: URL, - branchName: String? = nil + branchName: String? = nil, + path: String? = nil ) { + let normalizedURL = rootURL.standardizedFileURL self.id = id self.name = name - self.rootURL = rootURL.standardizedFileURL + self.path = path + sourceKind = .existingPath + sourceLocation = normalizedURL.path(percentEncoded: false) self.branchName = branchName + baseRef = nil + } + + init( + id: String, + name: String, + sourceKind: ProjectWorkspaceRepositorySourceKind, + sourceLocation: String, + branchName: String? = nil, + baseRef: String? = nil, + path: String? = nil + ) { + self.id = id + self.name = name + self.path = path + self.sourceKind = sourceKind + self.sourceLocation = sourceLocation + self.branchName = branchName + self.baseRef = baseRef + } + + var localSourceURL: URL? { + switch sourceKind { + case .existingPath, .localRepository, .bareRepository: + let trimmed = sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return URL(fileURLWithPath: trimmed).standardizedFileURL + case .remote: + return nil + } } } @@ -47,14 +86,30 @@ nonisolated struct ProjectWorkspaceCreationRequest: Equatable, Sendable { var createdAt: Date } +nonisolated struct ProjectWorkspaceGitCommand: Equatable, Sendable { + var arguments: [String] + var currentDirectoryURL: URL? + + var displayCommand: String { + (["git"] + arguments).joined(separator: " ") + } +} + +nonisolated struct ProjectWorkspaceGitRunner: Sendable { + var run: @Sendable (ProjectWorkspaceGitCommand) async throws -> Void +} + nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Sendable { case missingTitle case missingPath case notEnoughRepositories + case missingRepositoryName + case missingRepositorySource(String) case destinationIsFile(String) case workspaceAlreadyExists(String) case repositoryDoesNotExist(String) case linkAlreadyExists(String) + case gitCommandFailed(command: String, message: String) var errorDescription: String? { switch self { @@ -64,6 +119,10 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda return "Workspace folder required." case .notEnoughRepositories: return "Select at least two repositories." + case .missingRepositoryName: + return "Repository name required." + case .missingRepositorySource(let name): + return "Source required for \(name)." case .destinationIsFile(let path): return "\(path) is a file. Choose a folder path instead." case .workspaceAlreadyExists(let path): @@ -72,6 +131,11 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda return "\(path) does not exist." case .linkAlreadyExists(let path): return "\(path) already exists." + case .gitCommandFailed(let command, let message): + if message.isEmpty { + return "Git command failed: \(command)" + } + return "Git command failed: \(command)\n\(message)" } } } @@ -259,8 +323,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { static func create( _ request: ProjectWorkspaceCreationRequest, - fileManager: FileManager = .default - ) throws -> ProjectWorkspace { + fileManager: FileManager = .default, + gitRunner: ProjectWorkspaceGitRunner + ) async throws -> ProjectWorkspace { let title = request.draft.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw ProjectWorkspaceCreationError.missingTitle @@ -277,6 +342,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { var createdRoot = false var createdMetadataDirectory = false var createdURLs: [URL] = [] + var cleanupCommands: [ProjectWorkspaceGitCommand] = [] do { var isDirectory = ObjCBool(false) if fileManager.fileExists(atPath: rootPath, isDirectory: &isDirectory) { @@ -300,32 +366,20 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } var occupiedNames: Set = [] - let entries = try request.draft.repositories.map { repository in - let repositoryPath = normalizedPath(repository.rootURL, resolvingSymlinks: true) - let repositoryURL = URL(fileURLWithPath: repositoryPath, isDirectory: true).standardizedFileURL - var repositoryIsDirectory = ObjCBool(false) - guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), - repositoryIsDirectory.boolValue - else { - throw ProjectWorkspaceCreationError.repositoryDoesNotExist(repositoryPath) - } - - let linkName = uniqueRepositoryLinkName(for: repository, occupiedNames: &occupiedNames) - let linkURL = rootURL.appending(path: linkName, directoryHint: .isDirectory) - let linkPath = linkURL.path(percentEncoded: false) - guard !fileManager.fileExists(atPath: linkPath) else { - throw ProjectWorkspaceCreationError.linkAlreadyExists(linkPath) - } - try fileManager.createSymbolicLink(at: linkURL, withDestinationURL: repositoryURL) - createdURLs.append(linkURL) - return RepositoryEntry( - id: repository.id, - name: repository.name, - path: linkName, - sourceKind: .existingPath, - sourceLocation: repositoryPath, - branchName: repository.branchName + var entries: [RepositoryEntry] = [] + for repository in request.draft.repositories { + let prepared = try await materialize( + repository, + workspaceRootURL: rootURL, + occupiedNames: &occupiedNames, + fileManager: fileManager, + gitRunner: gitRunner ) + createdURLs.append(prepared.createdURL) + if let cleanupCommand = prepared.cleanupCommand { + cleanupCommands.append(cleanupCommand) + } + entries.append(prepared.entry) } let workspace = ProjectWorkspace( @@ -343,6 +397,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { createdURLs.append(metadataURL) return workspace } catch { + for command in cleanupCommands.reversed() { + try? await gitRunner.run(command) + } for url in createdURLs.reversed() { try? fileManager.removeItem(at: url) } @@ -360,13 +417,168 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return sanitized.isEmpty ? "workspace" : sanitized } - private static func uniqueRepositoryLinkName( + private struct PreparedRepository { + var entry: RepositoryEntry + var createdURL: URL + var cleanupCommand: ProjectWorkspaceGitCommand? + } + + private static func materialize( + _ repository: ProjectWorkspaceCreationRepository, + workspaceRootURL: URL, + occupiedNames: inout Set, + fileManager: FileManager, + gitRunner: ProjectWorkspaceGitRunner + ) async throws -> PreparedRepository { + let name = repositoryDisplayName(repository) + guard !name.isEmpty else { + throw ProjectWorkspaceCreationError.missingRepositoryName + } + let sourceLocation = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceLocation.isEmpty else { + throw ProjectWorkspaceCreationError.missingRepositorySource(name) + } + let workspacePath = uniqueRepositoryPath(for: repository, displayName: name, occupiedNames: &occupiedNames) + let destinationURL = workspaceRootURL.appending(path: workspacePath, directoryHint: .isDirectory) + let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) + guard !fileManager.fileExists(atPath: destinationPath) else { + throw ProjectWorkspaceCreationError.linkAlreadyExists(destinationPath) + } + + let sourceKind = repository.sourceKind + let normalizedSourceLocation: String? + let cleanupCommand: ProjectWorkspaceGitCommand? + switch sourceKind { + case .existingPath, .localRepository: + let sourcePath = try localRepositoryPath(repository, sourceLocation: sourceLocation, fileManager: fileManager) + let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + normalizedSourceLocation = sourcePath + cleanupCommand = nil + + case .remote: + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["clone", sourceLocation, destinationPath], + currentDirectoryURL: workspaceRootURL + ) + ) + try await checkoutIfNeeded( + repository, + destinationURL: destinationURL, + gitRunner: gitRunner + ) + normalizedSourceLocation = sourceLocation + cleanupCommand = nil + + case .bareRepository: + let sourcePath = try localRepositoryPath(repository, sourceLocation: sourceLocation, fileManager: fileManager) + let command = bareWorktreeCommand( + repository, + sourcePath: sourcePath, + destinationPath: destinationPath + ) + try await gitRunner.run(command) + normalizedSourceLocation = sourcePath + cleanupCommand = ProjectWorkspaceGitCommand( + arguments: ["-C", sourcePath, "worktree", "remove", "--force", destinationPath], + currentDirectoryURL: nil + ) + } + + return PreparedRepository( + entry: RepositoryEntry( + id: repository.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? workspacePath, + name: name, + path: workspacePath, + sourceKind: sourceKind, + sourceLocation: normalizedSourceLocation, + branchName: repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + baseRef: repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ), + createdURL: destinationURL, + cleanupCommand: cleanupCommand + ) + } + + private static func checkoutIfNeeded( + _ repository: ProjectWorkspaceCreationRepository, + destinationURL: URL, + gitRunner: ProjectWorkspaceGitRunner + ) async throws { + let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) + let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + switch (branchName, baseRef) { + case (.some(let branchName), .some(let baseRef)): + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", "-B", branchName, baseRef], + currentDirectoryURL: nil + ) + ) + case (.some(let branchName), .none): + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", "-B", branchName], + currentDirectoryURL: nil + ) + ) + case (.none, .some(let baseRef)): + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", baseRef], + currentDirectoryURL: nil + ) + ) + case (.none, .none): + break + } + } + + private static func bareWorktreeCommand( + _ repository: ProjectWorkspaceCreationRepository, + sourcePath: String, + destinationPath: String + ) -> ProjectWorkspaceGitCommand { + let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + var arguments = ["-C", sourcePath, "worktree", "add"] + if let branchName, let baseRef { + arguments += ["-b", branchName, destinationPath, baseRef] + } else if let branchName { + arguments += [destinationPath, branchName] + } else if let baseRef { + arguments += [destinationPath, baseRef] + } else { + arguments += [destinationPath] + } + return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) + } + + private static func localRepositoryPath( + _ repository: ProjectWorkspaceCreationRepository, + sourceLocation: String, + fileManager: FileManager + ) throws -> String { + let repositoryPath = normalizedPath(URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) + var repositoryIsDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), + repositoryIsDirectory.boolValue + else { + throw ProjectWorkspaceCreationError.repositoryDoesNotExist(repositoryPath) + } + return repositoryPath + } + + private static func uniqueRepositoryPath( for repository: ProjectWorkspaceCreationRepository, + displayName: String, occupiedNames: inout Set ) -> String { - var baseName = sanitizedWorkspaceComponent(repository.name) + var baseName = sanitizedWorkspaceComponent(repository.path ?? "") if baseName.isEmpty { - baseName = sanitizedWorkspaceComponent(repository.rootURL.lastPathComponent) + baseName = sanitizedWorkspaceComponent(displayName) } if baseName.isEmpty { baseName = "repository" @@ -382,6 +594,31 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return candidate } + private static func repositoryDisplayName(_ repository: ProjectWorkspaceCreationRepository) -> String { + let trimmedName = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + return trimmedName + } + switch repository.sourceKind { + case .existingPath, .localRepository, .bareRepository: + if let localSourceURL = repository.localSourceURL { + return Repository.name(for: localSourceURL) + } + case .remote: + let source = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + let lastPathComponent = + source + .split(separator: "/") + .last + .map(String.init)? + .replacing(".git", with: "") + if let lastPathComponent, !lastPathComponent.isEmpty { + return lastPathComponent + } + } + return "" + } + private static func sanitizedWorkspaceComponent(_ value: String) -> String { var result = "" for scalar in value.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 732b76cb..522e1092 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -3,7 +3,7 @@ import Foundation extension RepositoriesFeature.State { var canCreateWorkspace: Bool { - workspaceCreationCandidates.count >= 2 + true } var workspaceCreationCandidates: [ProjectWorkspaceCreationRepository] { @@ -30,16 +30,9 @@ extension RepositoriesFeature { switch action { case .promptRequested: let candidates = state.workspaceCreationCandidates - guard candidates.count >= 2 else { - state.alert = messageAlert( - title: "Unable to create workspace", - message: "Open at least two repositories to create a workspace." - ) - return .none - } let title = workspaceCreationDefaultTitle(candidates: candidates) state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( - candidates: candidates, + repositories: candidates, title: title, rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), selectedRepositoryIDs: Set(candidates.map(\.id)) @@ -54,9 +47,26 @@ extension RepositoriesFeature { state.workspaceCreationPrompt?.isCreating = true state.workspaceCreationPrompt?.validationMessage = nil let request = ProjectWorkspaceCreationRequest(draft: draft, createdAt: now) + let shellClient = shellClient + let gitRunner = ProjectWorkspaceGitRunner { command in + do { + _ = try await shellClient.run( + URL(fileURLWithPath: "/usr/bin/env"), + ["git"] + command.arguments, + command.currentDirectoryURL + ) + } catch let error as ShellClientError { + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: command.displayCommand, + message: error.stderr.isEmpty ? error.stdout : error.stderr + ) + } catch { + throw error + } + } return .run { send in do { - _ = try ProjectWorkspace.create(request) + _ = try await ProjectWorkspace.create(request, gitRunner: gitRunner) await send(.workspaceCreation(.workspaceCreated(request.draft.rootURL))) } catch { await send(.workspaceCreation(.workspaceCreationFailed(error.localizedDescription))) @@ -94,6 +104,9 @@ extension RepositoriesFeature { private func workspaceCreationDefaultTitle(candidates: [ProjectWorkspaceCreationRepository]) -> String { let names = candidates.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if names.isEmpty { + return "Workspace" + } if names.count <= 3 { return names.joined(separator: " + ") } diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 2b7355d9..91aeedb6 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -1,11 +1,12 @@ import ComposableArchitecture import Foundation +import IdentifiedCollections @Reducer struct WorkspaceCreationPromptFeature { @ObservableState struct State: Equatable { - let candidates: [ProjectWorkspaceCreationRepository] + var repositories: IdentifiedArrayOf var title: String var rootPath: String var selectedRepositoryIDs: Set @@ -13,21 +14,42 @@ struct WorkspaceCreationPromptFeature { var isCreating = false var selectedRepositoryCount: Int { - candidates.count { selectedRepositoryIDs.contains($0.id) } + repositories.count { selectedRepositoryIDs.contains($0.id) } } var selectedRepositories: [ProjectWorkspaceCreationRepository] { - candidates.filter { selectedRepositoryIDs.contains($0.id) } + repositories.filter { selectedRepositoryIDs.contains($0.id) } } var rootPathPreview: String { PathPolicy.normalizePath(rootPath, resolvingSymlinks: false) ?? rootPath } + + init( + repositories: [ProjectWorkspaceCreationRepository], + title: String, + rootPath: String, + selectedRepositoryIDs: Set + ) { + self.repositories = IdentifiedArray(repositories, uniquingIDsWith: { current, _ in current }) + self.title = title + self.rootPath = rootPath + self.selectedRepositoryIDs = selectedRepositoryIDs + } } enum Action: BindableAction, Equatable { case binding(BindingAction) + case addBlankRepository(ProjectWorkspaceRepositorySourceKind) + case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) + case removeRepository(Repository.ID) case repositorySelectionChanged(Repository.ID, Bool) + case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) + case repositoryNameChanged(Repository.ID, String) + case repositoryPathChanged(Repository.ID, String) + case repositorySourceLocationChanged(Repository.ID, String) + case repositoryBranchNameChanged(Repository.ID, String) + case repositoryBaseRefChanged(Repository.ID, String) case rootPathChosen(String) case cancelButtonTapped case createButtonTapped @@ -42,6 +64,8 @@ struct WorkspaceCreationPromptFeature { case submit(ProjectWorkspaceCreationDraft) } + @Dependency(\.uuid) var uuid + var body: some Reducer { BindingReducer() Reduce { state, action in @@ -50,6 +74,46 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .addBlankRepository(let sourceKind): + let id = uuid().uuidString + state.repositories.append( + ProjectWorkspaceCreationRepository( + id: id, + name: "", + sourceKind: sourceKind, + sourceLocation: "" + ) + ) + state.selectedRepositoryIDs.insert(id) + state.validationMessage = nil + return .none + + case .addRepositoryFromURL(let sourceKind, let path): + guard let rootPath = PathPolicy.normalizePath(path) else { + state.validationMessage = + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + return .none + } + let url = URL(fileURLWithPath: rootPath).standardizedFileURL + let id = uuid().uuidString + state.repositories.append( + ProjectWorkspaceCreationRepository( + id: id, + name: Repository.name(for: url), + sourceKind: sourceKind, + sourceLocation: rootPath + ) + ) + state.selectedRepositoryIDs.insert(id) + state.validationMessage = nil + return .none + + case .removeRepository(let repositoryID): + state.repositories.remove(id: repositoryID) + state.selectedRepositoryIDs.remove(repositoryID) + state.validationMessage = nil + return .none + case .repositorySelectionChanged(let repositoryID, let isSelected): if isSelected { state.selectedRepositoryIDs.insert(repositoryID) @@ -59,6 +123,43 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .repositorySourceKindChanged(let repositoryID, let sourceKind): + guard var repository = state.repositories[id: repositoryID] else { + return .none + } + repository.sourceKind = sourceKind + if sourceKind == .remote { + repository.sourceLocation = "" + } + state.repositories[id: repositoryID] = repository + state.validationMessage = nil + return .none + + case .repositoryNameChanged(let repositoryID, let name): + state.repositories[id: repositoryID]?.name = name + state.validationMessage = nil + return .none + + case .repositoryPathChanged(let repositoryID, let path): + state.repositories[id: repositoryID]?.path = path + state.validationMessage = nil + return .none + + case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): + state.repositories[id: repositoryID]?.sourceLocation = sourceLocation + state.validationMessage = nil + return .none + + case .repositoryBranchNameChanged(let repositoryID, let branchName): + state.repositories[id: repositoryID]?.branchName = branchName + state.validationMessage = nil + return .none + + case .repositoryBaseRefChanged(let repositoryID, let baseRef): + state.repositories[id: repositoryID]?.baseRef = baseRef + state.validationMessage = nil + return .none + case .rootPathChosen(let path): state.rootPath = path state.validationMessage = nil @@ -82,6 +183,16 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription return .none } + for repository in repositories { + let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + let sourceLocation = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceLocation.isEmpty else { + let displayName = name.isEmpty ? "repository" : name + state.validationMessage = + ProjectWorkspaceCreationError.missingRepositorySource(displayName).localizedDescription + return .none + } + } state.validationMessage = nil return .send( .delegate( diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 717c81dd..16931b74 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -5,13 +5,19 @@ import SwiftUI struct WorkspaceCreationPromptView: View { @Bindable var store: StoreOf @FocusState private var isTitleFieldFocused: Bool + private let sourceKinds: [ProjectWorkspaceRepositorySourceKind] = [ + .existingPath, + .localRepository, + .remote, + .bareRepository, + ] var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("New Workspace") .font(.title3) - Text("\(store.selectedRepositoryCount) of \(store.candidates.count) repositories selected") + Text("\(store.selectedRepositoryCount) of \(store.repositories.count) repositories selected") .foregroundStyle(.secondary) } @@ -54,17 +60,42 @@ struct WorkspaceCreationPromptView: View { VStack(alignment: .leading, spacing: 8) { Text("Repositories") .foregroundStyle(.secondary) + HStack(spacing: 8) { + Button { + store.send(.addBlankRepository(.remote)) + } label: { + Label("Add Remote", systemImage: "network") + } + .help("Add Remote Repository") + .disabled(store.isCreating) + + Button { + chooseRepositorySource(kind: .localRepository) + } label: { + Label("Add Local", systemImage: "folder") + } + .help("Add Local Repository") + .disabled(store.isCreating) + + Button { + chooseRepositorySource(kind: .bareRepository) + } label: { + Label("Add Bare", systemImage: "externaldrive") + } + .help("Add Bare Repository") + .disabled(store.isCreating) + } ScrollView { VStack(spacing: 0) { - ForEach(store.candidates) { repository in - repositoryToggle(repository) - if repository.id != store.candidates.last?.id { + ForEach(store.repositories) { repository in + repositoryEditor(repository) + if repository.id != store.repositories.last?.id { Divider() } } } } - .frame(maxHeight: 220) + .frame(maxHeight: 340) .clipShape(.rect(cornerRadius: 8)) .overlay { RoundedRectangle(cornerRadius: 8) @@ -99,40 +130,138 @@ struct WorkspaceCreationPromptView: View { } } .padding(20) - .frame(minWidth: 520) + .frame(minWidth: 680) .task { isTitleFieldFocused = true } } - private func repositoryToggle(_ repository: ProjectWorkspaceCreationRepository) -> some View { - Toggle( - isOn: Binding( - get: { store.selectedRepositoryIDs.contains(repository.id) }, - set: { store.send(.repositorySelectionChanged(repository.id, $0)) } + private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { + VStack(alignment: .leading, spacing: 10) { + repositoryHeader(repository) + repositoryNameAndPathFields(repository) + repositorySourceField(repository) + repositoryBranchFields(repository) + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + + private func repositoryHeader(_ repository: ProjectWorkspaceCreationRepository) -> some View { + HStack(spacing: 10) { + Toggle( + isOn: Binding( + get: { store.selectedRepositoryIDs.contains(repository.id) }, + set: { store.send(.repositorySelectionChanged(repository.id, $0)) } + ) + ) { + Text(repository.name.isEmpty ? "Repository" : repository.name) + .fontWeight(.medium) + } + .toggleStyle(.checkbox) + + Picker( + "Source", + selection: Binding( + get: { repository.sourceKind }, + set: { store.send(.repositorySourceKindChanged(repository.id, $0)) } + ) + ) { + ForEach(sourceKinds, id: \.self) { kind in + Text(sourceKindTitle(kind)).tag(kind) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 150) + .disabled(store.isCreating) + + Spacer() + + Button { + store.send(.removeRepository(repository.id)) + } label: { + Image(systemName: "trash") + .accessibilityLabel("Remove Repository") + } + .buttonStyle(.borderless) + .help("Remove Repository") + .disabled(store.isCreating) + } + } + + private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { + HStack(spacing: 8) { + TextField( + "Name", + text: Binding( + get: { repository.name }, + set: { store.send(.repositoryNameChanged(repository.id, $0)) } + ) ) - ) { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(repository.name) - .fontWeight(.medium) - if let branchName = repository.branchName { - Text(branchName) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + + TextField( + "Workspace path", + text: Binding( + get: { repository.path ?? "" }, + set: { store.send(.repositoryPathChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + } + } + + private func repositorySourceField(_ repository: ProjectWorkspaceCreationRepository) -> some View { + HStack(spacing: 8) { + TextField( + sourceLocationPlaceholder(repository.sourceKind), + text: Binding( + get: { repository.sourceLocation }, + set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + + if repository.sourceKind != .remote { + Button { + chooseSource(for: repository) + } label: { + Image(systemName: "folder") + .accessibilityLabel("Choose Repository Source") } - Text(repository.rootURL.path(percentEncoded: false)) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + .help("Choose Repository Source") + .disabled(store.isCreating) } - .frame(maxWidth: .infinity, alignment: .leading) } - .toggleStyle(.checkbox) - .padding(.horizontal, 10) - .padding(.vertical, 8) + } + + private func repositoryBranchFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { + HStack(spacing: 8) { + TextField( + "Branch", + text: Binding( + get: { repository.branchName ?? "" }, + set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + + TextField( + "Base ref", + text: Binding( + get: { repository.baseRef ?? "" }, + set: { store.send(.repositoryBaseRefChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + } } private func chooseFolder() { @@ -150,4 +279,65 @@ struct WorkspaceCreationPromptView: View { store.send(.rootPathChosen(url.path(percentEncoded: false))) } } + + private func chooseRepositorySource(kind: ProjectWorkspaceRepositorySourceKind) { + let panel = repositorySourcePanel(kind: kind, currentPath: nil) + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + store.send(.addRepositoryFromURL(kind, url.path(percentEncoded: false))) + } + } + + private func chooseSource(for repository: ProjectWorkspaceCreationRepository) { + let panel = repositorySourcePanel(kind: repository.sourceKind, currentPath: repository.sourceLocation) + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + store.send(.repositorySourceLocationChanged(repository.id, url.path(percentEncoded: false))) + } + } + + private func repositorySourcePanel( + kind: ProjectWorkspaceRepositorySourceKind, + currentPath: String? + ) -> NSOpenPanel { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + if let currentPath, !currentPath.isEmpty { + panel.directoryURL = URL(filePath: currentPath).deletingLastPathComponent() + } + panel.message = kind == .bareRepository ? "Choose a bare repository folder" : "Choose a repository folder" + return panel + } + + private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "Opened Path" + case .localRepository: + return "Local Repo" + case .remote: + return "Remote Clone" + case .bareRepository: + return "Bare Worktree" + } + } + + private func sourceLocationPlaceholder(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath, .localRepository: + return "Repository folder" + case .remote: + return "Remote URL" + case .bareRepository: + return "Bare repository folder" + } + } } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index ea676ec8..90122d7a 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -373,19 +373,20 @@ struct AppFeatureCommandPaletteTests { let store = TestStore(initialState: AppFeature.State()) { AppFeature() } - let expectedAlert = AlertState { - TextState("Unable to create workspace") - } actions: { - ButtonState(role: .cancel) { - TextState("OK") - } - } message: { - TextState("Open at least two repositories to create a workspace.") - } await store.send(.commandPalette(.delegate(.newWorkspace))) await store.receive(\.repositories.workspaceCreation.promptRequested) { - $0.repositories.alert = expectedAlert + let title = "Workspace" + let expectedRootPath = SupacodePaths.workspacesDirectory + .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [], + title: title, + rootPath: expectedRootPath, + selectedRepositoryIDs: [] + ) } } diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 5f50d9c0..8345fef1 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import Foundation import IdentifiedCollections import Testing @@ -126,7 +127,7 @@ struct ProjectWorkspaceTests { #expect(normalized == [PersistedRepositoryEntry(path: rootPath, kind: .plain)]) } - @Test func createWorkspaceWritesMetadataAndRepositoryLinks() throws { + @Test func createWorkspaceWritesMetadataAndRepositoryLinks() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-created-workspace-\(UUID().uuidString)") .standardizedFileURL @@ -138,7 +139,7 @@ struct ProjectWorkspaceTests { try? FileManager.default.removeItem(at: apiURL) } let createdAt = Date(timeIntervalSince1970: 1_234_567) - let workspace = try ProjectWorkspace.create( + let workspace = try await ProjectWorkspace.create( ProjectWorkspaceCreationRequest( draft: ProjectWorkspaceCreationDraft( title: "Checkout Flow", @@ -159,7 +160,10 @@ struct ProjectWorkspaceTests { ] ), createdAt: createdAt - ) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "unexpected") + } ) #expect(workspace.title == "Checkout Flow") @@ -182,6 +186,66 @@ struct ProjectWorkspaceTests { #expect(URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == apiPath) } + @Test func createWorkspaceMaterializesRemoteCloneAndBareWorktree() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-materialized-workspace-\(UUID().uuidString)") + .standardizedFileURL + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let workspace = try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Materialized", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceCreationRepository( + id: "app", + name: "App", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + branchName: "codex/app", + baseRef: "origin/main", + path: "app" + ), + ProjectWorkspaceCreationRepository( + id: "api", + name: "API", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + branchName: "codex/api", + baseRef: "main", + path: "api" + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 2_345_678) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + #expect(workspace.repositories.map(\.path) == ["app", "api"]) + #expect(workspace.repositories.map(\.sourceKind) == [.remote, .bareRepository]) + let rootPath = rootURL.path(percentEncoded: false) + let barePath = normalizedTestPath(bareURL) + #expect( + commands.value.map(\.arguments) == [ + ["clone", "git@github.com:onevcat/app.git", "\(rootPath)/app"], + ["-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "origin/main"], + ["-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "main"], + ]) + + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) + #expect(loaded.repositories.map(\.branchName) == ["codex/app", "codex/api"]) + #expect(loaded.repositories.map(\.baseRef) == ["origin/main", "main"]) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3ce44367..5b4fa99d 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -572,23 +572,30 @@ struct RepositoriesFeatureTests { await store.finish() } - @Test func workspaceCreationPromptRequiresAtLeastTwoRepositories() async { + @Test func workspaceCreationPromptOpensWithoutTwoRepositories() async { let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) - let expectedAlert = AlertState { - TextState("Unable to create workspace") - } actions: { - ButtonState(role: .cancel) { - TextState("OK") - } - } message: { - TextState("Open at least two repositories to create a workspace.") - } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } await store.send(.workspaceCreation(.promptRequested)) { - $0.alert = expectedAlert + let title = "Repo A" + let expectedRootPath = SupacodePaths.workspacesDirectory + .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: "/tmp/repo-a", + name: "Repo A", + rootURL: URL(fileURLWithPath: "/tmp/repo-a") + ) + ], + title: title, + rootPath: expectedRootPath, + selectedRepositoryIDs: ["/tmp/repo-a"] + ) } } @@ -612,7 +619,7 @@ struct RepositoriesFeatureTests { await store.send(.workspaceCreation(.promptRequested)) { $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( - candidates: [ + repositories: [ ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", @@ -651,7 +658,7 @@ struct RepositoriesFeatureTests { let rootURL = URL(fileURLWithPath: "/tmp/multi-repo-workspace") let store = TestStore( initialState: WorkspaceCreationPromptFeature.State( - candidates: repositories, + repositories: repositories, title: " Multi Repo ", rootPath: " /tmp/multi-repo-workspace ", selectedRepositoryIDs: [repoRootA, repoRootB] @@ -674,6 +681,55 @@ struct RepositoriesFeatureTests { ) } + @Test func workspaceCreationPromptAddsRemoteAndLocalRepositories() async { + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [] + ) + ) { + WorkspaceCreationPromptFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.addBlankRepository(.remote)) { + $0.repositories = [ + ProjectWorkspaceCreationRepository( + id: UUID(0).uuidString, + name: "", + sourceKind: .remote, + sourceLocation: "" + ) + ] + $0.selectedRepositoryIDs = [UUID(0).uuidString] + } + + await store.send(.repositoryNameChanged(UUID(0).uuidString, "App")) { + $0.repositories[id: UUID(0).uuidString]?.name = "App" + } + await store.send(.repositorySourceLocationChanged(UUID(0).uuidString, "git@github.com:onevcat/app.git")) { + $0.repositories[id: UUID(0).uuidString]?.sourceLocation = "git@github.com:onevcat/app.git" + } + await store.send(.repositoryBranchNameChanged(UUID(0).uuidString, "codex/app")) { + $0.repositories[id: UUID(0).uuidString]?.branchName = "codex/app" + } + + await store.send(.addRepositoryFromURL(.localRepository, "/tmp/local-api")) { + $0.repositories.append( + ProjectWorkspaceCreationRepository( + id: UUID(1).uuidString, + name: "local-api", + sourceKind: .localRepository, + sourceLocation: "/tmp/local-api" + ) + ) + $0.selectedRepositoryIDs = [UUID(0).uuidString, UUID(1).uuidString] + } + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) -- 2.51.2 From 3b5aa44c1731499ae0d3098d596ee18017798d44 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 11:48:09 +0800 Subject: [PATCH 443/528] Add workspace base ref suggestions --- docs/components/workspaces.md | 6 ++ supacode/Domain/ProjectWorkspace.swift | 56 +++++++++- .../RepositoriesFeature+CoreReducer.swift | 3 + ...epositoriesFeature+WorkspaceCreation.swift | 101 +++++++++++++++++- .../Reducer/RepositoriesFeature.swift | 8 ++ .../WorkspaceCreationPromptFeature.swift | 36 ++++++- .../Views/WorkspaceCreationPromptView.swift | 16 ++- supacodeTests/RepositoriesFeatureTests.swift | 43 +++++++- 8 files changed, 260 insertions(+), 9 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 5697eda9..5925f766 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -45,6 +45,12 @@ Repository sources can be mixed in one workspace: `source_kind: bare_repository`. If both branch and base ref are supplied, Prowl creates the worktree branch from that base ref. +The creation prompt detects base-ref candidates for already opened, local, and +bare repositories by reading local git refs, preferring the detected default +branch such as `main` or `master`. Remote clone entries show common base-ref +candidates (`main`, `master`, `origin/main`, `origin/master`) without probing +the network; you can choose one from the menu or type any ref manually. + ## Metadata Example `.prowl/workspace.json`: diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index b010bb37..9aa41d93 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -15,13 +15,16 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send var sourceLocation: String var branchName: String? var baseRef: String? + var baseRefOptions: [String] init( id: String, name: String, rootURL: URL, branchName: String? = nil, - path: String? = nil + path: String? = nil, + baseRef: String? = nil, + baseRefOptions: [String] = [] ) { let normalizedURL = rootURL.standardizedFileURL self.id = id @@ -30,7 +33,8 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) self.branchName = branchName - baseRef = nil + self.baseRef = baseRef + self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) } init( @@ -40,7 +44,8 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send sourceLocation: String, branchName: String? = nil, baseRef: String? = nil, - path: String? = nil + path: String? = nil, + baseRefOptions: [String] = [] ) { self.id = id self.name = name @@ -49,6 +54,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.sourceLocation = sourceLocation self.branchName = branchName self.baseRef = baseRef + self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) } var localSourceURL: URL? { @@ -63,6 +69,50 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send return nil } } + + nonisolated static let commonRemoteBaseRefOptions = ["main", "master", "origin/main", "origin/master"] + + nonisolated static func baseRefOptions( + automaticBaseRef: String?, + refs: [String], + sourceKind: ProjectWorkspaceRepositorySourceKind + ) -> [String] { + var values: [String] = [] + if let automaticBaseRef { + values.append(automaticBaseRef) + } + switch sourceKind { + case .remote: + values += commonRemoteBaseRefOptions + case .existingPath, .localRepository, .bareRepository: + values += ["main", "master", "origin/main", "origin/master"] + } + values += refs + return normalizedBaseRefOptions(values) + } + + nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [String]) -> String? { + if let automaticBaseRef, + !automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return automaticBaseRef + } + return ["main", "master", "origin/main", "origin/master"].first { options.contains($0) } + ?? options.first + } + + nonisolated static func normalizedBaseRefOptions(_ values: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { + continue + } + result.append(trimmed) + } + return result + } } nonisolated struct ProjectWorkspaceCreationDraft: Equatable, Sendable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 146137c3..f26798d5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -721,6 +721,9 @@ extension RepositoriesFeature { case .workspaceCreationPrompt(.presented(.delegate(.cancel))): return .send(.workspaceCreation(.promptCanceled)) + case .workspaceCreationPrompt(.presented(.delegate(.baseRefSourceChanged(let repositoryID)))): + return .send(.workspaceCreation(.refreshBaseRefs(repositoryID))) + case .workspaceCreationPrompt(.presented(.delegate(.submit(let draft)))): return .send(.workspaceCreation(.createWorkspace(draft))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 522e1092..5fbb4707 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -37,12 +37,37 @@ extension RepositoriesFeature { rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), selectedRepositoryIDs: Set(candidates.map(\.id)) ) - return .none + return workspaceBaseRefsEffect(for: candidates) case .promptCanceled, .promptDismissed: state.workspaceCreationPrompt = nil return .cancel(id: CancelID.workspaceCreation) + case .refreshBaseRefs(let repositoryID): + guard let repository = state.workspaceCreationPrompt?.repositories[id: repositoryID] else { + return .none + } + return workspaceBaseRefsEffect(for: [repository]) + + case .baseRefsLoaded(let repositoryID, let sourceKind, let sourceLocation, let options, let defaultBaseRef): + guard var repository = state.workspaceCreationPrompt?.repositories[id: repositoryID], + repository.sourceKind == sourceKind, + repository.sourceLocation == sourceLocation + else { + return .none + } + var baseRefOptions = options + let baseRef = Self.trimmedNonEmpty(repository.baseRef) + if let baseRef { + baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions([baseRef] + baseRefOptions) + } + repository.baseRefOptions = baseRefOptions + if baseRef == nil { + repository.baseRef = defaultBaseRef + } + state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository + return .none + case .createWorkspace(let draft): state.workspaceCreationPrompt?.isCreating = true state.workspaceCreationPrompt?.validationMessage = nil @@ -127,4 +152,78 @@ extension RepositoriesFeature { } return candidateURL.standardizedFileURL } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + guard let value else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func workspaceBaseRefsEffect(for repositories: [ProjectWorkspaceCreationRepository]) -> Effect { + guard !repositories.isEmpty else { + return .none + } + let gitClient = gitClient + return .run { send in + for repository in repositories { + let result = await Self.workspaceBaseRefs(for: repository, gitClient: gitClient) + await send( + .workspaceCreation( + .baseRefsLoaded( + repositoryID: repository.id, + sourceKind: repository.sourceKind, + sourceLocation: repository.sourceLocation, + options: result.options, + defaultBaseRef: result.defaultBaseRef + ) + ) + ) + } + } + } + + private static func workspaceBaseRefs( + for repository: ProjectWorkspaceCreationRepository, + gitClient: GitClientDependency + ) async -> (options: [String], defaultBaseRef: String?) { + switch repository.sourceKind { + case .remote: + return (ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions, nil) + + case .existingPath, .localRepository, .bareRepository: + guard let sourceURL = repository.localSourceURL else { + let options = ProjectWorkspaceCreationRepository.baseRefOptions( + automaticBaseRef: nil, + refs: [], + sourceKind: repository.sourceKind + ) + return (options, nil) + } + + let repositoryURL: URL + if repository.sourceKind == .bareRepository { + repositoryURL = sourceURL + } else { + repositoryURL = (try? await gitClient.repoRoot(sourceURL)) ?? sourceURL + } + + let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(repositoryURL) + let refs = (try? await gitClient.branchRefs(repositoryURL)) ?? [] + let options = ProjectWorkspaceCreationRepository.baseRefOptions( + automaticBaseRef: automaticBaseRef, + refs: refs, + sourceKind: repository.sourceKind + ) + let defaultBaseRef = + automaticBaseRef != nil || !refs.isEmpty + ? ProjectWorkspaceCreationRepository.preferredBaseRef( + automaticBaseRef: automaticBaseRef, + options: options + ) + : nil + return (options, defaultBaseRef) + } + } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 37b1ab91..3934dab0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -218,6 +218,14 @@ struct RepositoriesFeature { case promptRequested case promptCanceled case promptDismissed + case refreshBaseRefs(Repository.ID) + case baseRefsLoaded( + repositoryID: Repository.ID, + sourceKind: ProjectWorkspaceRepositorySourceKind, + sourceLocation: String, + options: [String], + defaultBaseRef: String? + ) case createWorkspace(ProjectWorkspaceCreationDraft) case workspaceCreated(URL) case workspaceCreationFailed(String) diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 91aeedb6..7d371fbd 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -47,6 +47,7 @@ struct WorkspaceCreationPromptFeature { case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) case repositoryNameChanged(Repository.ID, String) case repositoryPathChanged(Repository.ID, String) + case repositorySourceChosen(Repository.ID, String) case repositorySourceLocationChanged(Repository.ID, String) case repositoryBranchNameChanged(Repository.ID, String) case repositoryBaseRefChanged(Repository.ID, String) @@ -60,6 +61,7 @@ struct WorkspaceCreationPromptFeature { @CasePathable enum Delegate: Equatable { + case baseRefSourceChanged(Repository.ID) case cancel case submit(ProjectWorkspaceCreationDraft) } @@ -81,7 +83,10 @@ struct WorkspaceCreationPromptFeature { id: id, name: "", sourceKind: sourceKind, - sourceLocation: "" + sourceLocation: "", + baseRefOptions: sourceKind == .remote + ? ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions + : [] ) ) state.selectedRepositoryIDs.insert(id) @@ -106,7 +111,7 @@ struct WorkspaceCreationPromptFeature { ) state.selectedRepositoryIDs.insert(id) state.validationMessage = nil - return .none + return .send(.delegate(.baseRefSourceChanged(id))) case .removeRepository(let repositoryID): state.repositories.remove(id: repositoryID) @@ -130,10 +135,17 @@ struct WorkspaceCreationPromptFeature { repository.sourceKind = sourceKind if sourceKind == .remote { repository.sourceLocation = "" + repository.baseRef = nil + repository.baseRefOptions = ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions + } else { + repository.baseRefOptions = [] } state.repositories[id: repositoryID] = repository state.validationMessage = nil - return .none + guard sourceKind != .remote, repository.localSourceURL != nil else { + return .none + } + return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositoryNameChanged(let repositoryID, let name): state.repositories[id: repositoryID]?.name = name @@ -145,6 +157,24 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .repositorySourceChosen(let repositoryID, let sourceLocation): + guard let rootPath = PathPolicy.normalizePath(sourceLocation) else { + state.validationMessage = + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + return .none + } + guard var repository = state.repositories[id: repositoryID] else { + return .none + } + repository.sourceLocation = rootPath + if repository.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + repository.name = Repository.name(for: URL(fileURLWithPath: rootPath)) + } + repository.baseRefOptions = [] + state.repositories[id: repositoryID] = repository + state.validationMessage = nil + return .send(.delegate(.baseRefSourceChanged(repositoryID))) + case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): state.repositories[id: repositoryID]?.sourceLocation = sourceLocation state.validationMessage = nil diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 16931b74..91497ade 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -261,6 +261,20 @@ struct WorkspaceCreationPromptView: View { ) .textFieldStyle(.roundedBorder) .disabled(store.isCreating) + + Menu { + ForEach(repository.baseRefOptions, id: \.self) { option in + Button(option) { + store.send(.repositoryBaseRefChanged(repository.id, option)) + } + } + } label: { + Image(systemName: "chevron.down") + .accessibilityLabel("Choose Base Ref") + } + .buttonStyle(.borderless) + .help("Choose Base Ref") + .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } } @@ -296,7 +310,7 @@ struct WorkspaceCreationPromptView: View { guard response == .OK, let url = panel.url else { return } - store.send(.repositorySourceLocationChanged(repository.id, url.path(percentEncoded: false))) + store.send(.repositorySourceChosen(repository.id, url.path(percentEncoded: false))) } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 5b4fa99d..76f60c19 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -576,6 +576,10 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() + } withDependencies: { + $0.gitClient.repoRoot = { url in url } + $0.gitClient.automaticWorktreeBaseRef = { _ in "master" } + $0.gitClient.branchRefs = { _ in ["master"] } } await store.send(.workspaceCreation(.promptRequested)) { @@ -597,6 +601,15 @@ struct RepositoriesFeatureTests { selectedRepositoryIDs: ["/tmp/repo-a"] ) } + await store.receive(\.workspaceCreation.baseRefsLoaded) { + $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRef = "master" + $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRefOptions = [ + "master", + "main", + "origin/main", + "origin/master", + ] + } } @Test func workspaceCreationPromptUsesLoadedRepositories() async { @@ -615,6 +628,14 @@ struct RepositoriesFeatureTests { .path(percentEncoded: false) let store = TestStore(initialState: state) { RepositoriesFeature() + } withDependencies: { + $0.gitClient.repoRoot = { url in url } + $0.gitClient.automaticWorktreeBaseRef = { url in + url.path(percentEncoded: false) == repoRootA ? "main" : "master" + } + $0.gitClient.branchRefs = { url in + url.path(percentEncoded: false) == repoRootA ? ["main", "origin/main"] : ["master", "origin/master"] + } } await store.send(.workspaceCreation(.promptRequested)) { @@ -637,6 +658,24 @@ struct RepositoriesFeatureTests { selectedRepositoryIDs: [repoRootA, repoRootB] ) } + await store.receive(\.workspaceCreation.baseRefsLoaded) { + $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" + $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRefOptions = [ + "main", + "master", + "origin/main", + "origin/master", + ] + } + await store.receive(\.workspaceCreation.baseRefsLoaded) { + $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRef = "master" + $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRefOptions = [ + "master", + "main", + "origin/main", + "origin/master", + ] + } } @Test func workspaceCreationPromptSubmitBuildsDraft() async { @@ -701,7 +740,8 @@ struct RepositoriesFeatureTests { id: UUID(0).uuidString, name: "", sourceKind: .remote, - sourceLocation: "" + sourceLocation: "", + baseRefOptions: ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions ) ] $0.selectedRepositoryIDs = [UUID(0).uuidString] @@ -728,6 +768,7 @@ struct RepositoriesFeatureTests { ) $0.selectedRepositoryIDs = [UUID(0).uuidString, UUID(1).uuidString] } + await store.receive(.delegate(.baseRefSourceChanged(UUID(1).uuidString))) } @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { -- 2.51.2 From 876c7a65eb3c6878d2b2fb2b5f58ca5ce5f7b0dc Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 11:57:06 +0800 Subject: [PATCH 444/528] Tighten workspace base ref selection --- docs/components/workspaces.md | 7 +-- supacode/Domain/ProjectWorkspace.swift | 11 +---- ...epositoriesFeature+WorkspaceCreation.swift | 17 +++---- .../WorkspaceCreationPromptFeature.swift | 19 +++++--- .../Views/WorkspaceCreationPromptView.swift | 22 ++++----- supacodeTests/RepositoriesFeatureTests.swift | 45 ++++++++++++++----- 6 files changed, 67 insertions(+), 54 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 5925f766..2901ce03 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -47,9 +47,10 @@ Repository sources can be mixed in one workspace: The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default -branch such as `main` or `master`. Remote clone entries show common base-ref -candidates (`main`, `master`, `origin/main`, `origin/master`) without probing -the network; you can choose one from the menu or type any ref manually. +branch such as `main` or `master`. Base refs are selected from detected refs so +workspace creation does not try to checkout an arbitrary, nonexistent branch. +Remote clone entries can be created without a base ref; after cloning, a branch +name without an explicit base is created from the repository's default checkout. ## Metadata diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 9aa41d93..5c97c8d4 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -70,23 +70,14 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send } } - nonisolated static let commonRemoteBaseRefOptions = ["main", "master", "origin/main", "origin/master"] - nonisolated static func baseRefOptions( automaticBaseRef: String?, - refs: [String], - sourceKind: ProjectWorkspaceRepositorySourceKind + refs: [String] ) -> [String] { var values: [String] = [] if let automaticBaseRef { values.append(automaticBaseRef) } - switch sourceKind { - case .remote: - values += commonRemoteBaseRefOptions - case .existingPath, .localRepository, .bareRepository: - values += ["main", "master", "origin/main", "origin/master"] - } values += refs return normalizedBaseRefOptions(values) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 5fbb4707..3ebe856a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -56,13 +56,12 @@ extension RepositoriesFeature { else { return .none } - var baseRefOptions = options let baseRef = Self.trimmedNonEmpty(repository.baseRef) - if let baseRef { - baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions([baseRef] + baseRefOptions) - } + let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(options) repository.baseRefOptions = baseRefOptions - if baseRef == nil { + if let baseRef, baseRefOptions.contains(baseRef) { + repository.baseRef = baseRef + } else { repository.baseRef = defaultBaseRef } state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository @@ -190,14 +189,13 @@ extension RepositoriesFeature { ) async -> (options: [String], defaultBaseRef: String?) { switch repository.sourceKind { case .remote: - return (ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions, nil) + return ([], nil) case .existingPath, .localRepository, .bareRepository: guard let sourceURL = repository.localSourceURL else { let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: nil, - refs: [], - sourceKind: repository.sourceKind + refs: [] ) return (options, nil) } @@ -213,8 +211,7 @@ extension RepositoriesFeature { let refs = (try? await gitClient.branchRefs(repositoryURL)) ?? [] let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: automaticBaseRef, - refs: refs, - sourceKind: repository.sourceKind + refs: refs ) let defaultBaseRef = automaticBaseRef != nil || !refs.isEmpty diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 7d371fbd..dc4a614b 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -83,10 +83,7 @@ struct WorkspaceCreationPromptFeature { id: id, name: "", sourceKind: sourceKind, - sourceLocation: "", - baseRefOptions: sourceKind == .remote - ? ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions - : [] + sourceLocation: "" ) ) state.selectedRepositoryIDs.insert(id) @@ -133,10 +130,9 @@ struct WorkspaceCreationPromptFeature { return .none } repository.sourceKind = sourceKind + repository.baseRef = nil if sourceKind == .remote { repository.sourceLocation = "" - repository.baseRef = nil - repository.baseRefOptions = ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions } else { repository.baseRefOptions = [] } @@ -170,6 +166,7 @@ struct WorkspaceCreationPromptFeature { if repository.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { repository.name = Repository.name(for: URL(fileURLWithPath: rootPath)) } + repository.baseRef = nil repository.baseRefOptions = [] state.repositories[id: repositoryID] = repository state.validationMessage = nil @@ -186,7 +183,15 @@ struct WorkspaceCreationPromptFeature { return .none case .repositoryBaseRefChanged(let repositoryID, let baseRef): - state.repositories[id: repositoryID]?.baseRef = baseRef + guard var repository = state.repositories[id: repositoryID] else { + return .none + } + let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty || repository.baseRefOptions.contains(trimmed) else { + return .none + } + repository.baseRef = trimmed.isEmpty ? nil : trimmed + state.repositories[id: repositoryID] = repository state.validationMessage = nil return .none diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 91497ade..af6463dd 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -252,27 +252,21 @@ struct WorkspaceCreationPromptView: View { .textFieldStyle(.roundedBorder) .disabled(store.isCreating) - TextField( + Picker( "Base ref", - text: Binding( + selection: Binding( get: { repository.baseRef ?? "" }, set: { store.send(.repositoryBaseRefChanged(repository.id, $0)) } ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) - - Menu { + ) { + Text("Base ref").tag("") ForEach(repository.baseRefOptions, id: \.self) { option in - Button(option) { - store.send(.repositoryBaseRefChanged(repository.id, option)) - } + Text(option).tag(option) } - } label: { - Image(systemName: "chevron.down") - .accessibilityLabel("Choose Base Ref") } - .buttonStyle(.borderless) + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) .help("Choose Base Ref") .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 76f60c19..45b80ed7 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -604,10 +604,7 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRef = "master" $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRefOptions = [ - "master", - "main", - "origin/main", - "origin/master", + "master" ] } } @@ -662,17 +659,13 @@ struct RepositoriesFeatureTests { $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRefOptions = [ "main", - "master", "origin/main", - "origin/master", ] } await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRef = "master" $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRefOptions = [ "master", - "main", - "origin/main", "origin/master", ] } @@ -740,8 +733,7 @@ struct RepositoriesFeatureTests { id: UUID(0).uuidString, name: "", sourceKind: .remote, - sourceLocation: "", - baseRefOptions: ProjectWorkspaceCreationRepository.commonRemoteBaseRefOptions + sourceLocation: "" ) ] $0.selectedRepositoryIDs = [UUID(0).uuidString] @@ -771,6 +763,39 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(UUID(1).uuidString))) } + @Test func workspaceCreationPromptRejectsUnknownBaseRef() async { + let repoRootA = "/tmp/repo-a" + let repoRootB = "/tmp/repo-b" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + baseRefOptions: ["main"] + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "Repo B", + rootURL: URL(fileURLWithPath: repoRootB), + baseRefOptions: ["master"] + ), + ], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [repoRootA, repoRootB] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.repositoryBaseRefChanged(repoRootA, "missing-branch")) + await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { + $0.repositories[id: repoRootA]?.baseRef = "main" + } + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) -- 2.51.2 From 588fa11a8c6ae147662b0f19bebb16db9213daed Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 15:39:09 +0800 Subject: [PATCH 445/528] Add existing branch mode for workspaces --- docs/components/workspaces.md | 23 +- supacode/Clients/Git/GitClient.swift | 107 ++++++-- supacode/Clients/Git/GitClientTypes.swift | 37 +++ .../Repositories/GitClientDependency.swift | 4 + supacode/Domain/ProjectWorkspace.swift | 78 ++++-- ...epositoriesFeature+WorkspaceCreation.swift | 10 +- .../Reducer/RepositoriesFeature.swift | 2 +- .../WorkspaceCreationPromptFeature.swift | 171 ++++++++++++- .../Views/WorkspaceCreationPromptView.swift | 241 ++++++++++++++++-- supacodeTests/GitClientBranchRefsTests.swift | 57 ++++- supacodeTests/ProjectWorkspaceTests.swift | 75 ++++++ supacodeTests/RepositoriesFeatureTests.swift | 200 +++++++++++++-- 12 files changed, 892 insertions(+), 113 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 2901ce03..7329c93e 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -38,19 +38,28 @@ Repository sources can be mixed in one workspace: existing_path`. - Local repository folders selected from disk are added as symlinks with `source_kind: local_repository`. -- Remote repositories are cloned into the workspace folder with `source_kind: - remote`. If a branch and base ref are supplied, Prowl checks out the branch - from the base after cloning. +- Remote repositories are added through a URL prompt that loads remote heads + before inserting the row. They are cloned into the workspace folder with + `source_kind: remote`. The inserted row defaults to **Use Existing** on the + detected default remote branch. - Bare repositories are materialized with `git worktree add` and recorded with `source_kind: bare_repository`. If both branch and base ref are supplied, Prowl creates the worktree branch from that base ref. The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default -branch such as `main` or `master`. Base refs are selected from detected refs so -workspace creation does not try to checkout an arbitrary, nonexistent branch. -Remote clone entries can be created without a base ref; after cloning, a branch -name without an explicit base is created from the repository's default checkout. +branch such as `main` or `master`. Refs are grouped as local branches, remote +tracking branches, or fetched remote branches, and the picker supports simple +text search. Base refs are selected from detected refs so workspace creation +does not try to checkout an arbitrary, nonexistent branch. + +Branch behavior is explicit: + +- **Create Branch** uses `branch_name` plus the selected base ref to create a + new branch or worktree branch. Remote and bare sources require a branch name + in this mode. +- **Use Existing** uses the selected ref directly. For remote clones, Prowl + checks out the selected remote branch after clone. ## Metadata diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 98b735e6..c9934df8 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -129,6 +129,14 @@ struct GitClient { } nonisolated func branchRefs(for repoRoot: URL) async throws -> [String] { + let options = try await branchRefOptions(for: repoRoot) + let refs = options.map(\.ref) + .filter { !$0.hasSuffix("/HEAD") } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return deduplicated(refs) + } + + nonisolated func branchRefOptions(for repoRoot: URL) async throws -> [GitBranchRefOption] { let path = repoRoot.path(percentEncoded: false) let localOutput = try await runGit( operation: .branchRefs, @@ -136,14 +144,45 @@ struct GitClient { "-C", path, "for-each-ref", - "--format=%(refname:short)\t%(upstream:short)", + "--format=%(refname:short)", "refs/heads", ] ) - let refs = parseLocalRefsWithUpstream(localOutput) + let remoteOutput = try await runGit( + operation: .branchRefs, + arguments: [ + "-C", + path, + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes", + ] + ) + let localRefs = parseRefLines(localOutput) .filter { !$0.hasSuffix("/HEAD") } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } - return deduplicated(refs) + let remoteRefs = parseRefLines(remoteOutput) + .filter { !$0.hasSuffix("/HEAD") } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return deduplicatedOptions( + localRefs.map { GitBranchRefOption(ref: $0, kind: .local) } + + remoteRefs.map { GitBranchRefOption(ref: $0, kind: .remoteTracking) } + ) + } + + nonisolated func remoteBranchRefs(for remoteURL: String) async throws -> GitRemoteBranchRefs { + let output = try await runGit( + operation: .remoteBranchRefs, + arguments: ["ls-remote", "--heads", "--symref", remoteURL] + ) + let parsed = parseRemoteBranchRefs(output) + let options = parsed.refs + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + .map { GitBranchRefOption(ref: "origin/\($0)", kind: .fetchedRemote) } + let defaultBaseRef = + parsed.defaultBranch.map { "origin/\($0)" } + ?? options.first?.ref + return GitRemoteBranchRefs(options: deduplicatedOptions(options), defaultBaseRef: defaultBaseRef) } nonisolated func defaultRemoteBranchRef(for repoRoot: URL) async throws -> String? { @@ -653,29 +692,52 @@ struct GitClient { .last { !$0.isEmpty } } - nonisolated private func parseLocalRefsWithUpstream(_ output: String) -> [String] { + nonisolated private func parseRefLines(_ output: String) -> [String] { output .split(whereSeparator: \.isNewline) - .flatMap { line -> [String] in + .compactMap { line -> String? in + let ref = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + return ref.isEmpty ? nil : ref + } + } + + nonisolated private func parseRemoteBranchRefs(_ output: String) -> (refs: [String], defaultBranch: String?) { + var refs: [String] = [] + var defaultBranch: String? + for rawLine in output.split(whereSeparator: \.isNewline) { + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + if line.hasPrefix("ref:") { let parts = line.split(separator: "\t", omittingEmptySubsequences: false) - guard let local = parts.first else { - return [] - } - let localRef = String(local).trimmingCharacters(in: .whitespacesAndNewlines) - let upstreamRef = - parts.count > 1 - ? String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) - : "" - - var refs: [String] = [] - if !localRef.isEmpty { - refs.append(localRef) + guard parts.count >= 2, parts[1] == "HEAD" else { + continue } - if !upstreamRef.isEmpty { - refs.append(upstreamRef) + let target = parts[0] + .dropFirst("ref:".count) + .trimmingCharacters(in: .whitespacesAndNewlines) + if let branch = normalizeHeadRef(target) { + defaultBranch = branch } - return refs + continue + } + let parts = line.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count >= 2, + let branch = normalizeHeadRef(String(parts[1])) + else { + continue } + refs.append(branch) + } + return (deduplicated(refs), defaultBranch) + } + + nonisolated private func normalizeHeadRef(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "refs/heads/" + guard trimmed.hasPrefix(prefix) else { + return nil + } + let branch = String(trimmed.dropFirst(prefix.count)) + return branch.isEmpty ? nil : branch } nonisolated private func deduplicated(_ values: [String]) -> [String] { @@ -683,6 +745,11 @@ struct GitClient { return values.filter { seen.insert($0).inserted } } + nonisolated private func deduplicatedOptions(_ options: [GitBranchRefOption]) -> [GitBranchRefOption] { + var seen = Set() + return options.filter { seen.insert($0.id).inserted } + } + nonisolated private func normalizeRemoteRef(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/supacode/Clients/Git/GitClientTypes.swift b/supacode/Clients/Git/GitClientTypes.swift index d016f3a2..c8a427d8 100644 --- a/supacode/Clients/Git/GitClientTypes.swift +++ b/supacode/Clients/Git/GitClientTypes.swift @@ -23,6 +23,7 @@ enum GitOperation: String { case remoteInfo = "remote_info" case remoteList = "remote_list" case fetchRemote = "fetch_remote" + case remoteBranchRefs = "remote_branch_refs" } enum GitClientError: LocalizedError { @@ -89,6 +90,42 @@ nonisolated enum GitRemoteMatcher { } } +nonisolated enum GitBranchRefKind: String, Codable, Equatable, Hashable, Sendable, CaseIterable { + case local + case remoteTracking = "remote_tracking" + case fetchedRemote = "fetched_remote" + + var title: String { + switch self { + case .local: + return "Local Branches" + case .remoteTracking: + return "Remote Branches" + case .fetchedRemote: + return "Fetched Remote Branches" + } + } +} + +nonisolated struct GitBranchRefOption: Codable, Equatable, Hashable, Sendable, Identifiable { + var ref: String + var kind: GitBranchRefKind + + var id: String { + "\(kind.rawValue):\(ref)" + } + + init(ref: String, kind: GitBranchRefKind) { + self.ref = ref + self.kind = kind + } +} + +nonisolated struct GitRemoteBranchRefs: Equatable, Sendable { + var options: [GitBranchRefOption] + var defaultBaseRef: String? +} + struct GitWtWorktreeEntry: Decodable, Equatable { let branch: String let path: String diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index e52ab0ff..0bad7c43 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -8,6 +8,8 @@ struct GitClientDependency: Sendable { var localBranchNames: @Sendable (URL) async throws -> Set var isValidBranchName: @Sendable (String, URL) async -> Bool var branchRefs: @Sendable (URL) async throws -> [String] + var branchRefOptions: @Sendable (URL) async throws -> [GitBranchRefOption] + var remoteBranchRefs: @Sendable (String) async throws -> GitRemoteBranchRefs var defaultRemoteBranchRef: @Sendable (URL) async throws -> String? var automaticWorktreeBaseRef: @Sendable (URL) async -> String? var ignoredFileCount: @Sendable (URL) async throws -> Int @@ -47,6 +49,8 @@ extension GitClientDependency: DependencyKey { await GitClient().isValidBranchName(branchName, for: repoRoot) }, branchRefs: { try await GitClient().branchRefs(for: $0) }, + branchRefOptions: { try await GitClient().branchRefOptions(for: $0) }, + remoteBranchRefs: { try await GitClient().remoteBranchRefs(for: $0) }, defaultRemoteBranchRef: { try await GitClient().defaultRemoteBranchRef(for: $0) }, automaticWorktreeBaseRef: { await GitClient().automaticWorktreeBaseRef(for: $0) }, ignoredFileCount: { try await GitClient().ignoredFileCount(for: $0) }, diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 5c97c8d4..dd4a9bbd 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -7,24 +7,31 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl case existingPath = "existing_path" } +nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, Sendable { + case createBranch = "create_branch" + case useExistingRef = "use_existing_ref" +} + nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { var id: String var name: String var path: String? var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String + var checkoutMode: ProjectWorkspaceRepositoryCheckoutMode var branchName: String? var baseRef: String? - var baseRefOptions: [String] + var baseRefOptions: [GitBranchRefOption] init( id: String, name: String, rootURL: URL, + checkoutMode: ProjectWorkspaceRepositoryCheckoutMode = .createBranch, branchName: String? = nil, path: String? = nil, baseRef: String? = nil, - baseRefOptions: [String] = [] + baseRefOptions: [GitBranchRefOption] = [] ) { let normalizedURL = rootURL.standardizedFileURL self.id = id @@ -32,6 +39,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.path = path sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) + self.checkoutMode = checkoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) @@ -42,16 +50,18 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send name: String, sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, + checkoutMode: ProjectWorkspaceRepositoryCheckoutMode = .createBranch, branchName: String? = nil, baseRef: String? = nil, path: String? = nil, - baseRefOptions: [String] = [] + baseRefOptions: [GitBranchRefOption] = [] ) { self.id = id self.name = name self.path = path self.sourceKind = sourceKind self.sourceLocation = sourceLocation + self.checkoutMode = checkoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) @@ -72,35 +82,39 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send nonisolated static func baseRefOptions( automaticBaseRef: String?, - refs: [String] - ) -> [String] { - var values: [String] = [] + options: [GitBranchRefOption] + ) -> [GitBranchRefOption] { + var values: [GitBranchRefOption] = [] if let automaticBaseRef { - values.append(automaticBaseRef) + let trimmed = automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines) + let kind = options.first { $0.ref == trimmed }?.kind ?? .local + values.append(GitBranchRefOption(ref: automaticBaseRef, kind: kind)) } - values += refs + values += options return normalizedBaseRefOptions(values) } - nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [String]) -> String? { + nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) -> String? { + let refs = Set(options.map(\.ref)) if let automaticBaseRef, - !automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + refs.contains(automaticBaseRef) { return automaticBaseRef } - return ["main", "master", "origin/main", "origin/master"].first { options.contains($0) } - ?? options.first + return ["main", "master", "origin/main", "origin/master"].first { refs.contains($0) } + ?? options.first?.ref } - nonisolated static func normalizedBaseRefOptions(_ values: [String]) -> [String] { + nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) -> [GitBranchRefOption] { var seen = Set() - var result: [String] = [] + var result: [GitBranchRefOption] = [] for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = value.ref.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue } - result.append(trimmed) + result.append(GitBranchRefOption(ref: trimmed, kind: value.kind)) } return result } @@ -534,7 +548,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { path: workspacePath, sourceKind: sourceKind, sourceLocation: normalizedSourceLocation, - branchName: repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + branchName: repository.checkoutMode == .createBranch + ? repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + : nil, baseRef: repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ), createdURL: destinationURL, @@ -550,6 +566,18 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + if repository.checkoutMode == .useExistingRef { + guard let baseRef else { + return + } + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", remoteCloneExistingCheckoutRef(baseRef)], + currentDirectoryURL: nil + ) + ) + return + } switch (branchName, baseRef) { case (.some(let branchName), .some(let baseRef)): try await gitRunner.run( @@ -585,7 +613,13 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty var arguments = ["-C", sourcePath, "worktree", "add"] - if let branchName, let baseRef { + if repository.checkoutMode == .useExistingRef { + if let baseRef { + arguments += [destinationPath, baseRef] + } else { + arguments += [destinationPath] + } + } else if let branchName, let baseRef { arguments += ["-b", branchName, destinationPath, baseRef] } else if let branchName { arguments += [destinationPath, branchName] @@ -597,6 +631,14 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) } + private static func remoteCloneExistingCheckoutRef(_ baseRef: String) -> String { + let prefix = "origin/" + guard baseRef.hasPrefix(prefix), baseRef.count > prefix.count else { + return baseRef + } + return String(baseRef.dropFirst(prefix.count)) + } + private static func localRepositoryPath( _ repository: ProjectWorkspaceCreationRepository, sourceLocation: String, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 3ebe856a..78784ff0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -59,7 +59,7 @@ extension RepositoriesFeature { let baseRef = Self.trimmedNonEmpty(repository.baseRef) let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(options) repository.baseRefOptions = baseRefOptions - if let baseRef, baseRefOptions.contains(baseRef) { + if let baseRef, baseRefOptions.contains(where: { $0.ref == baseRef }) { repository.baseRef = baseRef } else { repository.baseRef = defaultBaseRef @@ -186,7 +186,7 @@ extension RepositoriesFeature { private static func workspaceBaseRefs( for repository: ProjectWorkspaceCreationRepository, gitClient: GitClientDependency - ) async -> (options: [String], defaultBaseRef: String?) { + ) async -> (options: [GitBranchRefOption], defaultBaseRef: String?) { switch repository.sourceKind { case .remote: return ([], nil) @@ -195,7 +195,7 @@ extension RepositoriesFeature { guard let sourceURL = repository.localSourceURL else { let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: nil, - refs: [] + options: [] ) return (options, nil) } @@ -208,10 +208,10 @@ extension RepositoriesFeature { } let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(repositoryURL) - let refs = (try? await gitClient.branchRefs(repositoryURL)) ?? [] + let refs = (try? await gitClient.branchRefOptions(repositoryURL)) ?? [] let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: automaticBaseRef, - refs: refs + options: refs ) let defaultBaseRef = automaticBaseRef != nil || !refs.isEmpty diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 3934dab0..300fefa2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -223,7 +223,7 @@ struct RepositoriesFeature { repositoryID: Repository.ID, sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, - options: [String], + options: [GitBranchRefOption], defaultBaseRef: String? ) case createWorkspace(ProjectWorkspaceCreationDraft) diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index dc4a614b..7f739ea9 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -12,6 +12,7 @@ struct WorkspaceCreationPromptFeature { var selectedRepositoryIDs: Set var validationMessage: String? var isCreating = false + var remoteRepositoryPrompt: RemoteRepositoryPromptState? var selectedRepositoryCount: Int { repositories.count { selectedRepositoryIDs.contains($0.id) } @@ -38,13 +39,37 @@ struct WorkspaceCreationPromptFeature { } } + @ObservableState + struct RemoteRepositoryPromptState: Equatable { + var url = "" + var name = "" + var branchOptions: [GitBranchRefOption] = [] + var defaultBaseRef: String? + var validationMessage: String? + var isLoading = false + + var displayName: String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Remote Repository" : trimmed + } + } + enum Action: BindableAction, Equatable { case binding(BindingAction) case addBlankRepository(ProjectWorkspaceRepositorySourceKind) case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) + case addRemoteButtonTapped + case remoteRepositoryPromptURLChanged(String) + case remoteRepositoryPromptNameChanged(String) + case remoteRepositoryPromptLoadButtonTapped + case remoteRepositoryPromptLoaded(String, GitRemoteBranchRefs) + case remoteRepositoryPromptFailed(String) + case remoteRepositoryPromptAddButtonTapped + case remoteRepositoryPromptDismissed case removeRepository(Repository.ID) case repositorySelectionChanged(Repository.ID, Bool) case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) + case repositoryCheckoutModeChanged(Repository.ID, ProjectWorkspaceRepositoryCheckoutMode) case repositoryNameChanged(Repository.ID, String) case repositoryPathChanged(Repository.ID, String) case repositorySourceChosen(Repository.ID, String) @@ -67,6 +92,7 @@ struct WorkspaceCreationPromptFeature { } @Dependency(\.uuid) var uuid + @Dependency(GitClientDependency.self) var gitClient var body: some Reducer { BindingReducer() @@ -90,6 +116,109 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .addRemoteButtonTapped: + state.remoteRepositoryPrompt = RemoteRepositoryPromptState() + state.validationMessage = nil + return .none + + case .remoteRepositoryPromptURLChanged(let url): + state.remoteRepositoryPrompt?.url = url + state.remoteRepositoryPrompt?.validationMessage = nil + state.remoteRepositoryPrompt?.branchOptions = [] + state.remoteRepositoryPrompt?.defaultBaseRef = nil + if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + state.remoteRepositoryPrompt?.name = Self.remoteRepositoryName(for: url) + } + return .none + + case .remoteRepositoryPromptNameChanged(let name): + state.remoteRepositoryPrompt?.name = name + state.remoteRepositoryPrompt?.validationMessage = nil + return .none + + case .remoteRepositoryPromptLoadButtonTapped: + guard var prompt = state.remoteRepositoryPrompt else { + return .none + } + let url = prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + prompt.validationMessage = "Remote URL required." + state.remoteRepositoryPrompt = prompt + return .none + } + prompt.url = url + if prompt.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + prompt.name = Self.remoteRepositoryName(for: url) + } + prompt.isLoading = true + prompt.validationMessage = nil + state.remoteRepositoryPrompt = prompt + let gitClient = gitClient + return .run { send in + do { + let refs = try await gitClient.remoteBranchRefs(url) + await send(.remoteRepositoryPromptLoaded(url, refs)) + } catch { + await send(.remoteRepositoryPromptFailed(error.localizedDescription)) + } + } + + case .remoteRepositoryPromptLoaded(let url, let refs): + guard var prompt = state.remoteRepositoryPrompt, + prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) == url + else { + return .none + } + let options = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(refs.options) + prompt.isLoading = false + prompt.branchOptions = options + prompt.defaultBaseRef = ProjectWorkspaceCreationRepository.preferredBaseRef( + automaticBaseRef: refs.defaultBaseRef, + options: options + ) + prompt.validationMessage = options.isEmpty ? "No remote branches found." : nil + state.remoteRepositoryPrompt = prompt + return .none + + case .remoteRepositoryPromptFailed(let message): + state.remoteRepositoryPrompt?.isLoading = false + state.remoteRepositoryPrompt?.validationMessage = message + return .none + + case .remoteRepositoryPromptAddButtonTapped: + guard let prompt = state.remoteRepositoryPrompt else { + return .none + } + let url = prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + state.remoteRepositoryPrompt?.validationMessage = "Remote URL required." + return .none + } + guard !prompt.branchOptions.isEmpty else { + state.remoteRepositoryPrompt?.validationMessage = "Load remote branches before adding." + return .none + } + let id = uuid().uuidString + state.repositories.append( + ProjectWorkspaceCreationRepository( + id: id, + name: prompt.displayName, + sourceKind: .remote, + sourceLocation: url, + checkoutMode: .useExistingRef, + baseRef: prompt.defaultBaseRef, + baseRefOptions: prompt.branchOptions + ) + ) + state.selectedRepositoryIDs.insert(id) + state.remoteRepositoryPrompt = nil + state.validationMessage = nil + return .none + + case .remoteRepositoryPromptDismissed: + state.remoteRepositoryPrompt = nil + return .none + case .addRepositoryFromURL(let sourceKind, let path): guard let rootPath = PathPolicy.normalizePath(path) else { state.validationMessage = @@ -125,16 +254,24 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .repositoryCheckoutModeChanged(let repositoryID, let checkoutMode): + guard var repository = state.repositories[id: repositoryID] else { + return .none + } + repository.checkoutMode = checkoutMode + state.repositories[id: repositoryID] = repository + state.validationMessage = nil + return .none + case .repositorySourceKindChanged(let repositoryID, let sourceKind): guard var repository = state.repositories[id: repositoryID] else { return .none } repository.sourceKind = sourceKind repository.baseRef = nil + repository.baseRefOptions = [] if sourceKind == .remote { repository.sourceLocation = "" - } else { - repository.baseRefOptions = [] } state.repositories[id: repositoryID] = repository state.validationMessage = nil @@ -187,7 +324,7 @@ struct WorkspaceCreationPromptFeature { return .none } let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty || repository.baseRefOptions.contains(trimmed) else { + guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) else { return .none } repository.baseRef = trimmed.isEmpty ? nil : trimmed @@ -227,6 +364,21 @@ struct WorkspaceCreationPromptFeature { ProjectWorkspaceCreationError.missingRepositorySource(displayName).localizedDescription return .none } + if repository.checkoutMode == .createBranch, + repository.sourceKind == .remote || repository.sourceKind == .bareRepository, + repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false + { + let displayName = name.isEmpty ? "repository" : name + state.validationMessage = "Branch name required for \(displayName)." + return .none + } + if repository.checkoutMode == .useExistingRef, + repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false + { + let displayName = name.isEmpty ? "repository" : name + state.validationMessage = "Choose an existing branch for \(displayName)." + return .none + } } state.validationMessage = nil return .send( @@ -254,4 +406,17 @@ struct WorkspaceCreationPromptFeature { } } } + + private static func remoteRepositoryName(for remoteURL: String) -> String { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmed.isEmpty else { + return "" + } + let separatorIndex = trimmed.lastIndex { $0 == "/" || $0 == ":" } + let component = + separatorIndex.map { String(trimmed[trimmed.index(after: $0)...]) } + ?? trimmed + return component.hasSuffix(".git") ? String(component.dropLast(4)) : component + } } diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index af6463dd..2a0ca489 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -62,7 +62,7 @@ struct WorkspaceCreationPromptView: View { .foregroundStyle(.secondary) HStack(spacing: 8) { Button { - store.send(.addBlankRepository(.remote)) + store.send(.addRemoteButtonTapped) } label: { Label("Add Remote", systemImage: "network") } @@ -134,6 +134,18 @@ struct WorkspaceCreationPromptView: View { .task { isTitleFieldFocused = true } + .sheet( + isPresented: Binding( + get: { store.remoteRepositoryPrompt != nil }, + set: { isPresented in + if !isPresented { + store.send(.remoteRepositoryPromptDismissed) + } + } + ) + ) { + remoteRepositoryPromptView() + } } private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { @@ -242,36 +254,126 @@ struct WorkspaceCreationPromptView: View { private func repositoryBranchFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { HStack(spacing: 8) { - TextField( - "Branch", - text: Binding( - get: { repository.branchName ?? "" }, - set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) - Picker( - "Base ref", + "Branch action", selection: Binding( - get: { repository.baseRef ?? "" }, - set: { store.send(.repositoryBaseRefChanged(repository.id, $0)) } + get: { repository.checkoutMode }, + set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } ) ) { - Text("Base ref").tag("") - ForEach(repository.baseRefOptions, id: \.self) { option in - Text(option).tag(option) - } + Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) + Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) } .pickerStyle(.menu) .labelsHidden() - .frame(maxWidth: .infinity, alignment: .leading) - .help("Choose Base Ref") + .frame(width: 150) + .help("Choose Branch Action") + .disabled(store.isCreating) + + if repository.checkoutMode == .createBranch { + TextField( + "Branch", + text: Binding( + get: { repository.branchName ?? "" }, + set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + } + + WorkspaceBranchRefPickerView( + title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", + selection: repository.baseRef, + options: repository.baseRefOptions, + isDisabled: store.isCreating || repository.baseRefOptions.isEmpty + ) { ref in + store.send(.repositoryBaseRefChanged(repository.id, ref)) + } .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } } + @ViewBuilder + private func remoteRepositoryPromptView() -> some View { + if let prompt = store.remoteRepositoryPrompt { + VStack(alignment: .leading, spacing: 16) { + Text("Add Remote Repository") + .font(.title3) + + VStack(alignment: .leading, spacing: 8) { + Text("Remote URL") + .foregroundStyle(.secondary) + TextField( + "git@github.com:owner/repo.git", + text: Binding( + get: { prompt.url }, + set: { store.send(.remoteRepositoryPromptURLChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(prompt.isLoading) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + "Repository name", + text: Binding( + get: { prompt.name }, + set: { store.send(.remoteRepositoryPromptNameChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(prompt.isLoading) + } + + if !prompt.branchOptions.isEmpty { + Text("\(prompt.branchOptions.count) remote branches loaded") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let message = prompt.validationMessage, !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + } + + HStack { + if prompt.isLoading { + ProgressView() + .controlSize(.small) + } + Spacer() + Button("Cancel") { + store.send(.remoteRepositoryPromptDismissed) + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + .disabled(prompt.isLoading) + + Button("Load") { + store.send(.remoteRepositoryPromptLoadButtonTapped) + } + .help("Load Remote Branches") + .disabled(prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button("Add") { + store.send(.remoteRepositoryPromptAddButtonTapped) + } + .keyboardShortcut(.defaultAction) + .help("Add Remote Repository") + .disabled(prompt.isLoading || prompt.branchOptions.isEmpty) + } + } + .padding(20) + .frame(width: 520) + } + } + private func chooseFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -349,3 +451,102 @@ struct WorkspaceCreationPromptView: View { } } } + +private struct WorkspaceBranchRefPickerView: View { + let title: String + let selection: String? + let options: [GitBranchRefOption] + let isDisabled: Bool + let onSelect: (String) -> Void + + @State private var isPresented = false + @State private var searchText = "" + + var body: some View { + Button { + isPresented = true + } label: { + HStack { + Text(displayTitle) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Image(systemName: "chevron.down") + .imageScale(.small) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + .help("Choose \(title)") + .disabled(isDisabled) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 10) { + TextField("Search branches", text: $searchText) + .textFieldStyle(.roundedBorder) + + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(groupedOptions, id: \.kind) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.kind.title) + .font(.caption) + .foregroundStyle(.secondary) + ForEach(group.options) { option in + Button { + onSelect(option.ref) + isPresented = false + searchText = "" + } label: { + HStack { + if option.ref == selection { + Image(systemName: "checkmark") + .frame(width: 14) + .accessibilityHidden(true) + } else { + Color.clear + .frame(width: 14, height: 1) + } + Text(option.ref) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + } + .buttonStyle(.plain) + .padding(.vertical, 3) + } + } + } + if groupedOptions.isEmpty { + Text("No matching branches") + .foregroundStyle(.secondary) + } + } + } + .frame(maxHeight: 260) + } + .padding(14) + .frame(width: 420) + } + } + + private var displayTitle: String { + guard let selection, !selection.isEmpty else { + return title + } + return selection + } + + private var groupedOptions: [(kind: GitBranchRefKind, options: [GitBranchRefOption])] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = + query.isEmpty + ? options + : options.filter { $0.ref.localizedCaseInsensitiveContains(query) } + return GitBranchRefKind.allCases.compactMap { kind in + let group = filtered.filter { $0.kind == kind } + return group.isEmpty ? nil : (kind, group) + } + } +} diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index 9c025542..b6b0f493 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -39,17 +39,15 @@ struct GitClientBranchRefsTests { #expect(remote == nil) } - @Test func branchRefsIncludesLocalAndUpstreamRefs() async throws { + @Test func branchRefsIncludesLocalAndRemoteTrackingRefs() async throws { let store = ShellCallStore() - let output = """ - feature\torigin/feature - main\t - bugfix\torigin/bugfix - """ let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) - return ShellOutput(stdout: output, stderr: "", exitCode: 0) + if arguments.contains("refs/heads") { + return ShellOutput(stdout: "feature\nmain\nbugfix\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "origin/feature\norigin/bugfix\n", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) @@ -61,22 +59,27 @@ struct GitClientBranchRefsTests { let expected = ["bugfix", "feature", "main", "origin/bugfix", "origin/feature"] .sorted { $0.localizedStandardCompare($1) == .orderedAscending } #expect(refs == expected) + let options = try await client.branchRefOptions(for: repoRoot) + #expect(options.contains(GitBranchRefOption(ref: "main", kind: .local))) + #expect(options.contains(GitBranchRefOption(ref: "origin/feature", kind: .remoteTracking))) let calls = await store.calls - #expect(calls.count == 1) + #expect(calls.count == 4) let args = calls[0] #expect(args.first == "git") #expect(args.contains("for-each-ref")) #expect(args.contains("refs/heads")) - #expect(args.contains("--format=%(refname:short)\t%(upstream:short)")) + #expect(args.contains("--format=%(refname:short)")) + #expect(calls[1].contains("refs/remotes")) } @Test func branchRefsDropsOriginHead() async throws { - let output = """ - head\torigin/HEAD - main\torigin/main - """ let shell = ShellClient( - run: { _, _, _ in ShellOutput(stdout: output, stderr: "", exitCode: 0) }, + run: { _, arguments, _ in + if arguments.contains("refs/heads") { + return ShellOutput(stdout: "head\nmain\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "origin/HEAD\norigin/main\n", stderr: "", exitCode: 0) + }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell) @@ -87,6 +90,32 @@ struct GitClientBranchRefsTests { #expect(refs == ["head", "main", "origin/main"]) } + @Test func remoteBranchRefsParsesDefaultHeadAndBranches() async throws { + let output = """ + ref: refs/heads/main\tHEAD + abc123\tHEAD + abc123\trefs/heads/main + def456\trefs/heads/feature/login + """ + let shell = ShellClient( + run: { _, arguments, _ in + #expect(arguments.contains("ls-remote")) + return ShellOutput(stdout: output, stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let refs = try await client.remoteBranchRefs(for: "git@github.com:onevcat/app.git") + + #expect(refs.defaultBaseRef == "origin/main") + #expect( + refs.options == [ + GitBranchRefOption(ref: "origin/feature/login", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), + ]) + } + @Test func defaultRemoteBranchRefStripsPrefix() async throws { let shell = ShellClient( run: { _, _, _ in diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 8345fef1..cb900958 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -7,6 +7,22 @@ import Testing @MainActor struct ProjectWorkspaceTests { + @Test func baseRefOptionsKeepsDetectedKindForAutomaticSlashBranch() { + let options = ProjectWorkspaceCreationRepository.baseRefOptions( + automaticBaseRef: "feature/login", + options: [ + GitBranchRefOption(ref: "feature/login", kind: .local), + GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), + ] + ) + + #expect( + options == [ + GitBranchRefOption(ref: "feature/login", kind: .local), + GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), + ]) + } + @Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { let rootURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -246,6 +262,65 @@ struct ProjectWorkspaceTests { #expect(loaded.repositories.map(\.baseRef) == ["origin/main", "main"]) } + @Test func createWorkspaceUsesExistingRefsWithoutCreatingBranches() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-existing-ref-workspace-\(UUID().uuidString)") + .standardizedFileURL + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + _ = try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Existing Refs", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceCreationRepository( + id: "app", + name: "App", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkoutMode: .useExistingRef, + branchName: "codex/app", + baseRef: "origin/feature/login", + path: "app" + ), + ProjectWorkspaceCreationRepository( + id: "api", + name: "API", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + checkoutMode: .useExistingRef, + branchName: "codex/api", + baseRef: "main", + path: "api" + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 3_456_789) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + let rootPath = rootURL.path(percentEncoded: false) + let barePath = normalizedTestPath(bareURL) + #expect( + commands.value.map(\.arguments) == [ + ["clone", "git@github.com:onevcat/app.git", "\(rootPath)/app"], + ["-C", "\(rootPath)/app", "checkout", "feature/login"], + ["-C", barePath, "worktree", "add", "\(rootPath)/api", "main"], + ]) + + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.repositories.map(\.branchName) == [nil, nil]) + #expect(loaded.repositories.map(\.baseRef) == ["origin/feature/login", "main"]) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 45b80ed7..4cac8b70 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -579,7 +579,7 @@ struct RepositoriesFeatureTests { } withDependencies: { $0.gitClient.repoRoot = { url in url } $0.gitClient.automaticWorktreeBaseRef = { _ in "master" } - $0.gitClient.branchRefs = { _ in ["master"] } + $0.gitClient.branchRefOptions = { _ in [GitBranchRefOption(ref: "master", kind: .local)] } } await store.send(.workspaceCreation(.promptRequested)) { @@ -604,7 +604,7 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRef = "master" $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRefOptions = [ - "master" + GitBranchRefOption(ref: "master", kind: .local) ] } } @@ -630,8 +630,16 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { url in url.path(percentEncoded: false) == repoRootA ? "main" : "master" } - $0.gitClient.branchRefs = { url in - url.path(percentEncoded: false) == repoRootA ? ["main", "origin/main"] : ["master", "origin/master"] + $0.gitClient.branchRefOptions = { url in + url.path(percentEncoded: false) == repoRootA + ? [ + GitBranchRefOption(ref: "main", kind: .local), + GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), + ] + : [ + GitBranchRefOption(ref: "master", kind: .local), + GitBranchRefOption(ref: "origin/master", kind: .remoteTracking), + ] } } @@ -658,15 +666,15 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRefOptions = [ - "main", - "origin/main", + GitBranchRefOption(ref: "main", kind: .local), + GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), ] } await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRef = "master" $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRefOptions = [ - "master", - "origin/master", + GitBranchRefOption(ref: "master", kind: .local), + GitBranchRefOption(ref: "origin/master", kind: .remoteTracking), ] } } @@ -713,7 +721,11 @@ struct RepositoriesFeatureTests { ) } - @Test func workspaceCreationPromptAddsRemoteAndLocalRepositories() async { + @Test func workspaceCreationPromptAddsRemoteRepositoryAfterLoadingRefs() async { + let options = [ + GitBranchRefOption(ref: "origin/feature/login", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), + ] let store = TestStore( initialState: WorkspaceCreationPromptFeature.State( repositories: [], @@ -725,42 +737,108 @@ struct RepositoriesFeatureTests { WorkspaceCreationPromptFeature() } withDependencies: { $0.uuid = .incrementing + $0.gitClient.remoteBranchRefs = { remoteURL in + #expect(remoteURL == "git@github.com:onevcat/app.git") + return GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main") + } } - await store.send(.addBlankRepository(.remote)) { - $0.repositories = [ + await store.send(.addRemoteButtonTapped) { + $0.remoteRepositoryPrompt = WorkspaceCreationPromptFeature.RemoteRepositoryPromptState() + } + await store.send(.remoteRepositoryPromptURLChanged(" git@github.com:onevcat/app.git ")) { + $0.remoteRepositoryPrompt?.url = " git@github.com:onevcat/app.git " + $0.remoteRepositoryPrompt?.name = "app" + } + await store.send(.remoteRepositoryPromptLoadButtonTapped) { + $0.remoteRepositoryPrompt?.url = "git@github.com:onevcat/app.git" + $0.remoteRepositoryPrompt?.isLoading = true + } + await store.receive( + .remoteRepositoryPromptLoaded( + "git@github.com:onevcat/app.git", GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) + ) { + $0.remoteRepositoryPrompt?.isLoading = false + $0.remoteRepositoryPrompt?.branchOptions = options + $0.remoteRepositoryPrompt?.defaultBaseRef = "origin/main" + } + await store.send(.remoteRepositoryPromptAddButtonTapped) { + $0.repositories.append( ProjectWorkspaceCreationRepository( id: UUID(0).uuidString, - name: "", + name: "app", sourceKind: .remote, - sourceLocation: "" + sourceLocation: "git@github.com:onevcat/app.git", + checkoutMode: .useExistingRef, + baseRef: "origin/main", + baseRefOptions: options ) - ] + ) $0.selectedRepositoryIDs = [UUID(0).uuidString] + $0.remoteRepositoryPrompt = nil + } + } + + @Test func workspaceCreationPromptRemoteLoadUsesDetectedDefaultFromOptions() async { + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [] + ) + ) { + WorkspaceCreationPromptFeature() } - await store.send(.repositoryNameChanged(UUID(0).uuidString, "App")) { - $0.repositories[id: UUID(0).uuidString]?.name = "App" + await store.send(.addRemoteButtonTapped) { + $0.remoteRepositoryPrompt = WorkspaceCreationPromptFeature.RemoteRepositoryPromptState() } - await store.send(.repositorySourceLocationChanged(UUID(0).uuidString, "git@github.com:onevcat/app.git")) { - $0.repositories[id: UUID(0).uuidString]?.sourceLocation = "git@github.com:onevcat/app.git" + await store.send( + .remoteRepositoryPromptLoaded( + "", + GitRemoteBranchRefs( + options: [ + GitBranchRefOption(ref: " origin/main ", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), + ], + defaultBaseRef: "origin/missing" + ) + ) + ) { + $0.remoteRepositoryPrompt?.branchOptions = [ + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote) + ] + $0.remoteRepositoryPrompt?.defaultBaseRef = "origin/main" } - await store.send(.repositoryBranchNameChanged(UUID(0).uuidString, "codex/app")) { - $0.repositories[id: UUID(0).uuidString]?.branchName = "codex/app" + } + + @Test func workspaceCreationPromptAddsLocalRepository() async { + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [] + ) + ) { + WorkspaceCreationPromptFeature() + } withDependencies: { + $0.uuid = .incrementing } await store.send(.addRepositoryFromURL(.localRepository, "/tmp/local-api")) { $0.repositories.append( ProjectWorkspaceCreationRepository( - id: UUID(1).uuidString, + id: UUID(0).uuidString, name: "local-api", sourceKind: .localRepository, sourceLocation: "/tmp/local-api" ) ) - $0.selectedRepositoryIDs = [UUID(0).uuidString, UUID(1).uuidString] + $0.selectedRepositoryIDs = [UUID(0).uuidString] } - await store.receive(.delegate(.baseRefSourceChanged(UUID(1).uuidString))) + await store.receive(.delegate(.baseRefSourceChanged(UUID(0).uuidString))) } @Test func workspaceCreationPromptRejectsUnknownBaseRef() async { @@ -773,13 +851,13 @@ struct RepositoriesFeatureTests { id: repoRootA, name: "Repo A", rootURL: URL(fileURLWithPath: repoRootA), - baseRefOptions: ["main"] + baseRefOptions: [GitBranchRefOption(ref: "main", kind: .local)] ), ProjectWorkspaceCreationRepository( id: repoRootB, name: "Repo B", rootURL: URL(fileURLWithPath: repoRootB), - baseRefOptions: ["master"] + baseRefOptions: [GitBranchRefOption(ref: "master", kind: .local)] ), ], title: "Workspace", @@ -796,6 +874,78 @@ struct RepositoriesFeatureTests { } } + @Test func workspaceCreationPromptRequiresExistingRefForUseExistingMode() async { + let repoRootA = "/tmp/repo-a" + let repoRootB = "/tmp/repo-b" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + checkoutMode: .useExistingRef, + baseRefOptions: [GitBranchRefOption(ref: "main", kind: .local)] + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "Repo B", + rootURL: URL(fileURLWithPath: repoRootB) + ), + ], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [repoRootA, repoRootB] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.createButtonTapped) { + $0.validationMessage = "Choose an existing branch for Repo A." + } + await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { + $0.repositories[id: repoRootA]?.baseRef = "main" + $0.validationMessage = nil + } + } + + @Test func workspaceCreationPromptRequiresBranchNameForCreateBranchMode() async { + let repoRootB = "/tmp/repo-b" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: "remote", + name: "Remote", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + baseRef: "origin/main", + baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)] + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "Repo B", + rootURL: URL(fileURLWithPath: repoRootB) + ), + ], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: ["remote", repoRootB] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.createButtonTapped) { + $0.validationMessage = "Branch name required for Remote." + } + await store.send(.repositoryBranchNameChanged("remote", "codex/app")) { + $0.repositories[id: "remote"]?.branchName = "codex/app" + $0.validationMessage = nil + } + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) -- 2.51.2 From 3e4509dd776e39ab98f3ea142b58a5c34c436502 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 16:03:49 +0800 Subject: [PATCH 446/528] Refine workspace repository picker --- docs/components/workspaces.md | 13 +- supacode/Clients/Git/GitClient.swift | 16 +- ...epositoriesFeature+WorkspaceCreation.swift | 23 +-- .../WorkspaceCreationPromptFeature.swift | 28 +++- .../Views/WorkspaceCreationPromptView.swift | 33 ++-- supacodeTests/GitClientBranchRefsTests.swift | 29 +++- supacodeTests/RepositoriesFeatureTests.swift | 152 ++++++++++++++---- 7 files changed, 211 insertions(+), 83 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 7329c93e..5381d4b5 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -34,8 +34,8 @@ my-feature-workspace/ Repository sources can be mixed in one workspace: -- Already opened repositories are added as symlinks with `source_kind: - existing_path`. +- Already opened repositories can be inserted from the **Add Opened** menu. They + are added as symlinks with `source_kind: existing_path`. - Local repository folders selected from disk are added as symlinks with `source_kind: local_repository`. - Remote repositories are added through a URL prompt that loads remote heads @@ -50,7 +50,8 @@ The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default branch such as `main` or `master`. Refs are grouped as local branches, remote tracking branches, or fetched remote branches, and the picker supports simple -text search. Base refs are selected from detected refs so workspace creation +text search. Remote default-branch pointers are shown explicitly, such as +`origin/HEAD`. Base refs are selected from detected refs so workspace creation does not try to checkout an arbitrary, nonexistent branch. Branch behavior is explicit: @@ -59,7 +60,11 @@ Branch behavior is explicit: new branch or worktree branch. Remote and bare sources require a branch name in this mode. - **Use Existing** uses the selected ref directly. For remote clones, Prowl - checks out the selected remote branch after clone. + checks out the selected remote branch after clone; Git creates the normal + local tracking branch for refs such as `origin/feature`. For bare + repositories, Prowl passes the selected ref to `git worktree add`, so local + branch refs produce branch worktrees and remote-tracking refs produce detached + worktrees. ## Metadata diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index c9934df8..3a8ede0c 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -144,7 +144,7 @@ struct GitClient { "-C", path, "for-each-ref", - "--format=%(refname:short)", + "--format=%(refname)", "refs/heads", ] ) @@ -154,15 +154,14 @@ struct GitClient { "-C", path, "for-each-ref", - "--format=%(refname:short)", + "--format=%(refname)", "refs/remotes", ] ) - let localRefs = parseRefLines(localOutput) + let localRefs = parseRefLines(localOutput, prefix: "refs/heads/") .filter { !$0.hasSuffix("/HEAD") } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } - let remoteRefs = parseRefLines(remoteOutput) - .filter { !$0.hasSuffix("/HEAD") } + let remoteRefs = parseRefLines(remoteOutput, prefix: "refs/remotes/") .sorted { $0.localizedStandardCompare($1) == .orderedAscending } return deduplicatedOptions( localRefs.map { GitBranchRefOption(ref: $0, kind: .local) } @@ -692,12 +691,15 @@ struct GitClient { .last { !$0.isEmpty } } - nonisolated private func parseRefLines(_ output: String) -> [String] { + nonisolated private func parseRefLines(_ output: String, prefix: String) -> [String] { output .split(whereSeparator: \.isNewline) .compactMap { line -> String? in let ref = String(line).trimmingCharacters(in: .whitespacesAndNewlines) - return ref.isEmpty ? nil : ref + guard !ref.isEmpty else { + return nil + } + return ref.hasPrefix(prefix) ? String(ref.dropFirst(prefix.count)) : ref } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 78784ff0..f3cd9b1e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -30,14 +30,15 @@ extension RepositoriesFeature { switch action { case .promptRequested: let candidates = state.workspaceCreationCandidates - let title = workspaceCreationDefaultTitle(candidates: candidates) + let title = "Workspace" state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( - repositories: candidates, + repositories: [], title: title, rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), - selectedRepositoryIDs: Set(candidates.map(\.id)) + selectedRepositoryIDs: [], + openedRepositoryCandidates: candidates ) - return workspaceBaseRefsEffect(for: candidates) + return .none case .promptCanceled, .promptDismissed: state.workspaceCreationPrompt = nil @@ -126,20 +127,6 @@ extension RepositoriesFeature { } } - private func workspaceCreationDefaultTitle(candidates: [ProjectWorkspaceCreationRepository]) -> String { - let names = candidates.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - if names.isEmpty { - return "Workspace" - } - if names.count <= 3 { - return names.joined(separator: " + ") - } - guard let first = names.first else { - return "Workspace" - } - return "\(first) + \(names.count - 1) repos" - } - private func defaultWorkspaceRootURL(title: String) -> URL { let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) let baseURL = SupacodePaths.workspacesDirectory diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 7f739ea9..89a1233e 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -7,6 +7,7 @@ struct WorkspaceCreationPromptFeature { @ObservableState struct State: Equatable { var repositories: IdentifiedArrayOf + var openedRepositoryCandidates: IdentifiedArrayOf var title: String var rootPath: String var selectedRepositoryIDs: Set @@ -15,11 +16,15 @@ struct WorkspaceCreationPromptFeature { var remoteRepositoryPrompt: RemoteRepositoryPromptState? var selectedRepositoryCount: Int { - repositories.count { selectedRepositoryIDs.contains($0.id) } + repositories.count } var selectedRepositories: [ProjectWorkspaceCreationRepository] { - repositories.filter { selectedRepositoryIDs.contains($0.id) } + Array(repositories) + } + + var availableOpenedRepositories: [ProjectWorkspaceCreationRepository] { + openedRepositoryCandidates.filter { repositories[id: $0.id] == nil } } var rootPathPreview: String { @@ -30,9 +35,14 @@ struct WorkspaceCreationPromptFeature { repositories: [ProjectWorkspaceCreationRepository], title: String, rootPath: String, - selectedRepositoryIDs: Set + selectedRepositoryIDs: Set, + openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [] ) { self.repositories = IdentifiedArray(repositories, uniquingIDsWith: { current, _ in current }) + self.openedRepositoryCandidates = IdentifiedArray( + openedRepositoryCandidates, + uniquingIDsWith: { current, _ in current } + ) self.title = title self.rootPath = rootPath self.selectedRepositoryIDs = selectedRepositoryIDs @@ -57,6 +67,7 @@ struct WorkspaceCreationPromptFeature { enum Action: BindableAction, Equatable { case binding(BindingAction) case addBlankRepository(ProjectWorkspaceRepositorySourceKind) + case addOpenedRepository(Repository.ID) case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) case addRemoteButtonTapped case remoteRepositoryPromptURLChanged(String) @@ -116,6 +127,17 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none + case .addOpenedRepository(let repositoryID): + guard let repository = state.openedRepositoryCandidates[id: repositoryID], + state.repositories[id: repositoryID] == nil + else { + return .none + } + state.repositories.append(repository) + state.selectedRepositoryIDs.insert(repositoryID) + state.validationMessage = nil + return .send(.delegate(.baseRefSourceChanged(repositoryID))) + case .addRemoteButtonTapped: state.remoteRepositoryPrompt = RemoteRepositoryPromptState() state.validationMessage = nil diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 2a0ca489..79884e79 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -17,7 +17,7 @@ struct WorkspaceCreationPromptView: View { VStack(alignment: .leading, spacing: 4) { Text("New Workspace") .font(.title3) - Text("\(store.selectedRepositoryCount) of \(store.repositories.count) repositories selected") + Text(repositoryCountText) .foregroundStyle(.secondary) } @@ -61,6 +61,20 @@ struct WorkspaceCreationPromptView: View { Text("Repositories") .foregroundStyle(.secondary) HStack(spacing: 8) { + Menu { + ForEach(store.availableOpenedRepositories) { repository in + Button { + store.send(.addOpenedRepository(repository.id)) + } label: { + Text(repository.name.isEmpty ? repository.sourceLocation : repository.name) + } + } + } label: { + Label("Add Opened", systemImage: "folder.badge.plus") + } + .help("Add Opened Path") + .disabled(store.isCreating || store.availableOpenedRepositories.isEmpty) + Button { store.send(.addRemoteButtonTapped) } label: { @@ -148,6 +162,10 @@ struct WorkspaceCreationPromptView: View { } } + private var repositoryCountText: String { + store.repositories.count == 1 ? "1 repository" : "\(store.repositories.count) repositories" + } + private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { VStack(alignment: .leading, spacing: 10) { repositoryHeader(repository) @@ -161,16 +179,9 @@ struct WorkspaceCreationPromptView: View { private func repositoryHeader(_ repository: ProjectWorkspaceCreationRepository) -> some View { HStack(spacing: 10) { - Toggle( - isOn: Binding( - get: { store.selectedRepositoryIDs.contains(repository.id) }, - set: { store.send(.repositorySelectionChanged(repository.id, $0)) } - ) - ) { - Text(repository.name.isEmpty ? "Repository" : repository.name) - .fontWeight(.medium) - } - .toggleStyle(.checkbox) + Text(repository.name.isEmpty ? "Repository" : repository.name) + .fontWeight(.medium) + .lineLimit(1) Picker( "Source", diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index b6b0f493..a1f875fc 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -45,9 +45,17 @@ struct GitClientBranchRefsTests { run: { _, arguments, _ in await store.record(arguments) if arguments.contains("refs/heads") { - return ShellOutput(stdout: "feature\nmain\nbugfix\n", stderr: "", exitCode: 0) + return ShellOutput( + stdout: "refs/heads/feature\nrefs/heads/main\nrefs/heads/bugfix\n", + stderr: "", + exitCode: 0 + ) } - return ShellOutput(stdout: "origin/feature\norigin/bugfix\n", stderr: "", exitCode: 0) + return ShellOutput( + stdout: "refs/remotes/origin/feature\nrefs/remotes/origin/bugfix\n", + stderr: "", + exitCode: 0 + ) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) @@ -68,17 +76,21 @@ struct GitClientBranchRefsTests { #expect(args.first == "git") #expect(args.contains("for-each-ref")) #expect(args.contains("refs/heads")) - #expect(args.contains("--format=%(refname:short)")) + #expect(args.contains("--format=%(refname)")) #expect(calls[1].contains("refs/remotes")) } - @Test func branchRefsDropsOriginHead() async throws { + @Test func branchRefOptionsSpellRemoteHeadExplicitly() async throws { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("refs/heads") { - return ShellOutput(stdout: "head\nmain\n", stderr: "", exitCode: 0) + return ShellOutput(stdout: "refs/heads/main\n", stderr: "", exitCode: 0) } - return ShellOutput(stdout: "origin/HEAD\norigin/main\n", stderr: "", exitCode: 0) + return ShellOutput( + stdout: "refs/remotes/origin/HEAD\nrefs/remotes/origin/main\n", + stderr: "", + exitCode: 0 + ) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) @@ -86,8 +98,11 @@ struct GitClientBranchRefsTests { let repoRoot = URL(fileURLWithPath: "/tmp/repo") let refs = try await client.branchRefs(for: repoRoot) + let options = try await client.branchRefOptions(for: repoRoot) - #expect(refs == ["head", "main", "origin/main"]) + #expect(refs == ["main", "origin/main"]) + #expect(options.contains(GitBranchRefOption(ref: "origin/HEAD", kind: .remoteTracking))) + #expect(!options.contains(GitBranchRefOption(ref: "origin", kind: .remoteTracking))) } @Test func remoteBranchRefsParsesDefaultHeadAndBranches() async throws { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 4cac8b70..5a9a0b27 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -572,44 +572,124 @@ struct RepositoriesFeatureTests { await store.finish() } - @Test func workspaceCreationPromptOpensWithoutTwoRepositories() async { + @Test func workspaceCreationPromptOffersOpenedRepositoriesWithoutAddingThem() async { let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() - } withDependencies: { - $0.gitClient.repoRoot = { url in url } - $0.gitClient.automaticWorktreeBaseRef = { _ in "master" } - $0.gitClient.branchRefOptions = { _ in [GitBranchRefOption(ref: "master", kind: .local)] } } await store.send(.workspaceCreation(.promptRequested)) { - let title = "Repo A" + let title = "Workspace" let expectedRootPath = SupacodePaths.workspacesDirectory .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) .standardizedFileURL .path(percentEncoded: false) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( - repositories: [ + repositories: [], + title: title, + rootPath: expectedRootPath, + selectedRepositoryIDs: [], + openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: "/tmp/repo-a", name: "Repo A", rootURL: URL(fileURLWithPath: "/tmp/repo-a") ) - ], + ] + ) + } + } + + @Test func workspaceCreationPromptAddsOpenedRepositoryOnRequest() async { + let repoRootA = "/tmp/repo-a" + let candidate = ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA) + ) + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [], + openedRepositoryCandidates: [candidate] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.addOpenedRepository(repoRootA)) { + $0.repositories = [candidate] + $0.selectedRepositoryIDs = [repoRootA] + } + await store.receive(.delegate(.baseRefSourceChanged(repoRootA))) + } + + @Test func workspaceCreationPromptIgnoresDuplicateOpenedRepository() async { + let repoRootA = "/tmp/repo-a" + let candidate = ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA) + ) + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [candidate], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [repoRootA], + openedRepositoryCandidates: [candidate] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.addOpenedRepository(repoRootA)) + } + + @Test func workspaceCreationPromptOffersLoadedRepositories() async { + let testID = UUID().uuidString + let repoRootA = "/tmp/\(testID)-repo-a" + let repoRootB = "/tmp/\(testID)-repo-b" + let worktreeA = makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA) + let repoA = makeRepository(id: repoRootA, name: "Repo A", worktrees: [worktreeA]) + let repoB = makeRepository(id: repoRootB, name: "Repo B", worktrees: []) + var state = makeState(repositories: [repoA, repoB]) + state.repositoryCustomTitles[repoB.id] = "API" + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.workspaceCreation(.promptRequested)) { + let title = "Workspace" + let expectedRootPath = SupacodePaths.workspacesDirectory + .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [], title: title, rootPath: expectedRootPath, - selectedRepositoryIDs: ["/tmp/repo-a"] + selectedRepositoryIDs: [], + openedRepositoryCandidates: [ + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + branchName: "main" + ), + ProjectWorkspaceCreationRepository( + id: repoRootB, + name: "API", + rootURL: URL(fileURLWithPath: repoRootB) + ), + ] ) } - await store.receive(\.workspaceCreation.baseRefsLoaded) { - $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRef = "master" - $0.workspaceCreationPrompt?.repositories[id: "/tmp/repo-a"]?.baseRefOptions = [ - GitBranchRefOption(ref: "master", kind: .local) - ] - } } - @Test func workspaceCreationPromptUsesLoadedRepositories() async { + @Test func workspaceCreationPromptLoadsBaseRefsForAddedOpenedRepository() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" @@ -618,11 +698,6 @@ struct RepositoriesFeatureTests { let repoB = makeRepository(id: repoRootB, name: "Repo B", worktrees: []) var state = makeState(repositories: [repoA, repoB]) state.repositoryCustomTitles[repoB.id] = "API" - let title = "Repo A + API" - let expectedRootPath = SupacodePaths.workspacesDirectory - .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { @@ -644,8 +719,17 @@ struct RepositoriesFeatureTests { } await store.send(.workspaceCreation(.promptRequested)) { + let title = "Workspace" + let expectedRootPath = SupacodePaths.workspacesDirectory + .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( - repositories: [ + repositories: [], + title: title, + rootPath: expectedRootPath, + selectedRepositoryIDs: [], + openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", @@ -657,12 +741,21 @@ struct RepositoriesFeatureTests { name: "API", rootURL: URL(fileURLWithPath: repoRootB) ), - ], - title: title, - rootPath: expectedRootPath, - selectedRepositoryIDs: [repoRootA, repoRootB] + ] + ) + } + await store.send(.workspaceCreationPrompt(.presented(.addOpenedRepository(repoRootA)))) { + $0.workspaceCreationPrompt?.repositories.append( + ProjectWorkspaceCreationRepository( + id: repoRootA, + name: "Repo A", + rootURL: URL(fileURLWithPath: repoRootA), + branchName: "main" + ) ) + $0.workspaceCreationPrompt?.selectedRepositoryIDs = [repoRootA] } + await store.receive(\.workspaceCreation.refreshBaseRefs) await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRefOptions = [ @@ -670,13 +763,6 @@ struct RepositoriesFeatureTests { GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), ] } - await store.receive(\.workspaceCreation.baseRefsLoaded) { - $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRef = "master" - $0.workspaceCreationPrompt?.repositories[id: repoRootB]?.baseRefOptions = [ - GitBranchRefOption(ref: "master", kind: .local), - GitBranchRefOption(ref: "origin/master", kind: .remoteTracking), - ] - } } @Test func workspaceCreationPromptSubmitBuildsDraft() async { -- 2.51.2 From da812a7a072ae741fed851bc93129941d010b509 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 16:59:47 +0800 Subject: [PATCH 447/528] Fix workspace base ref refresh --- supacode/Clients/Git/GitClient.swift | 2 +- .../WorkspaceCreationPromptFeature.swift | 16 ++++- supacodeTests/GitClientBranchRefsTests.swift | 14 ++++- supacodeTests/RepositoriesFeatureTests.swift | 61 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 3a8ede0c..8968ffe8 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -172,7 +172,7 @@ struct GitClient { nonisolated func remoteBranchRefs(for remoteURL: String) async throws -> GitRemoteBranchRefs { let output = try await runGit( operation: .remoteBranchRefs, - arguments: ["ls-remote", "--heads", "--symref", remoteURL] + arguments: ["ls-remote", "--symref", remoteURL, "HEAD", "refs/heads/*"] ) let parsed = parseRemoteBranchRefs(output) let options = parsed.refs diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 89a1233e..68f55d76 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -332,9 +332,21 @@ struct WorkspaceCreationPromptFeature { return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): - state.repositories[id: repositoryID]?.sourceLocation = sourceLocation + guard var repository = state.repositories[id: repositoryID] else { + return .none + } + let sourceLocationChanged = repository.sourceLocation != sourceLocation + repository.sourceLocation = sourceLocation + if sourceLocationChanged { + repository.baseRef = nil + repository.baseRefOptions = [] + } + state.repositories[id: repositoryID] = repository state.validationMessage = nil - return .none + guard sourceLocationChanged, repository.sourceKind != .remote, repository.localSourceURL != nil else { + return .none + } + return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositoryBranchNameChanged(let repositoryID, let branchName): state.repositories[id: repositoryID]?.branchName = branchName diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index a1f875fc..d363050e 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -109,12 +109,20 @@ struct GitClientBranchRefsTests { let output = """ ref: refs/heads/main\tHEAD abc123\tHEAD + def456\trefs/heads/develop abc123\trefs/heads/main - def456\trefs/heads/feature/login """ let shell = ShellClient( run: { _, arguments, _ in - #expect(arguments.contains("ls-remote")) + #expect( + arguments == [ + "git", + "ls-remote", + "--symref", + "git@github.com:onevcat/app.git", + "HEAD", + "refs/heads/*", + ]) return ShellOutput(stdout: output, stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } @@ -126,7 +134,7 @@ struct GitClientBranchRefsTests { #expect(refs.defaultBaseRef == "origin/main") #expect( refs.options == [ - GitBranchRefOption(ref: "origin/feature/login", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/develop", kind: .fetchedRemote), GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), ]) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 5a9a0b27..5313fb48 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -927,6 +927,67 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(UUID(0).uuidString))) } + @Test func workspaceCreationPromptClearsAndReloadsBaseRefsWhenSourceLocationChanges() async { + let repositoryID = "repo" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repositoryID, + name: "Repo", + sourceKind: .bareRepository, + sourceLocation: "/tmp/repo-a.git", + checkoutMode: .useExistingRef, + baseRef: "origin/main", + baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .remoteTracking)] + ) + ], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [repositoryID] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.repositorySourceLocationChanged(repositoryID, "/tmp/repo-b.git")) { + $0.repositories[id: repositoryID]?.sourceLocation = "/tmp/repo-b.git" + $0.repositories[id: repositoryID]?.baseRef = nil + $0.repositories[id: repositoryID]?.baseRefOptions = [] + } + await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) + } + + @Test func workspaceCreationPromptClearsBaseRefsWhenRemoteSourceLocationChanges() async { + let repositoryID = "remote" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repositoryID, + name: "Remote", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkoutMode: .useExistingRef, + baseRef: "origin/main", + baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)] + ) + ], + title: "Workspace", + rootPath: "/tmp/workspace", + selectedRepositoryIDs: [repositoryID] + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git")) { + $0.repositories[id: repositoryID]?.sourceLocation = "git@github.com:onevcat/other.git" + $0.repositories[id: repositoryID]?.baseRef = nil + $0.repositories[id: repositoryID]?.baseRefOptions = [] + } + } + @Test func workspaceCreationPromptRejectsUnknownBaseRef() async { let repoRootA = "/tmp/repo-a" let repoRootB = "/tmp/repo-b" -- 2.51.2 From 8304dfa922ef336e622c36a332a72c4785617271 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 18:21:56 +0800 Subject: [PATCH 448/528] Rework workspace checkout modes and creation rollback - Add Link / Create Branch / Use Existing checkout modes; local sources materialize with git worktree add instead of metadata-only branches - Validate checkout into a typed plan so invalid combinations cannot reach materialization - Register clone artifacts before checkout and run rollback in a detached task so canceling an in-flight creation cleans up reliably - Allow Cancel during creation and show a toast on cancellation - Surface branch detection errors, log workspace failures, and remove dead selection state and always-true guards - Pass --end-of-options to git invocations that take user input --- docs/components/workspaces.md | 30 +- supacode/Clients/Git/GitClient.swift | 2 +- supacode/Clients/Git/GitClientTypes.swift | 15 + supacode/Commands/WorktreeCommands.swift | 1 - supacode/Domain/ProjectWorkspace.swift | 429 +++++++++++------- supacode/Domain/Repository.swift | 3 +- ...epositoriesFeature+WorkspaceCreation.swift | 157 ++++--- .../Reducer/RepositoriesFeature.swift | 3 +- .../WorkspaceCreationPromptFeature.swift | 148 +++--- .../Repositories/Views/SidebarListView.swift | 1 - .../Views/WorkspaceCreationPromptView.swift | 38 +- .../AppFeatureCommandPaletteTests.swift | 3 +- supacodeTests/GitClientBranchRefsTests.swift | 1 + supacodeTests/ProjectWorkspaceTests.swift | 344 ++++++++++++-- supacodeTests/RepositoriesFeatureTests.swift | 157 +++++-- 15 files changed, 932 insertions(+), 400 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 5381d4b5..ee7e421f 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -21,7 +21,11 @@ When you open a workspace in Prowl: Use **New Workspace** from the sidebar toolbar, Worktrees menu, or command palette to create a workspace. Prowl creates the shared folder, materializes the selected repositories, writes `.prowl/workspace.json`, and opens the workspace -as a runnable folder. +as a runnable folder. A workspace needs at least two repositories. + +While a workspace is being created the prompt shows a spinner. **Cancel** stops +the creation and rolls back everything created so far: cloned folders, created +worktrees, and the workspace folder itself when Prowl created it. ```text my-feature-workspace/ @@ -34,10 +38,10 @@ my-feature-workspace/ Repository sources can be mixed in one workspace: -- Already opened repositories can be inserted from the **Add Opened** menu. They - are added as symlinks with `source_kind: existing_path`. -- Local repository folders selected from disk are added as symlinks with - `source_kind: local_repository`. +- Already opened repositories can be inserted from the **Add Opened** menu + (`source_kind: existing_path`). +- Local repository folders are selected from disk + (`source_kind: local_repository`). - Remote repositories are added through a URL prompt that loads remote heads before inserting the row. They are cloned into the workspace folder with `source_kind: remote`. The inserted row defaults to **Use Existing** on the @@ -46,6 +50,18 @@ Repository sources can be mixed in one workspace: `source_kind: bare_repository`. If both branch and base ref are supplied, Prowl creates the worktree branch from that base ref. +For already opened and local repositories, the branch action decides how the +folder is materialized: + +- **Link** (the default) adds a symlink to the repository as it is on disk, so + the workspace shares the live checkout. +- **Create Branch** runs `git worktree add -b` against the source repository: + the workspace gets an isolated checkout on a new branch created from the + selected base ref, without touching the source repository's own checkout. The + new worktree also appears in the source repository's worktree list. +- **Use Existing** runs `git worktree add` with the selected ref. Git rejects a + local branch that is already checked out elsewhere. + The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default branch such as `main` or `master`. Refs are grouped as local branches, remote @@ -57,8 +73,8 @@ does not try to checkout an arbitrary, nonexistent branch. Branch behavior is explicit: - **Create Branch** uses `branch_name` plus the selected base ref to create a - new branch or worktree branch. Remote and bare sources require a branch name - in this mode. + new branch or worktree branch. A branch name is required in this mode for + every source kind. - **Use Existing** uses the selected ref directly. For remote clones, Prowl checks out the selected remote branch after clone; Git creates the normal local tracking branch for refs such as `origin/feature`. For bare diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 8968ffe8..8034df41 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -172,7 +172,7 @@ struct GitClient { nonisolated func remoteBranchRefs(for remoteURL: String) async throws -> GitRemoteBranchRefs { let output = try await runGit( operation: .remoteBranchRefs, - arguments: ["ls-remote", "--symref", remoteURL, "HEAD", "refs/heads/*"] + arguments: ["ls-remote", "--symref", "--end-of-options", remoteURL, "HEAD", "refs/heads/*"] ) let parsed = parseRemoteBranchRefs(output) let options = parsed.refs diff --git a/supacode/Clients/Git/GitClientTypes.swift b/supacode/Clients/Git/GitClientTypes.swift index c8a427d8..5b531b27 100644 --- a/supacode/Clients/Git/GitClientTypes.swift +++ b/supacode/Clients/Git/GitClientTypes.swift @@ -126,6 +126,21 @@ nonisolated struct GitRemoteBranchRefs: Equatable, Sendable { var defaultBaseRef: String? } +nonisolated enum GitRemoteNaming { + static func repositoryName(fromRemoteURL remoteURL: String) -> String { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmed.isEmpty else { + return "" + } + let separatorIndex = trimmed.lastIndex { $0 == "/" || $0 == ":" } + let component = + separatorIndex.map { String(trimmed[trimmed.index(after: $0)...]) } + ?? trimmed + return component.hasSuffix(".git") ? String(component.dropLast(4)) : component + } +} + struct GitWtWorktreeEntry: Decodable, Equatable { let branch: String let path: String diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 777ad4b6..c1c191c7 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -90,7 +90,6 @@ struct WorktreeCommands: Commands { store.send(.repositories(.workspaceCreation(.promptRequested))) } .help("New Workspace") - .disabled(!repositories.canCreateWorkspace) Button("Open Worktree") { openSelectedWorktreeAction?() } diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index dd4a9bbd..e4c6b57c 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -5,13 +5,53 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl case localRepository = "local_repository" case bareRepository = "bare_repository" case existingPath = "existing_path" + + var supportsLinkCheckout: Bool { + switch self { + case .existingPath, .localRepository: + return true + case .remote, .bareRepository: + return false + } + } + + var defaultCheckoutMode: ProjectWorkspaceRepositoryCheckoutMode { + switch self { + case .existingPath, .localRepository: + return .link + case .bareRepository: + return .createBranch + case .remote: + return .useExistingRef + } + } + + func localSourceURL(from sourceLocation: String) -> URL? { + switch self { + case .existingPath, .localRepository, .bareRepository: + let trimmed = sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return URL(fileURLWithPath: trimmed).standardizedFileURL + case .remote: + return nil + } + } } nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, Sendable { + case link case createBranch = "create_branch" case useExistingRef = "use_existing_ref" } +nonisolated enum ProjectWorkspaceRepositoryCheckout: Equatable, Hashable, Sendable { + case link + case createBranch(branchName: String, baseRef: String?) + case useExistingRef(String) +} + nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { var id: String var name: String @@ -27,7 +67,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send id: String, name: String, rootURL: URL, - checkoutMode: ProjectWorkspaceRepositoryCheckoutMode = .createBranch, + checkoutMode: ProjectWorkspaceRepositoryCheckoutMode? = nil, branchName: String? = nil, path: String? = nil, baseRef: String? = nil, @@ -39,7 +79,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.path = path sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) - self.checkoutMode = checkoutMode + self.checkoutMode = checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) @@ -50,7 +90,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send name: String, sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, - checkoutMode: ProjectWorkspaceRepositoryCheckoutMode = .createBranch, + checkoutMode: ProjectWorkspaceRepositoryCheckoutMode? = nil, branchName: String? = nil, baseRef: String? = nil, path: String? = nil, @@ -61,23 +101,14 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.path = path self.sourceKind = sourceKind self.sourceLocation = sourceLocation - self.checkoutMode = checkoutMode + self.checkoutMode = checkoutMode ?? sourceKind.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) } var localSourceURL: URL? { - switch sourceKind { - case .existingPath, .localRepository, .bareRepository: - let trimmed = sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return URL(fileURLWithPath: trimmed).standardizedFileURL - case .remote: - return nil - } + sourceKind.localSourceURL(from: sourceLocation) } nonisolated static func baseRefOptions( @@ -96,11 +127,11 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) -> String? { let refs = Set(options.map(\.ref)) - if let automaticBaseRef, - !automaticBaseRef.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - refs.contains(automaticBaseRef) + if let trimmed = automaticBaseRef?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + refs.contains(trimmed) { - return automaticBaseRef + return trimmed } return ["main", "master", "origin/main", "origin/master"].first { refs.contains($0) } ?? options.first?.ref @@ -120,15 +151,28 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send } } +nonisolated struct ProjectWorkspaceRepositoryPlan: Equatable, Sendable, Identifiable { + var id: String + var name: String + var path: String? + var sourceKind: ProjectWorkspaceRepositorySourceKind + var sourceLocation: String + var checkout: ProjectWorkspaceRepositoryCheckout + + var localSourceURL: URL? { + sourceKind.localSourceURL(from: sourceLocation) + } +} + nonisolated struct ProjectWorkspaceCreationDraft: Equatable, Sendable { var title: String var rootURL: URL - var repositories: [ProjectWorkspaceCreationRepository] + var repositories: [ProjectWorkspaceRepositoryPlan] init( title: String, rootURL: URL, - repositories: [ProjectWorkspaceCreationRepository] + repositories: [ProjectWorkspaceRepositoryPlan] ) { self.title = title self.rootURL = rootURL.standardizedFileURL @@ -160,6 +204,9 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda case notEnoughRepositories case missingRepositoryName case missingRepositorySource(String) + case missingBranchName(String) + case missingExistingRef(String) + case linkCheckoutUnsupported(String) case destinationIsFile(String) case workspaceAlreadyExists(String) case repositoryDoesNotExist(String) @@ -178,6 +225,12 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda return "Repository name required." case .missingRepositorySource(let name): return "Source required for \(name)." + case .missingBranchName(let name): + return "Branch name required for \(name)." + case .missingExistingRef(let name): + return "Choose an existing branch for \(name)." + case .linkCheckoutUnsupported(let name): + return "Link is not available for \(name)." case .destinationIsFile(let path): return "\(path) is a file. Choose a folder path instead." case .workspaceAlreadyExists(let path): @@ -268,6 +321,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { nonisolated static let metadataDirectoryName = ".prowl" nonisolated static let metadataFileName = "workspace.json" + nonisolated private static let log = SupaLogger("workspace") var id: String var title: String @@ -329,7 +383,13 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - guard var workspace = try? decoder.decode(ProjectWorkspace.self, from: data) else { + var workspace: ProjectWorkspace + do { + workspace = try decoder.decode(ProjectWorkspace.self, from: data) + } catch { + log.warning( + "Ignoring malformed workspace metadata at \(metadataURL.path(percentEncoded: false)): \(error)" + ) return nil } let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) @@ -394,10 +454,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL - var createdRoot = false - var createdMetadataDirectory = false - var createdURLs: [URL] = [] - var cleanupCommands: [ProjectWorkspaceGitCommand] = [] + var ledger = MaterializationLedger() do { var isDirectory = ObjCBool(false) if fileManager.fileExists(atPath: rootPath, isDirectory: &isDirectory) { @@ -406,7 +463,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } } else { try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - createdRoot = true + ledger.createdRoot = true } let metadataDirectoryURL = rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory) @@ -417,24 +474,19 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } if !fileManager.fileExists(atPath: metadataPath) { try fileManager.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) - createdMetadataDirectory = true + ledger.createdMetadataDirectory = true } - var occupiedNames: Set = [] var entries: [RepositoryEntry] = [] for repository in request.draft.repositories { - let prepared = try await materialize( + let entry = try await materialize( repository, workspaceRootURL: rootURL, - occupiedNames: &occupiedNames, + ledger: &ledger, fileManager: fileManager, gitRunner: gitRunner ) - createdURLs.append(prepared.createdURL) - if let cleanupCommand = prepared.cleanupCommand { - cleanupCommands.append(cleanupCommand) - } - entries.append(prepared.entry) + entries.append(entry) } let workspace = ProjectWorkspace( @@ -449,22 +501,60 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] try encoder.encode(workspace).write(to: metadataURL, options: .atomic) - createdURLs.append(metadataURL) + ledger.createdURLs.append(metadataURL) return workspace } catch { - for command in cleanupCommands.reversed() { - try? await gitRunner.run(command) - } - for url in createdURLs.reversed() { - try? fileManager.removeItem(at: url) + await rollback(ledger, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) + throw error + } + } + + private struct MaterializationLedger: Sendable { + var occupiedNames: Set = [] + var createdURLs: [URL] = [] + var cleanupCommands: [ProjectWorkspaceGitCommand] = [] + var createdRoot = false + var createdMetadataDirectory = false + } + + // Rollback runs in a detached task so it survives cooperative cancellation of + // the surrounding effect: a cancelled parent task would otherwise SIGTERM the + // cleanup git subprocesses before they finish. + private static func rollback( + _ ledger: MaterializationLedger, + rootURL: URL, + fileManager: FileManager, + gitRunner: ProjectWorkspaceGitRunner + ) async { + nonisolated(unsafe) let fileManager = fileManager + let task = Task.detached { + for command in ledger.cleanupCommands.reversed() { + do { + try await gitRunner.run(command) + } catch { + log.warning("Workspace rollback command failed: \(command.displayCommand): \(error)") + } } - if createdRoot { - try? fileManager.removeItem(at: rootURL) - } else if createdMetadataDirectory { - try? fileManager.removeItem(at: rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory)) + let removableURLs = + ledger.createdURLs.reversed() + + (ledger.createdRoot + ? [rootURL] + : ledger.createdMetadataDirectory + ? [rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory)] + : []) + for url in removableURLs { + let path = url.path(percentEncoded: false) + guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true else { + continue + } + do { + try fileManager.removeItem(at: url) + } catch { + log.warning("Workspace rollback could not remove \(path): \(error)") + } } - throw error } + await task.value } static func defaultWorkspaceFolderName(for title: String) -> String { @@ -472,19 +562,13 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return sanitized.isEmpty ? "workspace" : sanitized } - private struct PreparedRepository { - var entry: RepositoryEntry - var createdURL: URL - var cleanupCommand: ProjectWorkspaceGitCommand? - } - private static func materialize( - _ repository: ProjectWorkspaceCreationRepository, + _ repository: ProjectWorkspaceRepositoryPlan, workspaceRootURL: URL, - occupiedNames: inout Set, + ledger: inout MaterializationLedger, fileManager: FileManager, gitRunner: ProjectWorkspaceGitRunner - ) async throws -> PreparedRepository { + ) async throws -> RepositoryEntry { let name = repositoryDisplayName(repository) guard !name.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositoryName @@ -493,144 +577,166 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { guard !sourceLocation.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositorySource(name) } - let workspacePath = uniqueRepositoryPath(for: repository, displayName: name, occupiedNames: &occupiedNames) + let checkout = try validatedCheckout(repository.checkout, sourceKind: repository.sourceKind, name: name) + let workspacePath = uniqueRepositoryPath( + for: repository, + displayName: name, + occupiedNames: &ledger.occupiedNames + ) let destinationURL = workspaceRootURL.appending(path: workspacePath, directoryHint: .isDirectory) let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) guard !fileManager.fileExists(atPath: destinationPath) else { throw ProjectWorkspaceCreationError.linkAlreadyExists(destinationPath) } - let sourceKind = repository.sourceKind - let normalizedSourceLocation: String? - let cleanupCommand: ProjectWorkspaceGitCommand? - switch sourceKind { + let normalizedSourceLocation: String + switch repository.sourceKind { case .existingPath, .localRepository: - let sourcePath = try localRepositoryPath(repository, sourceLocation: sourceLocation, fileManager: fileManager) - let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL - try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + switch checkout { + case .link: + let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + ledger.createdURLs.append(destinationURL) + case .createBranch, .useExistingRef: + try await gitRunner.run( + worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) + ) + ledger.createdURLs.append(destinationURL) + ledger.cleanupCommands.append( + worktreeRemoveCommand(sourcePath: sourcePath, destinationPath: destinationPath) + ) + } normalizedSourceLocation = sourcePath - cleanupCommand = nil case .remote: try await gitRunner.run( ProjectWorkspaceGitCommand( - arguments: ["clone", sourceLocation, destinationPath], + arguments: ["clone", "--end-of-options", sourceLocation, destinationPath], currentDirectoryURL: workspaceRootURL ) ) - try await checkoutIfNeeded( - repository, - destinationURL: destinationURL, - gitRunner: gitRunner - ) + ledger.createdURLs.append(destinationURL) + if let checkoutCommand = remoteCheckoutCommand(checkout, destinationPath: destinationPath) { + try await gitRunner.run(checkoutCommand) + } normalizedSourceLocation = sourceLocation - cleanupCommand = nil case .bareRepository: - let sourcePath = try localRepositoryPath(repository, sourceLocation: sourceLocation, fileManager: fileManager) - let command = bareWorktreeCommand( - repository, - sourcePath: sourcePath, - destinationPath: destinationPath + let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + try await gitRunner.run( + worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) - try await gitRunner.run(command) - normalizedSourceLocation = sourcePath - cleanupCommand = ProjectWorkspaceGitCommand( - arguments: ["-C", sourcePath, "worktree", "remove", "--force", destinationPath], - currentDirectoryURL: nil + ledger.createdURLs.append(destinationURL) + ledger.cleanupCommands.append( + worktreeRemoveCommand(sourcePath: sourcePath, destinationPath: destinationPath) ) + normalizedSourceLocation = sourcePath } - return PreparedRepository( - entry: RepositoryEntry( - id: repository.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? workspacePath, - name: name, - path: workspacePath, - sourceKind: sourceKind, - sourceLocation: normalizedSourceLocation, - branchName: repository.checkoutMode == .createBranch - ? repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - : nil, - baseRef: repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ), - createdURL: destinationURL, - cleanupCommand: cleanupCommand + let entryBranchName: String? + let entryBaseRef: String? + switch checkout { + case .link: + entryBranchName = nil + entryBaseRef = nil + case .createBranch(let branchName, let baseRef): + entryBranchName = branchName + entryBaseRef = baseRef + case .useExistingRef(let ref): + entryBranchName = nil + entryBaseRef = ref + } + + return RepositoryEntry( + id: repository.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? workspacePath, + name: name, + path: workspacePath, + sourceKind: repository.sourceKind, + sourceLocation: normalizedSourceLocation, + branchName: entryBranchName, + baseRef: entryBaseRef ) } - private static func checkoutIfNeeded( - _ repository: ProjectWorkspaceCreationRepository, - destinationURL: URL, - gitRunner: ProjectWorkspaceGitRunner - ) async throws { - let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) - let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - if repository.checkoutMode == .useExistingRef { - guard let baseRef else { - return + private static func validatedCheckout( + _ checkout: ProjectWorkspaceRepositoryCheckout, + sourceKind: ProjectWorkspaceRepositorySourceKind, + name: String + ) throws -> ProjectWorkspaceRepositoryCheckout { + switch checkout { + case .link: + guard sourceKind.supportsLinkCheckout else { + throw ProjectWorkspaceCreationError.linkCheckoutUnsupported(name) } - try await gitRunner.run( - ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", remoteCloneExistingCheckoutRef(baseRef)], - currentDirectoryURL: nil - ) - ) - return - } - switch (branchName, baseRef) { - case (.some(let branchName), .some(let baseRef)): - try await gitRunner.run( - ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", "-B", branchName, baseRef], - currentDirectoryURL: nil - ) - ) - case (.some(let branchName), .none): - try await gitRunner.run( - ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", "-B", branchName], - currentDirectoryURL: nil - ) - ) - case (.none, .some(let baseRef)): - try await gitRunner.run( - ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", baseRef], - currentDirectoryURL: nil - ) + return .link + case .createBranch(let branchName, let baseRef): + guard let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { + throw ProjectWorkspaceCreationError.missingBranchName(name) + } + return .createBranch( + branchName: trimmedBranch, + baseRef: baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ) - case (.none, .none): - break + case .useExistingRef(let ref): + guard let trimmedRef = ref.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { + throw ProjectWorkspaceCreationError.missingExistingRef(name) + } + return .useExistingRef(trimmedRef) } } - private static func bareWorktreeCommand( - _ repository: ProjectWorkspaceCreationRepository, + private static func worktreeAddCommand( + _ checkout: ProjectWorkspaceRepositoryCheckout, sourcePath: String, destinationPath: String ) -> ProjectWorkspaceGitCommand { - let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty var arguments = ["-C", sourcePath, "worktree", "add"] - if repository.checkoutMode == .useExistingRef { + switch checkout { + case .link: + arguments += [destinationPath] + case .createBranch(let branchName, let baseRef): + arguments += ["-b", branchName, destinationPath] if let baseRef { - arguments += [destinationPath, baseRef] - } else { - arguments += [destinationPath] + arguments += ["--end-of-options", baseRef] } - } else if let branchName, let baseRef { - arguments += ["-b", branchName, destinationPath, baseRef] - } else if let branchName { - arguments += [destinationPath, branchName] - } else if let baseRef { - arguments += [destinationPath, baseRef] - } else { - arguments += [destinationPath] + case .useExistingRef(let ref): + arguments += [destinationPath, "--end-of-options", ref] } return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) } + private static func worktreeRemoveCommand( + sourcePath: String, + destinationPath: String + ) -> ProjectWorkspaceGitCommand { + ProjectWorkspaceGitCommand( + arguments: ["-C", sourcePath, "worktree", "remove", "--force", destinationPath], + currentDirectoryURL: nil + ) + } + + private static func remoteCheckoutCommand( + _ checkout: ProjectWorkspaceRepositoryCheckout, + destinationPath: String + ) -> ProjectWorkspaceGitCommand? { + switch checkout { + case .link: + return nil + case .createBranch(let branchName, let baseRef): + var arguments = ["-C", destinationPath, "checkout", "-B", branchName] + if let baseRef { + arguments += ["--end-of-options", baseRef] + } + return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) + case .useExistingRef(let ref): + return ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", "--end-of-options", remoteCloneExistingCheckoutRef(ref)], + currentDirectoryURL: nil + ) + } + } + private static func remoteCloneExistingCheckoutRef(_ baseRef: String) -> String { let prefix = "origin/" guard baseRef.hasPrefix(prefix), baseRef.count > prefix.count else { @@ -640,7 +746,6 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } private static func localRepositoryPath( - _ repository: ProjectWorkspaceCreationRepository, sourceLocation: String, fileManager: FileManager ) throws -> String { @@ -655,7 +760,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } private static func uniqueRepositoryPath( - for repository: ProjectWorkspaceCreationRepository, + for repository: ProjectWorkspaceRepositoryPlan, displayName: String, occupiedNames: inout Set ) -> String { @@ -677,7 +782,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return candidate } - private static func repositoryDisplayName(_ repository: ProjectWorkspaceCreationRepository) -> String { + private static func repositoryDisplayName(_ repository: ProjectWorkspaceRepositoryPlan) -> String { let trimmedName = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { return trimmedName @@ -688,15 +793,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return Repository.name(for: localSourceURL) } case .remote: - let source = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) - let lastPathComponent = - source - .split(separator: "/") - .last - .map(String.init)? - .replacing(".git", with: "") - if let lastPathComponent, !lastPathComponent.isEmpty { - return lastPathComponent + let name = GitRemoteNaming.repositoryName(fromRemoteURL: repository.sourceLocation) + if !name.isEmpty { + return name } } return "" diff --git a/supacode/Domain/Repository.swift b/supacode/Domain/Repository.swift index c7f0f664..c3215bba 100644 --- a/supacode/Domain/Repository.swift +++ b/supacode/Domain/Repository.swift @@ -58,7 +58,8 @@ nonisolated struct Repository: Identifiable, Hashable, Sendable { self.id = id self.rootURL = rootURL self.name = name - self.kind = kind + // A workspace is always a plain runnable folder; git capabilities stay per-repository. + self.kind = workspace == nil ? kind : .plain self.worktrees = worktrees self.workspace = workspace } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index f3cd9b1e..f46105ee 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -2,10 +2,6 @@ import ComposableArchitecture import Foundation extension RepositoriesFeature.State { - var canCreateWorkspace: Bool { - true - } - var workspaceCreationCandidates: [ProjectWorkspaceCreationRepository] { repositories.compactMap { repository in guard !repository.isWorkspace, !removingRepositoryIDs.contains(repository.id) else { @@ -15,13 +11,20 @@ extension RepositoriesFeature.State { return ProjectWorkspaceCreationRepository( id: repository.id, name: name, - rootURL: repository.rootURL, - branchName: repository.worktrees.first(where: \.isMain)?.name + rootURL: repository.rootURL ) } } } +nonisolated private let workspaceLog = SupaLogger("workspace") + +nonisolated private struct WorkspaceBaseRefsResult: Sendable { + var options: [GitBranchRefOption] = [] + var defaultBaseRef: String? + var errorMessage: String? +} + extension RepositoriesFeature { func reduceWorkspaceCreation( state: inout State, @@ -35,14 +38,20 @@ extension RepositoriesFeature { repositories: [], title: title, rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), - selectedRepositoryIDs: [], openedRepositoryCandidates: candidates ) return .none case .promptCanceled, .promptDismissed: + let wasCreating = state.workspaceCreationPrompt?.isCreating == true state.workspaceCreationPrompt = nil - return .cancel(id: CancelID.workspaceCreation) + guard wasCreating else { + return .cancel(id: CancelID.workspaceCreation) + } + return .merge( + .cancel(id: CancelID.workspaceCreation), + .send(.showToast(.warning("Workspace creation canceled"))) + ) case .refreshBaseRefs(let repositoryID): guard let repository = state.workspaceCreationPrompt?.repositories[id: repositoryID] else { @@ -50,50 +59,37 @@ extension RepositoriesFeature { } return workspaceBaseRefsEffect(for: [repository]) - case .baseRefsLoaded(let repositoryID, let sourceKind, let sourceLocation, let options, let defaultBaseRef): - guard var repository = state.workspaceCreationPrompt?.repositories[id: repositoryID], - repository.sourceKind == sourceKind, - repository.sourceLocation == sourceLocation - else { - return .none - } - let baseRef = Self.trimmedNonEmpty(repository.baseRef) - let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(options) - repository.baseRefOptions = baseRefOptions - if let baseRef, baseRefOptions.contains(where: { $0.ref == baseRef }) { - repository.baseRef = baseRef - } else { - repository.baseRef = defaultBaseRef - } - state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository + case .baseRefsLoaded( + let repositoryID, let sourceKind, let sourceLocation, let options, let defaultBaseRef, let errorMessage + ): + Self.applyLoadedBaseRefs( + into: &state, + repositoryID: repositoryID, + sourceKind: sourceKind, + sourceLocation: sourceLocation, + result: WorkspaceBaseRefsResult( + options: options, + defaultBaseRef: defaultBaseRef, + errorMessage: errorMessage + ) + ) return .none case .createWorkspace(let draft): state.workspaceCreationPrompt?.isCreating = true state.workspaceCreationPrompt?.validationMessage = nil let request = ProjectWorkspaceCreationRequest(draft: draft, createdAt: now) - let shellClient = shellClient - let gitRunner = ProjectWorkspaceGitRunner { command in - do { - _ = try await shellClient.run( - URL(fileURLWithPath: "/usr/bin/env"), - ["git"] + command.arguments, - command.currentDirectoryURL - ) - } catch let error as ShellClientError { - throw ProjectWorkspaceCreationError.gitCommandFailed( - command: command.displayCommand, - message: error.stderr.isEmpty ? error.stdout : error.stderr - ) - } catch { - throw error - } - } + let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) return .run { send in do { _ = try await ProjectWorkspace.create(request, gitRunner: gitRunner) await send(.workspaceCreation(.workspaceCreated(request.draft.rootURL))) } catch { + guard !Task.isCancelled else { + workspaceLog.warning("Workspace creation canceled, rollback finished") + return + } + workspaceLog.warning("Workspace creation failed: \(error.localizedDescription)") await send(.workspaceCreation(.workspaceCreationFailed(error.localizedDescription))) } } @@ -147,6 +143,52 @@ extension RepositoriesFeature { return trimmed.isEmpty ? nil : trimmed } + private static func applyLoadedBaseRefs( + into state: inout State, + repositoryID: Repository.ID, + sourceKind: ProjectWorkspaceRepositorySourceKind, + sourceLocation: String, + result: WorkspaceBaseRefsResult + ) { + guard var repository = state.workspaceCreationPrompt?.repositories[id: repositoryID], + repository.sourceKind == sourceKind, + repository.sourceLocation == sourceLocation + else { + return + } + if let errorMessage = result.errorMessage { + state.workspaceCreationPrompt?.validationMessage = errorMessage + } + let baseRef = trimmedNonEmpty(repository.baseRef) + let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(result.options) + repository.baseRefOptions = baseRefOptions + if let baseRef, baseRefOptions.contains(where: { $0.ref == baseRef }) { + repository.baseRef = baseRef + } else { + repository.baseRef = result.defaultBaseRef + } + state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository + } + + private static func workspaceGitRunner(shellClient: ShellClient) -> ProjectWorkspaceGitRunner { + ProjectWorkspaceGitRunner { command in + do { + _ = try await shellClient.run( + URL(fileURLWithPath: "/usr/bin/env"), + ["git"] + command.arguments, + command.currentDirectoryURL + ) + } catch let error as ShellClientError { + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: command.displayCommand, + message: error.stderr.isEmpty ? error.stdout : error.stderr + ) + } catch { + throw error + } + } + } + private func workspaceBaseRefsEffect(for repositories: [ProjectWorkspaceCreationRepository]) -> Effect { guard !repositories.isEmpty else { return .none @@ -162,7 +204,8 @@ extension RepositoriesFeature { sourceKind: repository.sourceKind, sourceLocation: repository.sourceLocation, options: result.options, - defaultBaseRef: result.defaultBaseRef + defaultBaseRef: result.defaultBaseRef, + errorMessage: result.errorMessage ) ) ) @@ -173,18 +216,14 @@ extension RepositoriesFeature { private static func workspaceBaseRefs( for repository: ProjectWorkspaceCreationRepository, gitClient: GitClientDependency - ) async -> (options: [GitBranchRefOption], defaultBaseRef: String?) { + ) async -> WorkspaceBaseRefsResult { switch repository.sourceKind { case .remote: - return ([], nil) + return WorkspaceBaseRefsResult() case .existingPath, .localRepository, .bareRepository: guard let sourceURL = repository.localSourceURL else { - let options = ProjectWorkspaceCreationRepository.baseRefOptions( - automaticBaseRef: nil, - options: [] - ) - return (options, nil) + return WorkspaceBaseRefsResult() } let repositoryURL: URL @@ -195,7 +234,19 @@ extension RepositoriesFeature { } let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(repositoryURL) - let refs = (try? await gitClient.branchRefOptions(repositoryURL)) ?? [] + let refs: [GitBranchRefOption] + var errorMessage: String? + do { + refs = try await gitClient.branchRefOptions(repositoryURL) + } catch { + refs = [] + let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = name.isEmpty ? repositoryURL.lastPathComponent : name + errorMessage = "Could not read branches for \(displayName): \(error.localizedDescription)" + workspaceLog.warning( + "Branch detection failed for \(repositoryURL.path(percentEncoded: false)): \(error)" + ) + } let options = ProjectWorkspaceCreationRepository.baseRefOptions( automaticBaseRef: automaticBaseRef, options: refs @@ -207,7 +258,11 @@ extension RepositoriesFeature { options: options ) : nil - return (options, defaultBaseRef) + return WorkspaceBaseRefsResult( + options: options, + defaultBaseRef: defaultBaseRef, + errorMessage: errorMessage + ) } } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 300fefa2..1dee6e49 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -224,7 +224,8 @@ struct RepositoriesFeature { sourceKind: ProjectWorkspaceRepositorySourceKind, sourceLocation: String, options: [GitBranchRefOption], - defaultBaseRef: String? + defaultBaseRef: String?, + errorMessage: String? ) case createWorkspace(ProjectWorkspaceCreationDraft) case workspaceCreated(URL) diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 68f55d76..30f7a293 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -10,19 +10,10 @@ struct WorkspaceCreationPromptFeature { var openedRepositoryCandidates: IdentifiedArrayOf var title: String var rootPath: String - var selectedRepositoryIDs: Set var validationMessage: String? var isCreating = false var remoteRepositoryPrompt: RemoteRepositoryPromptState? - var selectedRepositoryCount: Int { - repositories.count - } - - var selectedRepositories: [ProjectWorkspaceCreationRepository] { - Array(repositories) - } - var availableOpenedRepositories: [ProjectWorkspaceCreationRepository] { openedRepositoryCandidates.filter { repositories[id: $0.id] == nil } } @@ -35,7 +26,6 @@ struct WorkspaceCreationPromptFeature { repositories: [ProjectWorkspaceCreationRepository], title: String, rootPath: String, - selectedRepositoryIDs: Set, openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [] ) { self.repositories = IdentifiedArray(repositories, uniquingIDsWith: { current, _ in current }) @@ -45,7 +35,6 @@ struct WorkspaceCreationPromptFeature { ) self.title = title self.rootPath = rootPath - self.selectedRepositoryIDs = selectedRepositoryIDs } } @@ -66,7 +55,6 @@ struct WorkspaceCreationPromptFeature { enum Action: BindableAction, Equatable { case binding(BindingAction) - case addBlankRepository(ProjectWorkspaceRepositorySourceKind) case addOpenedRepository(Repository.ID) case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) case addRemoteButtonTapped @@ -78,7 +66,6 @@ struct WorkspaceCreationPromptFeature { case remoteRepositoryPromptAddButtonTapped case remoteRepositoryPromptDismissed case removeRepository(Repository.ID) - case repositorySelectionChanged(Repository.ID, Bool) case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) case repositoryCheckoutModeChanged(Repository.ID, ProjectWorkspaceRepositoryCheckoutMode) case repositoryNameChanged(Repository.ID, String) @@ -90,8 +77,6 @@ struct WorkspaceCreationPromptFeature { case rootPathChosen(String) case cancelButtonTapped case createButtonTapped - case setCreating(Bool) - case setValidationMessage(String?) case delegate(Delegate) } @@ -113,20 +98,6 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = nil return .none - case .addBlankRepository(let sourceKind): - let id = uuid().uuidString - state.repositories.append( - ProjectWorkspaceCreationRepository( - id: id, - name: "", - sourceKind: sourceKind, - sourceLocation: "" - ) - ) - state.selectedRepositoryIDs.insert(id) - state.validationMessage = nil - return .none - case .addOpenedRepository(let repositoryID): guard let repository = state.openedRepositoryCandidates[id: repositoryID], state.repositories[id: repositoryID] == nil @@ -134,7 +105,6 @@ struct WorkspaceCreationPromptFeature { return .none } state.repositories.append(repository) - state.selectedRepositoryIDs.insert(repositoryID) state.validationMessage = nil return .send(.delegate(.baseRefSourceChanged(repositoryID))) @@ -149,7 +119,7 @@ struct WorkspaceCreationPromptFeature { state.remoteRepositoryPrompt?.branchOptions = [] state.remoteRepositoryPrompt?.defaultBaseRef = nil if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { - state.remoteRepositoryPrompt?.name = Self.remoteRepositoryName(for: url) + state.remoteRepositoryPrompt?.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) } return .none @@ -170,7 +140,7 @@ struct WorkspaceCreationPromptFeature { } prompt.url = url if prompt.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - prompt.name = Self.remoteRepositoryName(for: url) + prompt.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) } prompt.isLoading = true prompt.validationMessage = nil @@ -232,7 +202,6 @@ struct WorkspaceCreationPromptFeature { baseRefOptions: prompt.branchOptions ) ) - state.selectedRepositoryIDs.insert(id) state.remoteRepositoryPrompt = nil state.validationMessage = nil return .none @@ -257,22 +226,11 @@ struct WorkspaceCreationPromptFeature { sourceLocation: rootPath ) ) - state.selectedRepositoryIDs.insert(id) state.validationMessage = nil return .send(.delegate(.baseRefSourceChanged(id))) case .removeRepository(let repositoryID): state.repositories.remove(id: repositoryID) - state.selectedRepositoryIDs.remove(repositoryID) - state.validationMessage = nil - return .none - - case .repositorySelectionChanged(let repositoryID, let isSelected): - if isSelected { - state.selectedRepositoryIDs.insert(repositoryID) - } else { - state.selectedRepositoryIDs.remove(repositoryID) - } state.validationMessage = nil return .none @@ -280,6 +238,9 @@ struct WorkspaceCreationPromptFeature { guard var repository = state.repositories[id: repositoryID] else { return .none } + guard checkoutMode != .link || repository.sourceKind.supportsLinkCheckout else { + return .none + } repository.checkoutMode = checkoutMode state.repositories[id: repositoryID] = repository state.validationMessage = nil @@ -292,6 +253,9 @@ struct WorkspaceCreationPromptFeature { repository.sourceKind = sourceKind repository.baseRef = nil repository.baseRefOptions = [] + if repository.checkoutMode == .link, !sourceKind.supportsLinkCheckout { + repository.checkoutMode = sourceKind.defaultCheckoutMode + } if sourceKind == .remote { repository.sourceLocation = "" } @@ -384,33 +348,17 @@ struct WorkspaceCreationPromptFeature { state.validationMessage = ProjectWorkspaceCreationError.missingPath.localizedDescription return .none } - let repositories = state.selectedRepositories - guard repositories.count >= 2 else { + guard state.repositories.count >= 2 else { state.validationMessage = ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription return .none } - for repository in repositories { - let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) - let sourceLocation = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sourceLocation.isEmpty else { - let displayName = name.isEmpty ? "repository" : name - state.validationMessage = - ProjectWorkspaceCreationError.missingRepositorySource(displayName).localizedDescription - return .none - } - if repository.checkoutMode == .createBranch, - repository.sourceKind == .remote || repository.sourceKind == .bareRepository, - repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false - { - let displayName = name.isEmpty ? "repository" : name - state.validationMessage = "Branch name required for \(displayName)." - return .none - } - if repository.checkoutMode == .useExistingRef, - repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false - { - let displayName = name.isEmpty ? "repository" : name - state.validationMessage = "Choose an existing branch for \(displayName)." + var plans: [ProjectWorkspaceRepositoryPlan] = [] + for repository in state.repositories { + switch Self.plan(for: repository) { + case .success(let plan): + plans.append(plan) + case .failure(let error): + state.validationMessage = error.localizedDescription return .none } } @@ -421,36 +369,62 @@ struct WorkspaceCreationPromptFeature { ProjectWorkspaceCreationDraft( title: title, rootURL: URL(filePath: rootPath, directoryHint: .isDirectory), - repositories: repositories + repositories: plans ) ) ) ) - case .setCreating(let isCreating): - state.isCreating = isCreating - return .none - - case .setValidationMessage(let message): - state.validationMessage = message - return .none - case .delegate: return .none } } } - private static func remoteRepositoryName(for remoteURL: String) -> String { - let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !trimmed.isEmpty else { - return "" + static func plan( + for repository: ProjectWorkspaceCreationRepository + ) -> Result { + let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = name.isEmpty ? "repository" : name + let sourceLocation = repository.sourceLocation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceLocation.isEmpty else { + return .failure(.missingRepositorySource(displayName)) + } + let checkout: ProjectWorkspaceRepositoryCheckout + switch repository.checkoutMode { + case .link: + guard repository.sourceKind.supportsLinkCheckout else { + return .failure(.linkCheckoutUnsupported(displayName)) + } + checkout = .link + case .createBranch: + guard let branchName = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines), + !branchName.isEmpty + else { + return .failure(.missingBranchName(displayName)) + } + let trimmedBase = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines) + checkout = .createBranch( + branchName: branchName, + baseRef: trimmedBase?.isEmpty == false ? trimmedBase : nil + ) + case .useExistingRef: + guard let baseRef = repository.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines), + !baseRef.isEmpty + else { + return .failure(.missingExistingRef(displayName)) + } + checkout = .useExistingRef(baseRef) } - let separatorIndex = trimmed.lastIndex { $0 == "/" || $0 == ":" } - let component = - separatorIndex.map { String(trimmed[trimmed.index(after: $0)...]) } - ?? trimmed - return component.hasSuffix(".git") ? String(component.dropLast(4)) : component + return .success( + ProjectWorkspaceRepositoryPlan( + id: repository.id, + name: repository.name, + path: repository.path, + sourceKind: repository.sourceKind, + sourceLocation: sourceLocation, + checkout: checkout + ) + ) } } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index f4d742e7..3980d7ca 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -201,7 +201,6 @@ struct SidebarListView: View { Label("New Workspace", systemImage: "folder.badge.person.crop") } .help("New Workspace") - .disabled(!state.canCreateWorkspace) Button { store.send(.setOpenPanelPresented(true)) } label: { diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 79884e79..109aadfb 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -133,8 +133,7 @@ struct WorkspaceCreationPromptView: View { store.send(.cancelButtonTapped) } .keyboardShortcut(.cancelAction) - .help("Cancel (Esc)") - .disabled(store.isCreating) + .help(store.isCreating ? "Cancel creation and roll back (Esc)" : "Cancel (Esc)") Button("Create") { store.send(.createButtonTapped) } @@ -190,7 +189,7 @@ struct WorkspaceCreationPromptView: View { set: { store.send(.repositorySourceKindChanged(repository.id, $0)) } ) ) { - ForEach(sourceKinds, id: \.self) { kind in + ForEach(sourceKinds(for: repository), id: \.self) { kind in Text(sourceKindTitle(kind)).tag(kind) } } @@ -272,6 +271,9 @@ struct WorkspaceCreationPromptView: View { set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } ) ) { + if repository.sourceKind.supportsLinkCheckout { + Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + } Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) } @@ -293,18 +295,32 @@ struct WorkspaceCreationPromptView: View { .disabled(store.isCreating) } - WorkspaceBranchRefPickerView( - title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", - selection: repository.baseRef, - options: repository.baseRefOptions, - isDisabled: store.isCreating || repository.baseRefOptions.isEmpty - ) { ref in - store.send(.repositoryBaseRefChanged(repository.id, ref)) + if repository.checkoutMode == .link { + Text("Symlink to the repository as it is on disk") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + WorkspaceBranchRefPickerView( + title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", + selection: repository.baseRef, + options: repository.baseRefOptions, + isDisabled: store.isCreating || repository.baseRefOptions.isEmpty + ) { ref in + store.send(.repositoryBaseRefChanged(repository.id, ref)) + } + .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } - .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } } + private func sourceKinds( + for repository: ProjectWorkspaceCreationRepository + ) -> [ProjectWorkspaceRepositorySourceKind] { + repository.sourceKind == .remote + ? sourceKinds + : sourceKinds.filter { $0 != .remote } + } + @ViewBuilder private func remoteRepositoryPromptView() -> some View { if let prompt = store.remoteRepositoryPrompt { diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 90122d7a..6e0f50f0 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -384,8 +384,7 @@ struct AppFeatureCommandPaletteTests { $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: expectedRootPath, - selectedRepositoryIDs: [] + rootPath: expectedRootPath ) } } diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index d363050e..308159b1 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -119,6 +119,7 @@ struct GitClientBranchRefsTests { "git", "ls-remote", "--symref", + "--end-of-options", "git@github.com:onevcat/app.git", "HEAD", "refs/heads/*", diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index cb900958..cbc627df 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -23,6 +23,24 @@ struct ProjectWorkspaceTests { ]) } + @Test func preferredBaseRefMatchesTrimmedAutomaticBaseRef() { + let preferred = ProjectWorkspaceCreationRepository.preferredBaseRef( + automaticBaseRef: " develop \n", + options: [ + GitBranchRefOption(ref: "develop", kind: .local), + GitBranchRefOption(ref: "main", kind: .local), + ] + ) + + #expect(preferred == "develop") + } + + @Test func remoteNamingStripsOnlyTrailingGitSuffix() { + #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") == "x.github.io") + #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") + #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") + } + @Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { let rootURL = try makeTemporaryWorkspaceRoot() defer { try? FileManager.default.removeItem(at: rootURL) } @@ -92,6 +110,14 @@ struct ProjectWorkspaceTests { #expect(shared.sourceKind == .existingPath) } + @Test func loadReturnsNilForMalformedWorkspaceMetadata() throws { + let rootURL = try makeTemporaryWorkspaceRoot() + defer { try? FileManager.default.removeItem(at: rootURL) } + try writeWorkspaceJSON("{ not json", to: rootURL) + + #expect(ProjectWorkspace.load(from: rootURL) == nil) + } + @Test func normalizesEmptyWorkspaceAndRepositoryFields() throws { let rootURL = URL(fileURLWithPath: "/tmp/prowl-workspace") @@ -161,17 +187,21 @@ struct ProjectWorkspaceTests { title: "Checkout Flow", rootURL: rootURL, repositories: [ - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "app", name: "App Repo", - rootURL: appURL, - branchName: "main" + path: nil, + sourceKind: .existingPath, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .link ), - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "api", name: "App Repo", - rootURL: apiURL, - branchName: "feature/api" + path: nil, + sourceKind: .existingPath, + sourceLocation: apiURL.path(percentEncoded: false), + checkout: .link ), ] ), @@ -194,7 +224,7 @@ struct ProjectWorkspaceTests { appPath, apiPath, ]) - #expect(loaded.repositories.map(\.branchName) == ["main", "feature/api"]) + #expect(loaded.repositories.map(\.branchName) == [nil, nil]) let appLinkPath = rootURL.appending(path: "App-Repo").path(percentEncoded: false) let apiLinkPath = rootURL.appending(path: "App-Repo-2").path(percentEncoded: false) @@ -218,23 +248,21 @@ struct ProjectWorkspaceTests { title: "Materialized", rootURL: rootURL, repositories: [ - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "app", name: "App", + path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", - branchName: "codex/app", - baseRef: "origin/main", - path: "app" + checkout: .createBranch(branchName: "codex/app", baseRef: "origin/main") ), - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "api", name: "API", + path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), - branchName: "codex/api", - baseRef: "main", - path: "api" + checkout: .createBranch(branchName: "codex/api", baseRef: "main") ), ] ), @@ -251,9 +279,9 @@ struct ProjectWorkspaceTests { let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments) == [ - ["clone", "git@github.com:onevcat/app.git", "\(rootPath)/app"], - ["-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "origin/main"], - ["-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "main"], + ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], + ["-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main"], + ["-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "--end-of-options", "main"], ]) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) @@ -278,25 +306,21 @@ struct ProjectWorkspaceTests { title: "Existing Refs", rootURL: rootURL, repositories: [ - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "app", name: "App", + path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", - checkoutMode: .useExistingRef, - branchName: "codex/app", - baseRef: "origin/feature/login", - path: "app" + checkout: .useExistingRef("origin/feature/login") ), - ProjectWorkspaceCreationRepository( + ProjectWorkspaceRepositoryPlan( id: "api", name: "API", + path: "api", sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), - checkoutMode: .useExistingRef, - branchName: "codex/api", - baseRef: "main", - path: "api" + checkout: .useExistingRef("main") ), ] ), @@ -311,9 +335,9 @@ struct ProjectWorkspaceTests { let barePath = normalizedTestPath(bareURL) #expect( commands.value.map(\.arguments) == [ - ["clone", "git@github.com:onevcat/app.git", "\(rootPath)/app"], - ["-C", "\(rootPath)/app", "checkout", "feature/login"], - ["-C", barePath, "worktree", "add", "\(rootPath)/api", "main"], + ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], + ["-C", "\(rootPath)/app", "checkout", "--end-of-options", "feature/login"], + ["-C", barePath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "main"], ]) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) @@ -321,6 +345,249 @@ struct ProjectWorkspaceTests { #expect(loaded.repositories.map(\.baseRef) == ["origin/feature/login", "main"]) } + @Test func createWorkspaceMaterializesLocalRepositoriesAsWorktrees() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-local-worktree-workspace-\(UUID().uuidString)") + .standardizedFileURL + let appURL = try makeTemporaryWorkspaceRoot() + let apiURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + try? FileManager.default.removeItem(at: apiURL) + } + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let workspace = try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Local Worktrees", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .localRepository, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .createBranch(branchName: "codex/app", baseRef: "main") + ), + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .existingPath, + sourceLocation: apiURL.path(percentEncoded: false), + checkout: .useExistingRef("origin/main") + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 4_567_890) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + let rootPath = rootURL.path(percentEncoded: false) + let appPath = normalizedTestPath(appURL) + let apiPath = normalizedTestPath(apiURL) + #expect( + commands.value.map(\.arguments) == [ + ["-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", "--end-of-options", "main"], + ["-C", apiPath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "origin/main"], + ]) + #expect(workspace.repositories.map(\.branchName) == ["codex/app", nil]) + #expect(workspace.repositories.map(\.baseRef) == ["main", "origin/main"]) + } + + @Test func createWorkspaceRollsBackCloneWhenCheckoutFails() async throws { + let rootURL = try makeTemporaryWorkspaceRoot() + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let cloneDestination = rootURL.appending(path: "app", directoryHint: .isDirectory) + await #expect(throws: ProjectWorkspaceCreationError.self) { + try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Half Done", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + checkout: .createBranch(branchName: "codex/api", baseRef: "main") + ), + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkout: .createBranch(branchName: "codex/app", baseRef: "origin/main") + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 5_678_901) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + if command.arguments.first == "clone" { + try FileManager.default.createDirectory(at: cloneDestination, withIntermediateDirectories: true) + } + if command.arguments.contains("checkout") { + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: command.displayCommand, + message: "boom" + ) + } + } + ) + } + + let rootPath = rootURL.path(percentEncoded: false) + let barePath = normalizedTestPath(bareURL) + #expect( + commands.value.map(\.arguments).contains( + ["-C", barePath, "worktree", "remove", "--force", "\(rootPath)/api"] + ) + ) + #expect(!FileManager.default.fileExists(atPath: cloneDestination.path(percentEncoded: false))) + #expect( + !FileManager.default.fileExists( + atPath: ProjectWorkspace.metadataURL(for: rootURL).path(percentEncoded: false) + ) + ) + #expect(FileManager.default.fileExists(atPath: rootPath)) + } + + @Test func createWorkspaceRejectsCreateBranchWithoutName() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-invalid-workspace-\(UUID().uuidString)") + let appURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + } + await #expect(throws: ProjectWorkspaceCreationError.missingBranchName("App")) { + try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Invalid", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .localRepository, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .createBranch(branchName: " ", baseRef: nil) + ), + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .existingPath, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .link + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 6_789_012) + ), + gitRunner: ProjectWorkspaceGitRunner { _ in } + ) + } + #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + } + + @Test func createWorkspaceRejectsLinkForBareRepository() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-invalid-link-workspace-\(UUID().uuidString)") + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + await #expect(throws: ProjectWorkspaceCreationError.linkCheckoutUnsupported("API")) { + try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Invalid Link", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + checkout: .link + ), + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: bareURL.path(percentEncoded: false), + checkout: .link + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 7_890_123) + ), + gitRunner: ProjectWorkspaceGitRunner { _ in } + ) + } + } + + @Test func planRequiresBranchNameForCreateBranchOnLocalSources() { + let repository = ProjectWorkspaceCreationRepository( + id: "app", + name: "App", + rootURL: URL(fileURLWithPath: "/tmp/app"), + checkoutMode: .createBranch + ) + + #expect( + WorkspaceCreationPromptFeature.plan(for: repository) + == .failure(.missingBranchName("App")) + ) + } + + @Test func planMapsLinkAndExistingRefModes() throws { + let linked = ProjectWorkspaceCreationRepository( + id: "app", + name: "App", + rootURL: URL(fileURLWithPath: "/tmp/app") + ) + #expect(WorkspaceCreationPromptFeature.plan(for: linked).map(\.checkout) == .success(.link)) + + var existing = ProjectWorkspaceCreationRepository( + id: "api", + name: "API", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git", + checkoutMode: .useExistingRef + ) + #expect( + WorkspaceCreationPromptFeature.plan(for: existing) + == .failure(.missingExistingRef("API")) + ) + existing.baseRef = "main" + #expect( + WorkspaceCreationPromptFeature.plan(for: existing).map(\.checkout) + == .success(.useExistingRef("main")) + ) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( @@ -341,6 +608,21 @@ struct ProjectWorkspaceTests { #expect(contexts.first?.id == repository.id) } + @Test func repositoryWithWorkspaceIsAlwaysPlain() { + let rootURL = URL(fileURLWithPath: "/tmp/workspace") + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "Workspace", + kind: .git, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace") + ) + + #expect(repository.kind == .plain) + #expect(repository.isWorkspace) + } + private func makeTemporaryWorkspaceRoot() throws -> URL { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-workspace-\(UUID().uuidString)") diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 5313fb48..baac8b2b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -535,9 +535,10 @@ struct RepositoriesFeatureTests { .write(to: ProjectWorkspace.metadataURL(for: rootURL)) let rootPath = rootURL.path(percentEncoded: false) - let workspace = try #require(ProjectWorkspace.load(from: rootURL)) + let loadedRootURL = URL(fileURLWithPath: rootPath).standardizedFileURL + let workspace = try #require(ProjectWorkspace.load(from: loadedRootURL)) let repository = makeRepository( - id: rootPath, + id: loadedRootURL.path(percentEncoded: false), name: "Multi Repo Workspace", kind: .plain, worktrees: [], @@ -564,7 +565,7 @@ struct RepositoriesFeatureTests { await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repository] - $0.repositoryRoots = [rootURL] + $0.repositoryRoots = [URL(fileURLWithPath: rootPath)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } @@ -588,7 +589,6 @@ struct RepositoriesFeatureTests { repositories: [], title: title, rootPath: expectedRootPath, - selectedRepositoryIDs: [], openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: "/tmp/repo-a", @@ -612,7 +612,6 @@ struct RepositoriesFeatureTests { repositories: [], title: "Workspace", rootPath: "/tmp/workspace", - selectedRepositoryIDs: [], openedRepositoryCandidates: [candidate] ) ) { @@ -621,7 +620,6 @@ struct RepositoriesFeatureTests { await store.send(.addOpenedRepository(repoRootA)) { $0.repositories = [candidate] - $0.selectedRepositoryIDs = [repoRootA] } await store.receive(.delegate(.baseRefSourceChanged(repoRootA))) } @@ -638,7 +636,6 @@ struct RepositoriesFeatureTests { repositories: [candidate], title: "Workspace", rootPath: "/tmp/workspace", - selectedRepositoryIDs: [repoRootA], openedRepositoryCandidates: [candidate] ) ) { @@ -671,13 +668,11 @@ struct RepositoriesFeatureTests { repositories: [], title: title, rootPath: expectedRootPath, - selectedRepositoryIDs: [], openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - branchName: "main" + rootURL: URL(fileURLWithPath: repoRootA) ), ProjectWorkspaceCreationRepository( id: repoRootB, @@ -728,13 +723,11 @@ struct RepositoriesFeatureTests { repositories: [], title: title, rootPath: expectedRootPath, - selectedRepositoryIDs: [], openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - branchName: "main" + rootURL: URL(fileURLWithPath: repoRootA) ), ProjectWorkspaceCreationRepository( id: repoRootB, @@ -749,12 +742,11 @@ struct RepositoriesFeatureTests { ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - branchName: "main" + rootURL: URL(fileURLWithPath: repoRootA) ) ) - $0.workspaceCreationPrompt?.selectedRepositoryIDs = [repoRootA] } + await store.receive(\.workspaceCreationPrompt.presented.delegate.baseRefSourceChanged) await store.receive(\.workspaceCreation.refreshBaseRefs) await store.receive(\.workspaceCreation.baseRefsLoaded) { $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" @@ -772,8 +764,7 @@ struct RepositoriesFeatureTests { ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - branchName: "main" + rootURL: URL(fileURLWithPath: repoRootA) ), ProjectWorkspaceCreationRepository( id: repoRootB, @@ -781,13 +772,11 @@ struct RepositoriesFeatureTests { rootURL: URL(fileURLWithPath: repoRootB) ), ] - let rootURL = URL(fileURLWithPath: "/tmp/multi-repo-workspace") let store = TestStore( initialState: WorkspaceCreationPromptFeature.State( repositories: repositories, title: " Multi Repo ", - rootPath: " /tmp/multi-repo-workspace ", - selectedRepositoryIDs: [repoRootA, repoRootB] + rootPath: " /tmp/multi-repo-workspace " ) ) { WorkspaceCreationPromptFeature() @@ -799,8 +788,25 @@ struct RepositoriesFeatureTests { .submit( ProjectWorkspaceCreationDraft( title: "Multi Repo", - rootURL: rootURL, - repositories: repositories + rootURL: URL(filePath: "/tmp/multi-repo-workspace", directoryHint: .isDirectory), + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: repoRootA, + name: "Repo A", + path: nil, + sourceKind: .existingPath, + sourceLocation: repoRootA, + checkout: .link + ), + ProjectWorkspaceRepositoryPlan( + id: repoRootB, + name: "Repo B", + path: nil, + sourceKind: .existingPath, + sourceLocation: repoRootB, + checkout: .link + ), + ] ) ) ) @@ -816,8 +822,7 @@ struct RepositoriesFeatureTests { initialState: WorkspaceCreationPromptFeature.State( repositories: [], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -860,7 +865,6 @@ struct RepositoriesFeatureTests { baseRefOptions: options ) ) - $0.selectedRepositoryIDs = [UUID(0).uuidString] $0.remoteRepositoryPrompt = nil } } @@ -870,8 +874,7 @@ struct RepositoriesFeatureTests { initialState: WorkspaceCreationPromptFeature.State( repositories: [], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -904,8 +907,7 @@ struct RepositoriesFeatureTests { initialState: WorkspaceCreationPromptFeature.State( repositories: [], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -922,7 +924,6 @@ struct RepositoriesFeatureTests { sourceLocation: "/tmp/local-api" ) ) - $0.selectedRepositoryIDs = [UUID(0).uuidString] } await store.receive(.delegate(.baseRefSourceChanged(UUID(0).uuidString))) } @@ -943,8 +944,7 @@ struct RepositoriesFeatureTests { ) ], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [repositoryID] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -974,8 +974,7 @@ struct RepositoriesFeatureTests { ) ], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [repositoryID] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -1008,8 +1007,7 @@ struct RepositoriesFeatureTests { ), ], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [repoRootA, repoRootB] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -1041,8 +1039,7 @@ struct RepositoriesFeatureTests { ), ], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: [repoRootA, repoRootB] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -1067,6 +1064,7 @@ struct RepositoriesFeatureTests { name: "Remote", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", + checkoutMode: .createBranch, baseRef: "origin/main", baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)] ), @@ -1077,8 +1075,7 @@ struct RepositoriesFeatureTests { ), ], title: "Workspace", - rootPath: "/tmp/workspace", - selectedRepositoryIDs: ["remote", repoRootB] + rootPath: "/tmp/workspace" ) ) { WorkspaceCreationPromptFeature() @@ -1093,6 +1090,84 @@ struct RepositoriesFeatureTests { } } + @Test func workspaceCreationPromptResetsLinkModeWhenSwitchingToBare() async { + let repositoryID = "repo" + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repositoryID, + name: "Repo", + rootURL: URL(fileURLWithPath: "/tmp/repo") + ) + ], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.repositorySourceKindChanged(repositoryID, .bareRepository)) { + $0.repositories[id: repositoryID]?.sourceKind = .bareRepository + $0.repositories[id: repositoryID]?.checkoutMode = .createBranch + } + await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) + } + + @Test func workspaceCreationCancelWhileCreatingShowsToast() async { + var initialState = RepositoriesFeature.State() + initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + initialState.workspaceCreationPrompt?.isCreating = true + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + store.exhaustivity = .off + + await store.send(.workspaceCreation(.promptCanceled)) { + $0.workspaceCreationPrompt = nil + } + await store.receive(\.showToast, .warning("Workspace creation canceled")) + } + + @Test func workspaceBaseRefsLoadErrorSurfacesValidationMessage() async { + let repositoryID = "/tmp/repo" + var initialState = RepositoriesFeature.State() + initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [ + ProjectWorkspaceCreationRepository( + id: repositoryID, + name: "Repo", + rootURL: URL(fileURLWithPath: repositoryID) + ) + ], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send( + .workspaceCreation( + .baseRefsLoaded( + repositoryID: repositoryID, + sourceKind: .existingPath, + sourceLocation: repositoryID, + options: [], + defaultBaseRef: nil, + errorMessage: "Could not read branches for Repo: boom" + ) + ) + ) { + $0.workspaceCreationPrompt?.validationMessage = "Could not read branches for Repo: boom" + } + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) -- 2.51.2 From 159459a51ce8cd0489e2ae5590ae2dd74e13cd3d Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 18:52:26 +0800 Subject: [PATCH 449/528] Fold sidebar workspace button into Add Repository menu Two separate toolbar buttons overflowed into the window-trailing ">>" menu when the sidebar got narrow. A single Menu with Add Repository as the primary action keeps one slot; New Workspace lives in the attached menu. --- docs/components/repositories-and-worktrees.md | 3 ++- docs/components/workspaces.md | 5 +++-- .../Repositories/Views/SidebarListView.swift | 22 ++++++++++--------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index b09e834e..a6a236f9 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -33,7 +33,8 @@ root; "not a git repository" → plain folder). - **Shortcut:** `⌘⇧O` (`open_repository`) - **Toolbar:** the **Add Repository** button (folder-with-plus icon) at the top of - the sidebar. + the sidebar. Clicking runs Add Repository directly; its attached menu holds + **New Workspace**. - **Command Palette:** "Open Repository". Pick one or more directories. Prowl detects git vs plain, de-duplicates, and diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index ee7e421f..4b35fb0c 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -18,8 +18,9 @@ When you open a workspace in Prowl: ## Folder layout -Use **New Workspace** from the sidebar toolbar, Worktrees menu, or command -palette to create a workspace. Prowl creates the shared folder, materializes the +Use **New Workspace** from the sidebar toolbar (the menu attached to the **Add +Repository** button), the Worktrees menu, or the command palette to create a +workspace. Prowl creates the shared folder, materializes the selected repositories, writes `.prowl/workspace.json`, and opens the workspace as a runnable folder. A workspace needs at least two repositories. diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 3980d7ca..3eaed7ca 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -194,19 +194,21 @@ struct SidebarListView: View { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { - ToolbarItemGroup(placement: .automatic) { - Button { - store.send(.workspaceCreation(.promptRequested)) - } label: { - Label("New Workspace", systemImage: "folder.badge.person.crop") - } - .help("New Workspace") - Button { - store.send(.setOpenPanelPresented(true)) + ToolbarItem(placement: .automatic) { + // Single toolbar slot: two separate buttons overflow into the + // window-trailing ">>" menu when the sidebar gets narrow. + Menu { + Button { + store.send(.workspaceCreation(.promptRequested)) + } label: { + Label("New Workspace", systemImage: "folder.badge.person.crop") + } } label: { Label("Add Repository", systemImage: "folder.badge.plus") + } primaryAction: { + store.send(.setOpenPanelPresented(true)) } - .help("Add Repository") + .help("Add Repository — open the menu for New Workspace") } } } // ScrollViewReader -- 2.51.2 From b0eb2b88598f683d492315f302adf624757ab9d2 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 19:22:01 +0800 Subject: [PATCH 450/528] Track remote refs as local branches in workspaces Use Existing with a remote-tracking ref produced a detached worktree for bare and local sources; it now runs git worktree add --track -B so the workspace lands on a local tracking branch, matching the remote clone behavior. Git still refuses refs whose branch is checked out in another worktree. Also strip the .git suffix from materialized folder names and default row names, and make default-workspace-path test expectations follow the same uniquing as production. --- docs/components/workspaces.md | 15 ++-- supacode/Domain/ProjectWorkspace.swift | 33 ++++++- .../WorkspaceCreationPromptFeature.swift | 25 +++++- .../AppFeatureCommandPaletteTests.swift | 5 +- supacodeTests/ProjectWorkspaceTests.swift | 88 +++++++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 29 +++--- 6 files changed, 167 insertions(+), 28 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 4b35fb0c..a98f80e9 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -60,8 +60,9 @@ folder is materialized: the workspace gets an isolated checkout on a new branch created from the selected base ref, without touching the source repository's own checkout. The new worktree also appears in the source repository's worktree list. -- **Use Existing** runs `git worktree add` with the selected ref. Git rejects a - local branch that is already checked out elsewhere. +- **Use Existing** runs `git worktree add` with the selected ref. Choosing a + remote-tracking ref creates a local tracking branch instead of a detached + worktree. Git rejects a branch that is already checked out elsewhere. The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default @@ -78,10 +79,12 @@ Branch behavior is explicit: every source kind. - **Use Existing** uses the selected ref directly. For remote clones, Prowl checks out the selected remote branch after clone; Git creates the normal - local tracking branch for refs such as `origin/feature`. For bare - repositories, Prowl passes the selected ref to `git worktree add`, so local - branch refs produce branch worktrees and remote-tracking refs produce detached - worktrees. + local tracking branch for refs such as `origin/feature`. For bare and local + repositories, a local branch ref produces a branch worktree, and a + remote-tracking ref such as `origin/feature` runs + `git worktree add --track -B feature`, which creates the local tracking + branch — aligning a same-named local branch to the remote when one exists. + Git refuses if that branch is already checked out in another worktree. ## Metadata diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index e4c6b57c..4bb3d609 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -50,6 +50,7 @@ nonisolated enum ProjectWorkspaceRepositoryCheckout: Equatable, Hashable, Sendab case link case createBranch(branchName: String, baseRef: String?) case useExistingRef(String) + case trackRemoteRef(remoteRef: String, branchName: String) } nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { @@ -598,7 +599,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) ledger.createdURLs.append(destinationURL) - case .createBranch, .useExistingRef: + case .createBranch, .useExistingRef, .trackRemoteRef: try await gitRunner.run( worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) @@ -646,6 +647,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { case .useExistingRef(let ref): entryBranchName = nil entryBaseRef = ref + case .trackRemoteRef(let remoteRef, let branchName): + entryBranchName = branchName + entryBaseRef = remoteRef } return RepositoryEntry( @@ -683,6 +687,13 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { throw ProjectWorkspaceCreationError.missingExistingRef(name) } return .useExistingRef(trimmedRef) + case .trackRemoteRef(let remoteRef, let branchName): + guard let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + else { + throw ProjectWorkspaceCreationError.missingExistingRef(name) + } + return .trackRemoteRef(remoteRef: trimmedRemoteRef, branchName: trimmedBranch) } } @@ -702,6 +713,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } case .useExistingRef(let ref): arguments += [destinationPath, "--end-of-options", ref] + case .trackRemoteRef(let remoteRef, let branchName): + // -B aligns a same-named local branch to the remote ref; git still refuses + // when that branch is checked out in another worktree. + arguments += ["--track", "-B", branchName, destinationPath, "--end-of-options", remoteRef] } return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) } @@ -734,7 +749,19 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { arguments: ["-C", destinationPath, "checkout", "--end-of-options", remoteCloneExistingCheckoutRef(ref)], currentDirectoryURL: nil ) + case .trackRemoteRef(_, let branchName): + return ProjectWorkspaceGitCommand( + arguments: ["-C", destinationPath, "checkout", "--end-of-options", branchName], + currentDirectoryURL: nil + ) + } + } + + private static func withoutGitSuffix(_ name: String) -> String { + guard name.count > 4, name.hasSuffix(".git") else { + return name } + return String(name.dropLast(4)) } private static func remoteCloneExistingCheckoutRef(_ baseRef: String) -> String { @@ -764,9 +791,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { displayName: String, occupiedNames: inout Set ) -> String { - var baseName = sanitizedWorkspaceComponent(repository.path ?? "") + var baseName = withoutGitSuffix(sanitizedWorkspaceComponent(repository.path ?? "")) if baseName.isEmpty { - baseName = sanitizedWorkspaceComponent(displayName) + baseName = withoutGitSuffix(sanitizedWorkspaceComponent(displayName)) } if baseName.isEmpty { baseName = "repository" diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 30f7a293..82b7277a 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -221,7 +221,7 @@ struct WorkspaceCreationPromptFeature { state.repositories.append( ProjectWorkspaceCreationRepository( id: id, - name: Repository.name(for: url), + name: Self.defaultRepositoryName(for: url), sourceKind: sourceKind, sourceLocation: rootPath ) @@ -287,7 +287,7 @@ struct WorkspaceCreationPromptFeature { } repository.sourceLocation = rootPath if repository.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - repository.name = Repository.name(for: URL(fileURLWithPath: rootPath)) + repository.name = Self.defaultRepositoryName(for: URL(fileURLWithPath: rootPath)) } repository.baseRef = nil repository.baseRefOptions = [] @@ -381,6 +381,14 @@ struct WorkspaceCreationPromptFeature { } } + static func defaultRepositoryName(for url: URL) -> String { + let name = Repository.name(for: url) + guard name.count > 4, name.hasSuffix(".git") else { + return name + } + return String(name.dropLast(4)) + } + static func plan( for repository: ProjectWorkspaceCreationRepository ) -> Result { @@ -414,7 +422,18 @@ struct WorkspaceCreationPromptFeature { else { return .failure(.missingExistingRef(displayName)) } - checkout = .useExistingRef(baseRef) + let kind = repository.baseRefOptions.first { $0.ref == baseRef }?.kind ?? .local + if kind == .local { + checkout = .useExistingRef(baseRef) + } else { + // Remote refs are named /; materialize them as a local + // tracking branch instead of a detached worktree. + let branchName = baseRef.split(separator: "/").dropFirst().joined(separator: "/") + guard !branchName.isEmpty else { + return .failure(.missingExistingRef(displayName)) + } + checkout = .trackRemoteRef(remoteRef: baseRef, branchName: branchName) + } } return .success( ProjectWorkspaceRepositoryPlan( diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 6e0f50f0..85121f31 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -377,10 +377,7 @@ struct AppFeatureCommandPaletteTests { await store.send(.commandPalette(.delegate(.newWorkspace))) await store.receive(\.repositories.workspaceCreation.promptRequested) { let title = "Workspace" - let expectedRootPath = SupacodePaths.workspacesDirectory - .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) + let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index cbc627df..8e261544 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -345,6 +345,94 @@ struct ProjectWorkspaceTests { #expect(loaded.repositories.map(\.baseRef) == ["origin/feature/login", "main"]) } + @Test func createWorkspaceTracksRemoteRefsAsLocalBranches() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-tracked-ref-workspace-\(UUID().uuidString)") + .standardizedFileURL + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let workspace = try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Tracked Refs", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + ), + ProjectWorkspaceRepositoryPlan( + id: "maker", + name: "maker.git", + path: nil, + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + ), + ] + ), + createdAt: Date(timeIntervalSince1970: 8_901_234) + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + let rootPath = rootURL.path(percentEncoded: false) + let barePath = normalizedTestPath(bareURL) + #expect( + commands.value.map(\.arguments) == [ + ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], + ["-C", "\(rootPath)/app", "checkout", "--end-of-options", "chore/disable-spine"], + [ + "-C", barePath, "worktree", "add", "--track", "-B", "chore/disable-spine", + "\(rootPath)/maker", "--end-of-options", "origin/chore/disable-spine", + ], + ]) + #expect(workspace.repositories.map(\.path) == ["app", "maker"]) + #expect(workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) + #expect( + workspace.repositories.map(\.baseRef) + == ["origin/chore/disable-spine", "origin/chore/disable-spine"] + ) + } + + @Test func planMapsRemoteTrackingRefToTrackingBranch() { + let repository = ProjectWorkspaceCreationRepository( + id: "maker", + name: "maker.git", + sourceKind: .bareRepository, + sourceLocation: "/tmp/maker.git", + checkoutMode: .useExistingRef, + baseRef: "origin/chore/x", + baseRefOptions: [GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking)] + ) + + #expect( + WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) + ) + } + + @Test func defaultRepositoryNameStripsGitSuffix() { + #expect( + WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker.git")) + == "maker" + ) + #expect( + WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker")) + == "maker" + ) + } + @Test func createWorkspaceMaterializesLocalRepositoriesAsWorktrees() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-local-worktree-workspace-\(UUID().uuidString)") diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index baac8b2b..11732474 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -581,10 +581,7 @@ struct RepositoriesFeatureTests { await store.send(.workspaceCreation(.promptRequested)) { let title = "Workspace" - let expectedRootPath = SupacodePaths.workspacesDirectory - .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) + let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, @@ -660,10 +657,7 @@ struct RepositoriesFeatureTests { await store.send(.workspaceCreation(.promptRequested)) { let title = "Workspace" - let expectedRootPath = SupacodePaths.workspacesDirectory - .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) + let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, @@ -715,10 +709,7 @@ struct RepositoriesFeatureTests { await store.send(.workspaceCreation(.promptRequested)) { let title = "Workspace" - let expectedRootPath = SupacodePaths.workspacesDirectory - .appending(path: ProjectWorkspace.defaultWorkspaceFolderName(for: title), directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) + let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, @@ -6187,3 +6178,17 @@ struct RepositoriesFeatureTests { } } } + +// Mirrors RepositoriesFeature.defaultWorkspaceRootURL: the default folder name +// is uniqued against directories that already exist on the test machine. +func expectedDefaultWorkspaceRootPath(for title: String) -> String { + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + let baseURL = SupacodePaths.workspacesDirectory + var candidateURL = baseURL.appending(path: folderName, directoryHint: .isDirectory) + var suffix = 2 + while FileManager.default.fileExists(atPath: candidateURL.path(percentEncoded: false)) { + candidateURL = baseURL.appending(path: "\(folderName)-\(suffix)", directoryHint: .isDirectory) + suffix += 1 + } + return candidateURL.standardizedFileURL.path(percentEncoded: false) +} -- 2.51.2 From b8ee57d38875589fc83b0f944a3ea43881456508 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 19:36:13 +0800 Subject: [PATCH 451/528] Offer file cleanup when removing a workspace Removing a workspace now opens a dedicated confirmation sheet with an opt-in toggle that unregisters the worktrees created for the workspace from their source repositories and deletes the workspace folder. Symlinked repositories are left untouched. Cleanup is best-effort: failures are logged and skipped so a broken source repository cannot block removal. --- docs/components/workspaces.md | 11 ++++ supacode/App/ContentView.swift | 24 ++++++++ supacode/Domain/ProjectWorkspace.swift | 38 ++++++++++++ ...sitoriesFeature+RepositoryManagement.swift | 49 +++++++++++++++ ...epositoriesFeature+WorkspaceCreation.swift | 2 +- .../Reducer/RepositoriesFeature.swift | 11 ++++ .../RemoveWorkspaceConfirmationView.swift | 60 +++++++++++++++++++ supacodeTests/ProjectWorkspaceTests.swift | 55 +++++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 55 +++++++++++++++++ 9 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index a98f80e9..860fbbc0 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -86,6 +86,17 @@ Branch behavior is explicit: branch — aligning a same-named local branch to the remote when one exists. Git refuses if that branch is already checked out in another worktree. +## Removing a workspace + +**Remove Repository** on a workspace opens a dedicated confirmation. By default +it only removes the entry from Prowl and leaves everything on disk. Tick **Also +delete the workspace folder and its worktrees** to additionally unregister the +worktrees that were created for this workspace from their source repositories +(`git worktree remove --force`) and delete the workspace folder. Linked +repositories stay untouched — only the symlinks inside the workspace folder are +removed. Cleanup is best-effort: a broken source repository is logged and +skipped instead of blocking the deletion. + ## Metadata Example `.prowl/workspace.json`: diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 2eed9a54..33c98d63 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -39,6 +39,14 @@ struct ContentView: View { } } ) + let removeWorkspaceConfirmationPresented = Binding( + get: { repositoriesStore.removeWorkspaceConfirmation != nil }, + set: { isPresented in + if !isPresented { + repositoriesStore.send(.repositoryManagement(.removeWorkspacePromptDismissed)) + } + } + ) Group { if store.repositories.isInitialLoadComplete { mainSplitView @@ -100,6 +108,22 @@ struct ContentView: View { ) } } + .sheet(isPresented: removeWorkspaceConfirmationPresented) { + if let confirmation = repositoriesStore.removeWorkspaceConfirmation { + RemoveWorkspaceConfirmationView( + confirmation: confirmation, + onDeleteFilesChanged: { + repositoriesStore.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged($0))) + }, + onCancel: { + repositoriesStore.send(.repositoryManagement(.removeWorkspacePromptDismissed)) + }, + onRemove: { + repositoriesStore.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) + } + ) + } + } .sheet(isPresented: isRunScriptPromptPresented) { RunScriptPromptView( script: runScriptDraft, diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 4bb3d609..5a19b71b 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -558,6 +558,44 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { await task.value } + // Best-effort removal cleanup: every failure is logged and the remaining + // entries are still processed so a broken repository cannot block deletion. + static func cleanup( + _ workspace: ProjectWorkspace, + rootURL: URL, + fileManager: FileManager = .default, + gitRunner: ProjectWorkspaceGitRunner + ) async { + let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) + for entry in workspace.repositories { + let entryURL = entry.resolvedURL(relativeTo: rootURL) + let entryPath = entryURL.path(percentEncoded: false) + guard entryPath.hasPrefix(rootPath + "/") else { + continue + } + let isSymbolicLink = + (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation else { + continue + } + do { + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", sourceLocation, "worktree", "remove", "--force", entryPath], + currentDirectoryURL: nil + ) + ) + } catch { + log.warning("Workspace cleanup could not unregister worktree at \(entryPath): \(error)") + } + } + do { + try fileManager.removeItem(at: rootURL) + } catch { + log.warning("Workspace cleanup could not remove \(rootPath): \(error)") + } + } + static func defaultWorkspaceFolderName(for title: String) -> String { let sanitized = sanitizedWorkspaceComponent(title) return sanitized.isEmpty ? "workspace" : sanitized diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index dff64e5b..8f491063 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -163,9 +163,58 @@ extension RepositoriesFeature { return .merge(allEffects) case .requestRemoveRepository(let repositoryID): + if let repository = state.repositories[id: repositoryID], repository.isWorkspace { + state.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( + repositoryID: repositoryID, + workspaceTitle: repository.name, + rootPath: repository.rootURL.path(percentEncoded: false) + ) + return .none + } state.alert = confirmationAlertForRepositoryRemoval(repositoryID: repositoryID, state: state) return .none + case .removeWorkspaceDeleteFilesChanged(let deleteFiles): + state.removeWorkspaceConfirmation?.deleteFiles = deleteFiles + return .none + + case .removeWorkspacePromptDismissed: + state.removeWorkspaceConfirmation = nil + return .none + + case .removeWorkspacePromptConfirmed: + guard let confirmation = state.removeWorkspaceConfirmation else { + return .none + } + state.removeWorkspaceConfirmation = nil + guard let repository = state.repositories[id: confirmation.repositoryID], + !state.removingRepositoryIDs.contains(repository.id) + else { + return .none + } + state.removingRepositoryIDs.insert(repository.id) + let selectionWasRemoved = + state.selectedWorktreeID.map { id in + repository.worktrees.contains(where: { $0.id == id }) + } ?? false + guard confirmation.deleteFiles, let workspace = repository.workspace else { + return .send( + .repositoryManagement( + .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved) + ) + ) + } + let rootURL = repository.rootURL + let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) + return .run { send in + await ProjectWorkspace.cleanup(workspace, rootURL: rootURL, gitRunner: gitRunner) + await send( + .repositoryManagement( + .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved) + ) + ) + } + case .removeFailedRepository(let repositoryID): state.alert = nil state.loadFailuresByID.removeValue(forKey: repositoryID) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index f46105ee..6747f95f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -170,7 +170,7 @@ extension RepositoriesFeature { state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository } - private static func workspaceGitRunner(shellClient: ShellClient) -> ProjectWorkspaceGitRunner { + static func workspaceGitRunner(shellClient: ShellClient) -> ProjectWorkspaceGitRunner { ProjectWorkspaceGitRunner { command in do { _ = try await shellClient.run( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 1dee6e49..02183eae 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -69,6 +69,13 @@ struct ForceDeleteBranchRequest: Equatable { let errorMessage: String } +struct RemoveWorkspaceConfirmation: Equatable { + let repositoryID: Repository.ID + let workspaceTitle: String + let rootPath: String + var deleteFiles = false +} + @Reducer struct RepositoriesFeature { enum CancelID { @@ -209,6 +216,9 @@ struct RepositoriesFeature { ) case requestRemoveRepository(Repository.ID) case removeFailedRepository(Repository.ID) + case removeWorkspaceDeleteFilesChanged(Bool) + case removeWorkspacePromptDismissed + case removeWorkspacePromptConfirmed case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) case openRepositorySettings(Repository.ID) } @@ -290,6 +300,7 @@ struct RepositoriesFeature { @Shared(.appStorage("prowlCreatedWorktreeIDs")) var prowlCreatedWorktreeIDs: [Worktree.ID] = [] var nextDeleteWorktreeConfirmationID = 0 var deleteWorktreeConfirmation: DeleteWorktreeConfirmation? + var removeWorkspaceConfirmation: RemoveWorkspaceConfirmation? var pendingForceDeleteBranchRequests: [ForceDeleteBranchRequest] = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? diff --git a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift new file mode 100644 index 00000000..80f6c539 --- /dev/null +++ b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct RemoveWorkspaceConfirmationView: View { + let confirmation: RemoveWorkspaceConfirmation + let onDeleteFilesChanged: (Bool) -> Void + let onCancel: () -> Void + let onRemove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Remove workspace?") + .font(.headline) + Text("This removes \(confirmation.workspaceTitle) from Prowl.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(confirmation.rootPath) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Toggle( + "Also delete the workspace folder and its worktrees", + isOn: Binding( + get: { confirmation.deleteFiles }, + set: { onDeleteFilesChanged($0) } + ) + ) + .help( + "Unregister worktrees created for this workspace from their source repositories, " + + "then delete the workspace folder." + ) + + Text("Linked repositories stay untouched; only the symlinks inside the workspace folder are removed.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + Button("Cancel", role: .cancel) { + onCancel() + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + + Button("Remove", role: .destructive) { + onRemove() + } + .keyboardShortcut(.defaultAction) + .help("Remove workspace (↩)") + } + } + .padding(24) + .frame(width: 440) + } +} diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 8e261544..76691871 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -676,6 +676,61 @@ struct ProjectWorkspaceTests { ) } + @Test func cleanupUnregistersWorktreesAndRemovesFolder() async throws { + let rootURL = try makeTemporaryWorkspaceRoot() + let linkedSourceURL = try makeTemporaryWorkspaceRoot() + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: linkedSourceURL) + try? FileManager.default.removeItem(at: bareURL) + } + try FileManager.default.createSymbolicLink( + at: rootURL.appending(path: "app"), + withDestinationURL: linkedSourceURL + ) + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: linkedSourceURL.path(percentEncoded: false) + ), + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + branchName: "chore/x" + ), + ] + ) + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) + await ProjectWorkspace.cleanup( + workspace, + rootURL: rootURL, + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + #expect( + commands.value.map(\.arguments) == [ + ["-C", bareURL.path(percentEncoded: false), "worktree", "remove", "--force", apiPath] + ]) + #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + #expect(FileManager.default.fileExists(atPath: linkedSourceURL.path(percentEncoded: false))) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 11732474..bb197b10 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1106,6 +1106,61 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) } + @Test func requestRemoveWorkspaceShowsConfirmationSheet() async { + let workspaceID = "/tmp/ws" + let repository = makeRepository( + id: workspaceID, + name: "WS", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "WS") + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) { + $0.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( + repositoryID: workspaceID, + workspaceTitle: "WS", + rootPath: workspaceID + ) + } + await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) { + $0.removeWorkspaceConfirmation?.deleteFiles = true + } + await store.send(.repositoryManagement(.removeWorkspacePromptDismissed)) { + $0.removeWorkspaceConfirmation = nil + } + } + + @Test func removeWorkspaceConfirmedRemovesEntry() async { + let workspaceID = "/tmp/ws" + let repository = makeRepository( + id: workspaceID, + name: "WS", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "WS") + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) + await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) { + $0.removeWorkspaceConfirmation = nil + $0.removingRepositoryIDs = [workspaceID] + } + await store.receive(\.repositoryManagement.repositoryRemoved) + await store.finish() + } + @Test func workspaceCreationCancelWhileCreatingShowsToast() async { var initialState = RepositoriesFeature.State() initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( -- 2.51.2 From 6f2eb2441cba609920b17edd331fd9733fababa1 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 19:52:39 +0800 Subject: [PATCH 452/528] Drop removed repositories despite trailing-slash drift Repository IDs capture a trailing slash while the folder exists on disk, but entries re-normalized after a file cleanup lose it, so the removal filter never matched and an empty entry was left behind. Compare paths with trailing slashes stripped. --- ...sitoriesFeature+RepositoryManagement.swift | 21 +++++++++++-- supacodeTests/RepositoriesFeatureTests.swift | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 8f491063..4c41963f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -219,12 +219,12 @@ extension RepositoriesFeature { state.alert = nil state.loadFailuresByID.removeValue(forKey: repositoryID) state.repositoryRoots.removeAll { - $0.standardizedFileURL.path(percentEncoded: false) == repositoryID + isSameRepositoryPath($0.standardizedFileURL.path(percentEncoded: false), repositoryID) } let remainingRoots = state.repositoryRoots return .run { send in let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } + let remainingEntries = loadedEntries.filter { !isSameRepositoryPath($0.path, repositoryID) } await repositoryPersistence.saveRepositoryEntries(remainingEntries) let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } let (repositories, failures) = await loadRepositoriesData(remainingEntries) @@ -252,7 +252,7 @@ extension RepositoriesFeature { .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), .run { send in let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } + let remainingEntries = loadedEntries.filter { !isSameRepositoryPath($0.path, repositoryID) } await repositoryPersistence.saveRepositoryEntries(remainingEntries) let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } let (repositories, failures) = await loadRepositoriesData(remainingEntries) @@ -282,3 +282,18 @@ extension RepositoriesFeature { } } } + +// Path-based URL APIs append a trailing slash only while the directory exists +// on disk, so an ID captured before a deletion may not equal the re-normalized +// entry path afterwards. +nonisolated private func isSameRepositoryPath(_ lhs: String, _ rhs: String) -> Bool { + comparableRepositoryPath(lhs) == comparableRepositoryPath(rhs) +} + +nonisolated private func comparableRepositoryPath(_ path: String) -> String { + var path = path + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + return path +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index bb197b10..48a9bd48 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1161,6 +1161,37 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func repositoryRemovedDropsEntryDespiteTrailingSlashMismatch() async { + let repository = makeRepository( + id: "/tmp/ws/", + name: "WS", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "WS") + ) + let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: "/tmp/ws", kind: .plain)] + } + $0.repositoryPersistence.saveRepositoryEntries = { entries in + savedEntries.withValue { $0.append(entries) } + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repoRoot = { _ in + throw GitClientError.commandFailed(command: "git", message: "not a git repository") + } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) + await store.finish() + + #expect(savedEntries.value.last == []) + } + @Test func workspaceCreationCancelWhileCreatingShowsToast() async { var initialState = RepositoriesFeature.State() initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( -- 2.51.2 From 72c985f4629de1cef45e638f1c5626e738e99486 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 19:52:40 +0800 Subject: [PATCH 453/528] Show workspace metadata read-only in repository settings The settings page for a workspace now lists its description, task links, and repository table from .prowl/workspace.json, with a footer pointing at the file for edits. The repositories grid is shared with the workspace detail view and gains a source column. --- docs/components/workspaces.md | 4 ++ .../Features/App/Reducer/AppFeature.swift | 1 + .../Views/WorkspaceDetailView.swift | 41 +----------- .../Views/WorkspaceRepositoriesGridView.swift | 67 +++++++++++++++++++ .../Reducer/RepositorySettingsFeature.swift | 1 + .../Views/RepositorySettingsView.swift | 36 ++++++++++ 6 files changed, 110 insertions(+), 40 deletions(-) create mode 100644 supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 860fbbc0..1c2f068c 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -99,6 +99,10 @@ skipped instead of blocking the deletion. ## Metadata +The workspace's repository settings page (Settings → the workspace under +Repositories) shows this metadata read-only; edit `.prowl/workspace.json` to +change it. + Example `.prowl/workspace.json`: ```json diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index b32897d1..4200ed29 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -373,6 +373,7 @@ struct AppFeature { userSettings: userRepositorySettings, appearance: repositoryAppearances[repository.id] ?? .empty ) + repoSettingsState.workspace = repository.workspace repoSettingsState.globalCopyIgnoredOnWorktreeCreate = state.settings.copyIgnoredOnWorktreeCreate repoSettingsState.globalCopyUntrackedOnWorktreeCreate = state.settings.copyUntrackedOnWorktreeCreate repoSettingsState.globalPullRequestMergeStrategy = state.settings.pullRequestMergeStrategy diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift index 48043e83..a827c189 100644 --- a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -62,46 +62,7 @@ struct WorkspaceDetailView: View { VStack(alignment: .leading, spacing: 8) { Text("Repositories") .font(.headline) - if workspace.repositories.isEmpty { - Text("No repositories are declared in this workspace metadata.") - .font(.callout) - .foregroundStyle(.secondary) - } else { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { - tableHeader("Name") - tableHeader("Role") - tableHeader("Branch") - tableHeader("Path") - } - Divider() - .gridCellUnsizedAxes(.horizontal) - ForEach(workspace.repositories) { entry in - GridRow(alignment: .firstTextBaseline) { - Text(entry.name) - .font(.subheadline.weight(.medium)) - Text(entry.role ?? " ") - .font(.subheadline) - .foregroundStyle(.secondary) - Text(entry.branchName ?? entry.baseRef ?? " ") - .font(.subheadline.monospaced()) - .foregroundStyle(.secondary) - Text(entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false)) - .font(.subheadline.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - } - } - } + WorkspaceRepositoriesGridView(workspace: workspace, rootURL: repository.rootURL) } } - - private func tableHeader(_ title: String) -> some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } } diff --git a/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift new file mode 100644 index 00000000..0d0c91af --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct WorkspaceRepositoriesGridView: View { + let workspace: ProjectWorkspace + let rootURL: URL + + var body: some View { + if workspace.repositories.isEmpty { + Text("No repositories are declared in this workspace metadata.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { + GridRow { + header("Name") + header("Role") + header("Source") + header("Branch") + header("Path") + } + Divider() + .gridCellUnsizedAxes(.horizontal) + ForEach(workspace.repositories) { entry in + GridRow(alignment: .firstTextBaseline) { + Text(entry.name) + .font(.subheadline.weight(.medium)) + Text(entry.role ?? " ") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(sourceKindTitle(entry.sourceKind)) + .font(.subheadline) + .foregroundStyle(.secondary) + .help(entry.sourceLocation ?? "") + Text(entry.branchName ?? entry.baseRef ?? " ") + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + Text(entry.resolvedURL(relativeTo: rootURL).path(percentEncoded: false)) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + } + } + + private func header(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + + private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "Linked" + case .localRepository: + return "Local" + case .remote: + return "Remote" + case .bareRepository: + return "Bare" + } + } +} diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 3117009c..851ed26b 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -12,6 +12,7 @@ struct RepositorySettingsFeature { /// callers (AppFeature) always pass the canonical `repository.id`. var repositoryID: Repository.ID = "" var repositoryKind: Repository.Kind + var workspace: ProjectWorkspace? var settings: RepositorySettings var userSettings: UserRepositorySettings var appearance: RepositoryAppearance = .empty diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 87cd46f0..d780b6d0 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -96,6 +96,42 @@ struct RepositorySettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } + if let workspace = store.workspace { + Section { + VStack(alignment: .leading, spacing: 12) { + if !workspace.description.isEmpty { + Text(workspace.description) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + if !workspace.taskLinks.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(workspace.taskLinks, id: \.self) { link in + Text(link) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + WorkspaceRepositoriesGridView(workspace: workspace, rootURL: store.rootURL) + } + .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text("Workspace") + } footer: { + Text( + "Read-only. Defined in " + + "\(ProjectWorkspace.metadataURL(for: store.rootURL).path(percentEncoded: false)) " + + "— edit that file to change it." + ) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + if store.showsWorktreeSettings { Section { if store.isBranchDataLoaded { -- 2.51.2 From ed3fc6127e31765983cefd959eed20d42dde8732 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 20:35:31 +0800 Subject: [PATCH 454/528] Clarify workspace grid source and materialization columns Opened-path worktrees showed as "Linked" because the grid rendered the source kind with a materialization word. Rename the source labels and add a Mode column that derives Link / Worktree / Clone from the entry. --- .../Views/WorkspaceRepositoriesGridView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift index 0d0c91af..665840be 100644 --- a/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift @@ -15,6 +15,7 @@ struct WorkspaceRepositoriesGridView: View { header("Name") header("Role") header("Source") + header("Mode") header("Branch") header("Path") } @@ -31,6 +32,9 @@ struct WorkspaceRepositoriesGridView: View { .font(.subheadline) .foregroundStyle(.secondary) .help(entry.sourceLocation ?? "") + Text(materializationTitle(entry)) + .font(.subheadline) + .foregroundStyle(.secondary) Text(entry.branchName ?? entry.baseRef ?? " ") .font(.subheadline.monospaced()) .foregroundStyle(.secondary) @@ -55,7 +59,7 @@ struct WorkspaceRepositoriesGridView: View { private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath: - return "Linked" + return "Opened" case .localRepository: return "Local" case .remote: @@ -64,4 +68,17 @@ struct WorkspaceRepositoriesGridView: View { return "Bare" } } + + // The metadata does not record the checkout mode, but it is implied: only + // Link materialization leaves both branch fields empty for git sources. + private func materializationTitle(_ entry: ProjectWorkspaceRepositoryEntry) -> String { + switch entry.sourceKind { + case .remote: + return "Clone" + case .bareRepository: + return "Worktree" + case .existingPath, .localRepository: + return entry.branchName == nil && entry.baseRef == nil ? "Link" : "Worktree" + } + } } -- 2.51.2 From e53dc666e2d1cd2fae4a5965306e166527357f69 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 20:35:31 +0800 Subject: [PATCH 455/528] Add per-repository branch deletion to workspace removal The removal sheet lists a checkbox for each worktree entry with a recorded branch; selected branches are deleted from their source repositories with git branch -D after the worktree is removed. The checkboxes activate only when file cleanup is enabled. --- docs/components/workspaces.md | 11 ++-- supacode/App/ContentView.swift | 3 + supacode/Domain/ProjectWorkspace.swift | 15 +++++ ...sitoriesFeature+RepositoryManagement.swift | 35 ++++++++++- .../Reducer/RepositoriesFeature.swift | 9 +++ .../RemoveWorkspaceConfirmationView.swift | 24 ++++++++ supacodeTests/ProjectWorkspaceTests.swift | 44 ++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 60 +++++++++++++++++++ 8 files changed, 195 insertions(+), 6 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 1c2f068c..f63fa8d8 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -92,10 +92,13 @@ Branch behavior is explicit: it only removes the entry from Prowl and leaves everything on disk. Tick **Also delete the workspace folder and its worktrees** to additionally unregister the worktrees that were created for this workspace from their source repositories -(`git worktree remove --force`) and delete the workspace folder. Linked -repositories stay untouched — only the symlinks inside the workspace folder are -removed. Cleanup is best-effort: a broken source repository is logged and -skipped instead of blocking the deletion. +(`git worktree remove --force`) and delete the workspace folder. Worktree +entries with a recorded branch additionally offer a per-repository **Delete +branch** checkbox that removes the branch from the source repository +(`git branch -D`) after the worktree is gone. Linked repositories stay +untouched — only the symlinks inside the workspace folder are removed. Cleanup +is best-effort: a broken source repository is logged and skipped instead of +blocking the deletion. ## Metadata diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 33c98d63..8bab252a 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -115,6 +115,9 @@ struct ContentView: View { onDeleteFilesChanged: { repositoriesStore.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged($0))) }, + onDeleteBranchChanged: { + repositoriesStore.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged($0, $1))) + }, onCancel: { repositoriesStore.send(.repositoryManagement(.removeWorkspacePromptDismissed)) }, diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 5a19b71b..8f00f7bf 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -563,6 +563,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { static func cleanup( _ workspace: ProjectWorkspace, rootURL: URL, + deleteBranchEntryIDs: Set = [], fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner ) async { @@ -588,6 +589,20 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } catch { log.warning("Workspace cleanup could not unregister worktree at \(entryPath): \(error)") } + // Branch deletion must follow the worktree removal — git refuses to + // delete a branch that still has a checkout. + if deleteBranchEntryIDs.contains(entry.id), let branchName = entry.branchName { + do { + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", sourceLocation, "branch", "-D", branchName], + currentDirectoryURL: nil + ) + ) + } catch { + log.warning("Workspace cleanup could not delete branch \(branchName): \(error)") + } + } } do { try fileManager.removeItem(at: rootURL) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 4c41963f..0a2b744c 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -164,10 +164,25 @@ extension RepositoriesFeature { case .requestRemoveRepository(let repositoryID): if let repository = state.repositories[id: repositoryID], repository.isWorkspace { + let branchOptions = (repository.workspace?.repositories ?? []).compactMap { + entry -> RemoveWorkspaceConfirmation.BranchOption? in + guard entry.sourceKind != .remote, + let branchName = entry.branchName, + entry.sourceLocation != nil + else { + return nil + } + return RemoveWorkspaceConfirmation.BranchOption( + id: entry.id, + repositoryName: entry.name, + branchName: branchName + ) + } state.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( repositoryID: repositoryID, workspaceTitle: repository.name, - rootPath: repository.rootURL.path(percentEncoded: false) + rootPath: repository.rootURL.path(percentEncoded: false), + branchOptions: branchOptions ) return .none } @@ -178,6 +193,16 @@ extension RepositoriesFeature { state.removeWorkspaceConfirmation?.deleteFiles = deleteFiles return .none + case .removeWorkspaceDeleteBranchChanged(let entryID, let isSelected): + guard var confirmation = state.removeWorkspaceConfirmation, + let index = confirmation.branchOptions.firstIndex(where: { $0.id == entryID }) + else { + return .none + } + confirmation.branchOptions[index].isSelected = isSelected + state.removeWorkspaceConfirmation = confirmation + return .none + case .removeWorkspacePromptDismissed: state.removeWorkspaceConfirmation = nil return .none @@ -205,9 +230,15 @@ extension RepositoriesFeature { ) } let rootURL = repository.rootURL + let deleteBranchEntryIDs = Set(confirmation.branchOptions.filter(\.isSelected).map(\.id)) let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) return .run { send in - await ProjectWorkspace.cleanup(workspace, rootURL: rootURL, gitRunner: gitRunner) + await ProjectWorkspace.cleanup( + workspace, + rootURL: rootURL, + deleteBranchEntryIDs: deleteBranchEntryIDs, + gitRunner: gitRunner + ) await send( .repositoryManagement( .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 02183eae..00f8f0f6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -70,10 +70,18 @@ struct ForceDeleteBranchRequest: Equatable { } struct RemoveWorkspaceConfirmation: Equatable { + struct BranchOption: Equatable, Identifiable { + let id: String + let repositoryName: String + let branchName: String + var isSelected = false + } + let repositoryID: Repository.ID let workspaceTitle: String let rootPath: String var deleteFiles = false + var branchOptions: [BranchOption] = [] } @Reducer @@ -217,6 +225,7 @@ struct RepositoriesFeature { case requestRemoveRepository(Repository.ID) case removeFailedRepository(Repository.ID) case removeWorkspaceDeleteFilesChanged(Bool) + case removeWorkspaceDeleteBranchChanged(String, Bool) case removeWorkspacePromptDismissed case removeWorkspacePromptConfirmed case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) diff --git a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift index 80f6c539..ac2a64e4 100644 --- a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift +++ b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct RemoveWorkspaceConfirmationView: View { let confirmation: RemoveWorkspaceConfirmation let onDeleteFilesChanged: (Bool) -> Void + let onDeleteBranchChanged: (String, Bool) -> Void let onCancel: () -> Void let onRemove: () -> Void @@ -34,6 +35,29 @@ struct RemoveWorkspaceConfirmationView: View { + "then delete the workspace folder." ) + if !confirmation.branchOptions.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(confirmation.branchOptions) { option in + Toggle( + isOn: Binding( + get: { option.isSelected }, + set: { onDeleteBranchChanged(option.id, $0) } + ) + ) { + HStack(spacing: 4) { + Text("Delete branch") + Text(option.branchName) + .font(.body.monospaced()) + Text("in \(option.repositoryName)") + } + } + .help("Delete the branch from the source repository with git branch -D after removing the worktree.") + } + } + .padding(.leading, 20) + .disabled(!confirmation.deleteFiles) + } + Text("Linked repositories stay untouched; only the symlinks inside the workspace folder are removed.") .font(.footnote) .foregroundStyle(.secondary) diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 76691871..332bc025 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -731,6 +731,50 @@ struct ProjectWorkspaceTests { #expect(FileManager.default.fileExists(atPath: linkedSourceURL.path(percentEncoded: false))) } + @Test func cleanupDeletesSelectedBranchesAfterWorktreeRemoval() async throws { + let rootURL = try makeTemporaryWorkspaceRoot() + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + branchName: "chore/x" + ) + ] + ) + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) + await ProjectWorkspace.cleanup( + workspace, + rootURL: rootURL, + deleteBranchEntryIDs: ["api"], + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + let barePath = bareURL.path(percentEncoded: false) + #expect( + commands.value.map(\.arguments) == [ + ["-C", barePath, "worktree", "remove", "--force", apiPath], + ["-C", barePath, "branch", "-D", "chore/x"], + ]) + #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 48a9bd48..3d010394 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1134,6 +1134,66 @@ struct RepositoriesFeatureTests { } } + @Test func requestRemoveWorkspaceBuildsBranchOptions() async { + let workspaceID = "/tmp/ws" + let workspace = ProjectWorkspace( + title: "WS", + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git", + branchName: "chore/x" + ), + ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: "/tmp/app" + ), + ProjectWorkspace.RepositoryEntry( + id: "web", + name: "Web", + path: "web", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/web.git", + branchName: "feature/web" + ), + ] + ) + let repository = makeRepository( + id: workspaceID, + name: "WS", + kind: .plain, + worktrees: [], + workspace: workspace + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) { + $0.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( + repositoryID: workspaceID, + workspaceTitle: "WS", + rootPath: workspaceID, + branchOptions: [ + RemoveWorkspaceConfirmation.BranchOption( + id: "api", + repositoryName: "API", + branchName: "chore/x" + ) + ] + ) + } + await store.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged("api", true))) { + $0.removeWorkspaceConfirmation?.branchOptions[0].isSelected = true + } + } + @Test func removeWorkspaceConfirmedRemovesEntry() async { let workspaceID = "/tmp/ws" let repository = makeRepository( -- 2.51.2 From 0baa8d09c553cbb6b3119bca041b8084902a5a97 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Thu, 11 Jun 2026 18:43:25 +0800 Subject: [PATCH 456/528] Show New Workspace as a standalone sidebar toolbar button Revert the fold into an Add Repository menu: New Workspace is its own toolbar button again, next to Add Repository, instead of being hidden behind a dropdown chevron. The fold was motivated by avoiding overflow into the window-trailing menu when the sidebar is narrow, but the sidebar toolbar's disappear-after-collapse glitch is an unrelated macOS NavigationSplitView platform bug, so the arrangement is purely cosmetic. --- docs/components/repositories-and-worktrees.md | 3 +-- docs/components/workspaces.md | 6 ++--- .../Repositories/Views/SidebarListView.swift | 22 +++++++++---------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index a6a236f9..aff35d8c 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -33,8 +33,7 @@ root; "not a git repository" → plain folder). - **Shortcut:** `⌘⇧O` (`open_repository`) - **Toolbar:** the **Add Repository** button (folder-with-plus icon) at the top of - the sidebar. Clicking runs Add Repository directly; its attached menu holds - **New Workspace**. + the sidebar, next to **New Workspace** (person-in-folder icon). - **Command Palette:** "Open Repository". Pick one or more directories. Prowl detects git vs plain, de-duplicates, and diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index f63fa8d8..44f9e6bd 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -18,9 +18,9 @@ When you open a workspace in Prowl: ## Folder layout -Use **New Workspace** from the sidebar toolbar (the menu attached to the **Add -Repository** button), the Worktrees menu, or the command palette to create a -workspace. Prowl creates the shared folder, materializes the +Use **New Workspace** from the sidebar toolbar (the +person-in-folder button next to **Add Repository**), the Worktrees menu, or the +command palette to create a workspace. Prowl creates the shared folder, materializes the selected repositories, writes `.prowl/workspace.json`, and opens the workspace as a runnable folder. A workspace needs at least two repositories. diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 3eaed7ca..3980d7ca 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -194,21 +194,19 @@ struct SidebarListView: View { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { - ToolbarItem(placement: .automatic) { - // Single toolbar slot: two separate buttons overflow into the - // window-trailing ">>" menu when the sidebar gets narrow. - Menu { - Button { - store.send(.workspaceCreation(.promptRequested)) - } label: { - Label("New Workspace", systemImage: "folder.badge.person.crop") - } + ToolbarItemGroup(placement: .automatic) { + Button { + store.send(.workspaceCreation(.promptRequested)) } label: { - Label("Add Repository", systemImage: "folder.badge.plus") - } primaryAction: { + Label("New Workspace", systemImage: "folder.badge.person.crop") + } + .help("New Workspace") + Button { store.send(.setOpenPanelPresented(true)) + } label: { + Label("Add Repository", systemImage: "folder.badge.plus") } - .help("Add Repository — open the menu for New Workspace") + .help("Add Repository") } } } // ScrollViewReader -- 2.51.2 From 09e644933ca022381edfb242587bfe22cdaae63a Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Thu, 11 Jun 2026 20:37:58 +0800 Subject: [PATCH 457/528] List workspace child repositories as sidebar rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding a workspace now reveals a display-only row per child repository showing its live branch, uncommitted +/- diff, and PR (when GitHub integration is available), mirroring a worktree row's badges. Child status flows through a dedicated pipeline (workspaceChildInfoByID / workspaceChildBranchByID), refreshed from repositoriesLoaded so it tracks the periodic/scene-active reload cadence — the worktree info watcher bails on anything not tracked in repository.worktrees, so children cannot reuse it. Rows reuse WorktreeRow with all interaction closures nil; the workspace gains an expand chevron without worktree-creation affordances. Stale child entries are pruned on reload. --- .../Models/SidebarPresentation.swift | 18 ++- .../RepositoriesFeature+CoreReducer.swift | 5 + ...epositoriesFeature+RepositoryLoading.swift | 1 + .../RepositoriesFeature+StateQueries.swift | 37 +++++ ...epositoriesFeature+WorkspaceChildren.swift | 130 ++++++++++++++++++ .../Reducer/RepositoriesFeature.swift | 20 +++ .../Views/RepositorySectionView.swift | 29 ++-- .../Views/WorkspaceChildRowsView.swift | 40 ++++++ supacodeTests/RepositoriesFeatureTests.swift | 121 ++++++++++++++++ 9 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift create mode 100644 supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift index 44617867..e1e5031c 100644 --- a/supacode/Features/Repositories/Models/SidebarPresentation.swift +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -79,7 +79,19 @@ struct SidebarRepositoryContainerModel: Equatable, Identifiable { var kind: Repository.Kind var isExpanded: Bool var isRemoving: Bool + var isWorkspace: Bool var worktreeSections: WorktreeRowSections + var workspaceChildRows: [WorkspaceChildRowModel] +} + +/// A display-only sidebar row for one workspace child repository. `branchName` +/// is the live current branch (falling back to metadata); `info` carries the +/// uncommitted diff counts and PR, mirroring a worktree row's badges. +struct WorkspaceChildRowModel: Equatable, Identifiable { + let id: String + let repositoryName: String + let branchName: String? + let info: WorktreeInfoEntry? } struct FailedRepositoryModel: Equatable, Identifiable { @@ -157,7 +169,11 @@ extension RepositoriesFeature.State { kind: repository.kind, isExpanded: isExpanded, isRemoving: isRemovingRepository(repository), - worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty + isWorkspace: repository.isWorkspace, + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty, + workspaceChildRows: isExpanded && repository.isWorkspace + ? workspaceChildRows(in: repository) + : [] ) ) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index f26798d5..17ae0feb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -284,6 +284,7 @@ extension RepositoriesFeature { { allEffects.append(effect) } + allEffects.append(refreshWorkspaceChildrenEffect(state: state)) return .merge(allEffects) case .refreshAllCustomTitles: @@ -848,6 +849,10 @@ extension RepositoriesFeature { ) return .none + case .workspaceChildrenInfoLoaded(let updates): + applyWorkspaceChildrenInfo(updates, state: &state) + return .none + case .alert(.dismiss): dismissCurrentForceDeleteBranchRequest(state: &state) return .none diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 4d8c7d1e..42018c1a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -324,6 +324,7 @@ extension RepositoriesFeature { .map(SidebarSelection.worktree) state.shouldSelectFirstAfterReload = false } + pruneWorkspaceChildInfo(state: &state) return ApplyRepositoriesResult( didPrunePinned: didPrunePinned, didPruneRepositoryOrder: didPruneRepositoryOrder, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 21be338f..d741a893 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -148,6 +148,43 @@ extension RepositoriesFeature.State { return worktrees.filter { !archivedSet.contains($0.id) } } + /// Child repositories materialized inside every workspace, resolved to their + /// on-disk working directory. Used both to refresh their live status and to + /// render their sidebar rows. Child id is the working-directory path string. + func resolvedWorkspaceChildren(in repository: Repository) -> [ResolvedWorkspaceChild] { + guard let workspace = repository.workspace else { + return [] + } + return workspace.repositories.map { entry in + let url = entry.resolvedURL(relativeTo: repository.rootURL) + return ResolvedWorkspaceChild( + id: url.path(percentEncoded: false), + workspaceID: repository.id, + repositoryName: entry.name, + metadataBranch: entry.branchName, + workingDirectory: url + ) + } + } + + func allResolvedWorkspaceChildren() -> [ResolvedWorkspaceChild] { + repositories.filter(\.isWorkspace).flatMap { resolvedWorkspaceChildren(in: $0) } + } + + /// Sidebar display rows for one workspace's children, merging the resolved + /// metadata with live branch (`workspaceChildBranchByID`) and diff/PR info + /// (`workspaceChildInfoByID`). Live branch falls back to the metadata branch. + func workspaceChildRows(in repository: Repository) -> [WorkspaceChildRowModel] { + resolvedWorkspaceChildren(in: repository).map { child in + WorkspaceChildRowModel( + id: child.id, + repositoryName: child.repositoryName, + branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch, + info: workspaceChildInfoByID[child.id] + ) + } + } + struct ArchivedWorktreeGroup: Equatable { var repository: Repository var worktrees: [Worktree] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift new file mode 100644 index 00000000..419b687f --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -0,0 +1,130 @@ +import ComposableArchitecture +import Foundation + +/// A workspace child repository resolved to its on-disk working directory. +/// `id` is the working-directory path string (the key for all child maps). +struct ResolvedWorkspaceChild: Equatable, Sendable, Identifiable { + let id: String + let workspaceID: Repository.ID + let repositoryName: String + let metadataBranch: String? + let workingDirectory: URL +} + +extension RepositoriesFeature { + /// Refresh live status (current branch + uncommitted diff) for every + /// workspace child. Driven from `repositoriesLoaded`, which fires on initial + /// load, explicit reloads, and the periodic scene-active refresh — so child + /// rows update on the same cadence without watching their files directly. + /// + /// Workspace children are deliberately NOT fed through the worktree info + /// watcher: that pipeline bails on any worktree not tracked in + /// `repository.worktrees`, and children are metadata entries, not tracked + /// worktrees. + func refreshWorkspaceChildrenEffect(state: State) -> Effect { + let children = state.allResolvedWorkspaceChildren() + guard !children.isEmpty else { + return .none + } + let gitClient = self.gitClient + let githubCLI = self.githubCLI + // PR fetch only when GitHub integration is globally available; each child is + // its own repo, so its remote is resolved from the child's own git root. + let fetchesPullRequests = state.githubIntegrationAvailability == .available + return .run { send in + let updates = await withTaskGroup(of: WorkspaceChildInfoUpdate.self) { group in + for child in children { + group.addTask { + async let branchTask = gitClient.branchName(child.workingDirectory) + async let changesTask = gitClient.lineChanges(child.workingDirectory) + let changes = await changesTask + let branch = await branchTask + let pullRequest = await Self.fetchWorkspaceChildPullRequest( + workingDirectory: child.workingDirectory, + branch: branch, + enabled: fetchesPullRequests, + gitClient: gitClient, + githubCLI: githubCLI + ) + return WorkspaceChildInfoUpdate( + id: child.id, + branch: branch, + added: changes?.added, + removed: changes?.removed, + pullRequest: pullRequest + ) + } + } + var results: [WorkspaceChildInfoUpdate] = [] + for await update in group { + results.append(update) + } + return results + } + await send(.workspaceChildrenInfoLoaded(updates)) + } + .cancellable(id: CancelID.workspaceChildrenRefresh, cancelInFlight: true) + } + + /// Resolve the child repo's GitHub remote (local-only, cheap) and fetch the + /// PR for the current branch. Returns nil on any failure — children are + /// best-effort status, never blocking. + nonisolated private static func fetchWorkspaceChildPullRequest( + workingDirectory: URL, + branch: String?, + enabled: Bool, + gitClient: GitClientDependency, + githubCLI: GithubCLIClient + ) async -> GithubPullRequest? { + guard enabled, + let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines), !branch.isEmpty, + let remote = await gitClient.remoteInfo(workingDirectory) + else { + return nil + } + let pullRequestsByBranch = try? await githubCLI.batchPullRequests( + remote.host, + remote.owner, + remote.repo, + [branch] + ) + return pullRequestsByBranch?[branch] + } +} + +/// Merge a batch of child refresh results into the child maps. +func applyWorkspaceChildrenInfo( + _ updates: [WorkspaceChildInfoUpdate], + state: inout RepositoriesFeature.State +) { + for update in updates { + if let branch = update.branch?.trimmingCharacters(in: .whitespacesAndNewlines), !branch.isEmpty { + state.workspaceChildBranchByID[update.id] = branch + } else { + state.workspaceChildBranchByID.removeValue(forKey: update.id) + } + + var entry = state.workspaceChildInfoByID[update.id] ?? WorktreeInfoEntry() + if let added = update.added, let removed = update.removed, !(added == 0 && removed == 0) { + entry.addedLines = added + entry.removedLines = removed + } else { + entry.addedLines = nil + entry.removedLines = nil + } + entry.pullRequest = update.pullRequest + if entry.isEmpty { + state.workspaceChildInfoByID.removeValue(forKey: update.id) + } else { + state.workspaceChildInfoByID[update.id] = entry + } + } +} + +/// Drop child map entries that no longer belong to any current workspace. +/// Called from `applyRepositories` on every reload. +func pruneWorkspaceChildInfo(state: inout RepositoriesFeature.State) { + let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) + state.workspaceChildInfoByID = state.workspaceChildInfoByID.filter { validIDs.contains($0.key) } + state.workspaceChildBranchByID = state.workspaceChildBranchByID.filter { validIDs.contains($0.key) } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 00f8f0f6..0aee3c16 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -69,6 +69,17 @@ struct ForceDeleteBranchRequest: Equatable { let errorMessage: String } +// Result of refreshing one workspace child repository's live status: current +// branch, uncommitted diff counts, and (when GitHub integration is available) +// the PR for that branch. +struct WorkspaceChildInfoUpdate: Equatable, Sendable { + let id: String + let branch: String? + let added: Int? + let removed: Int? + let pullRequest: GithubPullRequest? +} + struct RemoveWorkspaceConfirmation: Equatable { struct BranchOption: Equatable, Identifiable { let id: String @@ -94,6 +105,7 @@ struct RepositoriesFeature { static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" static let workspaceCreation = "repositories.workspaceCreation" + static let workspaceChildrenRefresh = "repositories.workspaceChildrenRefresh" static func archiveScript(_ worktreeID: Worktree.ID) -> String { "repositories.archiveScript.\(worktreeID)" } @@ -266,6 +278,13 @@ struct RepositoriesFeature { var repositoryCustomTitles: [Repository.ID: String] = [:] var selection: SidebarSelection? var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] + // Live status for workspace child repositories, keyed by the child's + // working-directory path. Kept separate from `worktreeInfoByID` (which is + // pruned to tracked worktrees) because workspace children are not tracked + // worktrees — they are metadata entries materialized inside the workspace + // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. + var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] + var workspaceChildBranchByID: [String: String] = [:] var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false var isInitialLoadComplete = false @@ -412,6 +431,7 @@ struct RepositoriesFeature { case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) case worktreeBranchNameLoaded(worktreeID: Worktree.ID, name: String) case worktreeLineChangesLoaded(worktreeID: Worktree.ID, added: Int, removed: Int) + case workspaceChildrenInfoLoaded([WorkspaceChildInfoUpdate]) case showToast(StatusToast) case dismissToast case worktreeCreationPrompt(PresentationAction) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 45103c7d..935b36b9 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -22,6 +22,11 @@ struct RepositorySectionView: View { var body: some View { let state = store.state let isExpanded = expandedRepoIDs.contains(repository.id) + // Workspaces are `.plain` (no git worktrees) but still expand to reveal + // their child repository rows, so they get the chevron even though + // `supportsWorktrees` is false. Worktree-creation affordances stay gated on + // `supportsWorktrees` so a workspace never offers "New Worktree". + let isExpandable = repository.capabilities.supportsWorktrees || repository.isWorkspace let isRemovingRepository = state.isRemovingRepository(repository) let isSelected = state.selection == .repository(repository.id) let openRepoSettings = { @@ -147,7 +152,7 @@ struct RepositorySectionView: View { ) .disabled(isRemovingRepository) } - if repository.capabilities.supportsWorktrees { + if isExpandable { Button { toggleExpanded() } label: { @@ -186,7 +191,7 @@ struct RepositorySectionView: View { .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) .padding(.horizontal, 12) .padding(.top, hasTopSpacing ? 4 : 0) - .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) + .padding(.bottom, hasTopSpacing && !isExpandable ? 4 : 0) .contentShape(.interaction, .rect) .background { if isSelected { @@ -236,14 +241,18 @@ struct RepositorySectionView: View { header .tag(SidebarSelection.repository(repository.id)) if isExpanded { - WorktreeRowsView( - repository: repository, - isExpanded: isExpanded, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - store: store, - terminalManager: terminalManager - ) + if repository.isWorkspace { + WorkspaceChildRowsView(rows: state.workspaceChildRows(in: repository)) + } else { + WorktreeRowsView( + repository: repository, + isExpanded: isExpanded, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs, + store: store, + terminalManager: terminalManager + ) + } } } .id(SidebarScrollID.repository(repository.id)) diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift new file mode 100644 index 00000000..53a46758 --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// Display-only rows for the child repositories of an expanded workspace. +/// Reuses `WorktreeRow` for visual parity with git worktree rows (branch icon, +/// `+N/-M` diff badge, PR tag), but deliberately omits selection, drag, context +/// menu, and tap handling — a workspace has a single root terminal and its +/// children are not independently runnable targets. +struct WorkspaceChildRowsView: View { + let rows: [WorkspaceChildRowModel] + + var body: some View { + ForEach(rows) { row in + WorktreeRow( + name: row.branchName ?? row.repositoryName, + worktreeName: row.branchName == nil ? "" : row.repositoryName, + info: row.info, + showsPullRequestInfo: true, + isHovered: false, + isPinned: false, + isMainWorktree: false, + isLoading: false, + taskStatus: nil, + isRunScriptRunning: false, + showsNotificationIndicator: false, + notifications: [], + onFocusNotification: { _ in }, + shortcutHint: nil, + showsShortcutHint: false, + pinAction: nil, + isSelected: false, + archiveAction: nil, + onDiffTap: nil, + onStopRunScript: nil, + ) + .padding(.leading, 14) + .padding(.trailing, 8) + .id(row.id) + } + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3d010394..89581455 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -545,6 +545,7 @@ struct RepositoriesFeatureTests { workspace: workspace ) + let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path(percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { @@ -560,6 +561,11 @@ struct RepositoriesFeatureTests { Issue.record("workspace should not load git worktrees: \(url.path(percentEncoded: false))") return [] } + // The workspace's child repository is refreshed via the child pipeline + // (live branch + diff), distinct from the worktree probing above. + $0.gitClient.branchName = { _ in "main" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { _ in nil } } await store.send(.loadPersistedRepositories) @@ -570,6 +576,9 @@ struct RepositoriesFeatureTests { $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.workspaceChildrenInfoLoaded) { + $0.workspaceChildBranchByID = [childID: "main"] + } await store.finish() } @@ -6056,6 +6065,118 @@ struct RepositoriesFeatureTests { #expect(store.state.canNavigateWorktreeHistoryBackward) } + // MARK: - Workspace child rows + + private func makeWorkspaceRepository( + id: String, + children: [ProjectWorkspace.RepositoryEntry] + ) -> Repository { + makeRepository( + id: id, + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: children) + ) + } + + @Test func applyWorkspaceChildrenInfoWritesBranchDiffAndPR() { + var state = RepositoriesFeature.State() + let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature") + applyWorkspaceChildrenInfo( + [ + WorkspaceChildInfoUpdate(id: "/ws/app", branch: "feature", added: 7, removed: 2, pullRequest: pullRequest), + WorkspaceChildInfoUpdate(id: "/ws/api", branch: " ", added: 0, removed: 0, pullRequest: nil), + ], + state: &state + ) + + #expect(state.workspaceChildBranchByID["/ws/app"] == "feature") + #expect(state.workspaceChildInfoByID["/ws/app"]?.addedLines == 7) + #expect(state.workspaceChildInfoByID["/ws/app"]?.removedLines == 2) + #expect(state.workspaceChildInfoByID["/ws/app"]?.pullRequest == pullRequest) + // Blank branch + empty diff + no PR → no entries. + #expect(state.workspaceChildBranchByID["/ws/api"] == nil) + #expect(state.workspaceChildInfoByID["/ws/api"] == nil) + } + + @Test func workspaceChildRowsMergesLiveBranchAndInfo() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws", children: [entry]) + var state = makeState(repositories: [repository]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + state.workspaceChildBranchByID[childID] = "live-branch" + state.workspaceChildInfoByID[childID] = WorktreeInfoEntry(addedLines: 3, removedLines: 1, pullRequest: nil) + + let rows = state.workspaceChildRows(in: repository) + + #expect(rows.count == 1) + #expect(rows.first?.repositoryName == "App") + // Live branch wins over the metadata branch. + #expect(rows.first?.branchName == "live-branch") + #expect(rows.first?.info?.addedLines == 3) + } + + @Test func workspaceChildRowsFallsBackToMetadataBranch() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .bareRepository, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws2", children: [entry]) + let state = makeState(repositories: [repository]) + + let rows = state.workspaceChildRows(in: repository) + #expect(rows.first?.branchName == "metadata-branch") + #expect(rows.first?.info == nil) + } + + @Test func repositoriesLoadedRefreshesAndPrunesWorkspaceChildren() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-refresh", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + var initialState = makeState(repositories: [repository]) + // A stale child entry from a workspace that no longer exists must be pruned. + initialState.workspaceChildInfoByID["/tmp/gone/app"] = WorktreeInfoEntry( + addedLines: 1, + removedLines: 1, + pullRequest: nil + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in (7, 2) } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send( + .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) + ) + await store.receive(\.workspaceChildrenInfoLoaded) + await store.finish() + + #expect(store.state.workspaceChildInfoByID["/tmp/gone/app"] == nil) + #expect(store.state.workspaceChildBranchByID[childID] == "feature/live") + #expect(store.state.workspaceChildInfoByID[childID]?.addedLines == 7) + #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) + } + private func makeWorktree( id: String, name: String, -- 2.51.2 From 7764b15125ae8ac779e1e38b3123825805298113 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Fri, 12 Jun 2026 15:24:05 +0800 Subject: [PATCH 458/528] Harden workspace branch deletion and risky cleanup - Route workspace removal's branch deletion through GitClient's guarded deleteLocalBranch so main/master/the default remote branch are never force-deleted - Move the workspace default-path filesystem scan out of the reducer body into an effect - Split workspace cleanup so a failed worktree removal asks before deleting the workspace folder instead of orphaning a live worktree registration - Drop /HEAD pointers from the base-ref picker so they cannot derive an invalid HEAD branch name - Let Use-Existing on a remote-tracking ref keep an existing same-named local branch or reset it, via an inline per-repository choice --- docs/components/workspaces.md | 27 +- supacode/Clients/Git/GitClient.swift | 4 + supacode/Domain/ProjectWorkspace.swift | 68 +++-- .../RepositoriesFeature+CoreReducer.swift | 14 + ...sitoriesFeature+RepositoryManagement.swift | 116 ++++++++- ...epositoriesFeature+WorkspaceCreation.swift | 47 +++- .../Reducer/RepositoriesFeature.swift | 9 + .../WorkspaceCreationPromptFeature.swift | 17 +- .../Views/WorkspaceCreationPromptView.swift | 22 ++ .../AppFeatureCommandPaletteTests.swift | 8 +- supacodeTests/GitClientBranchRefsTests.swift | 8 +- supacodeTests/ProjectWorkspaceTests.swift | 99 ++++++- supacodeTests/RepositoriesFeatureTests.swift | 245 +++++++++++++++++- 13 files changed, 616 insertions(+), 68 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 44f9e6bd..04c5a631 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -68,9 +68,10 @@ The creation prompt detects base-ref candidates for already opened, local, and bare repositories by reading local git refs, preferring the detected default branch such as `main` or `master`. Refs are grouped as local branches, remote tracking branches, or fetched remote branches, and the picker supports simple -text search. Remote default-branch pointers are shown explicitly, such as -`origin/HEAD`. Base refs are selected from detected refs so workspace creation -does not try to checkout an arbitrary, nonexistent branch. +text search. `/HEAD` symbolic pointers are omitted from the picker +because they only alias a branch that is already listed. Base refs are selected +from detected refs so workspace creation does not try to checkout an arbitrary, +nonexistent branch. Branch behavior is explicit: @@ -85,6 +86,12 @@ Branch behavior is explicit: `git worktree add --track -B feature`, which creates the local tracking branch — aligning a same-named local branch to the remote when one exists. Git refuses if that branch is already checked out in another worktree. + - When a remote-tracking ref is selected and a same-named local branch already + exists, the repository row shows a choice: **Use local branch** (the + default — checks out the existing local branch as-is) or **Reset to** the + remote ref (the `-B` behavior, which discards local-only commits on that + branch). This prevents an unnoticed reset of a local branch that is ahead of + the remote. ## Removing a workspace @@ -94,11 +101,15 @@ delete the workspace folder and its worktrees** to additionally unregister the worktrees that were created for this workspace from their source repositories (`git worktree remove --force`) and delete the workspace folder. Worktree entries with a recorded branch additionally offer a per-repository **Delete -branch** checkbox that removes the branch from the source repository -(`git branch -D`) after the worktree is gone. Linked repositories stay -untouched — only the symlinks inside the workspace folder are removed. Cleanup -is best-effort: a broken source repository is logged and skipped instead of -blocking the deletion. +branch** checkbox that removes the branch from the source repository after the +worktree is gone. Branch deletion goes through the same protected-branch guard +as the rest of Prowl, so `main`, `master`, and the repository's default remote +branch are never deleted even if they were recorded as a workspace branch. +Linked repositories stay untouched — only the symlinks inside the workspace +folder are removed. Cleanup is best-effort: a broken source repository is logged +and skipped instead of blocking the deletion. If a worktree cannot be +unregistered, Prowl asks before deleting the workspace folder, since deleting it +anyway would leave a dangling worktree registration in the source repository. ## Metadata diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 8034df41..7feb7433 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -162,6 +162,10 @@ struct GitClient { .filter { !$0.hasSuffix("/HEAD") } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } let remoteRefs = parseRefLines(remoteOutput, prefix: "refs/remotes/") + // Drop `/HEAD` symbolic pointers: they resolve to a branch that is + // already listed, and selecting one would derive an invalid `HEAD` branch + // name in the remote-tracking checkout path. + .filter { !$0.hasSuffix("/HEAD") } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } return deduplicatedOptions( localRefs.map { GitBranchRefOption(ref: $0, kind: .local) } diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 8f00f7bf..38f6be34 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -63,6 +63,10 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send var branchName: String? var baseRef: String? var baseRefOptions: [GitBranchRefOption] + // User choice when a Use-Existing checkout on a remote-tracking ref would + // reset an existing same-named local branch: false keeps the local branch + // (checks it out directly), true resets it to the remote ref. + var resetLocalBranchToRemote: Bool = false init( id: String, @@ -112,6 +116,27 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send sourceKind.localSourceURL(from: sourceLocation) } + // Non-nil when a Use-Existing checkout on a remote-tracking ref would reset an + // already-existing same-named local branch via `git worktree add -B`, so the + // user should choose between keeping the local branch and resetting it. + // Derived purely from the already-loaded ref options — no extra git lookup. + var resettableLocalBranchName: String? { + guard checkoutMode == .useExistingRef, + let selectedRef = baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + let kind = baseRefOptions.first(where: { $0.ref == selectedRef })?.kind, + kind != .local + else { + return nil + } + let localName = selectedRef.split(separator: "/").dropFirst().joined(separator: "/") + guard !localName.isEmpty, + baseRefOptions.contains(where: { $0.kind == .local && $0.ref == localName }) + else { + return nil + } + return localName + } + nonisolated static func baseRefOptions( automaticBaseRef: String?, options: [GitBranchRefOption] @@ -558,16 +583,24 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { await task.value } - // Best-effort removal cleanup: every failure is logged and the remaining - // entries are still processed so a broken repository cannot block deletion. - static func cleanup( + // Best-effort worktree removal for a workspace being deleted. Every failure + // is logged and the remaining entries are still processed so a broken + // repository cannot block deletion. Returns the display names of entries + // whose worktree could not be unregistered, so the caller can decide whether + // to still delete the workspace folder — deleting it unconditionally would + // leave a dangling worktree registration in the source repository. + // + // Branch deletion is intentionally NOT performed here: the caller routes it + // through GitClient's guarded entry point so protected branches + // (main/master/default) can never be force-deleted. + static func removeWorktrees( _ workspace: ProjectWorkspace, rootURL: URL, - deleteBranchEntryIDs: Set = [], fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner - ) async { + ) async -> [String] { let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) + var failedRemovals: [String] = [] for entry in workspace.repositories { let entryURL = entry.resolvedURL(relativeTo: rootURL) let entryPath = entryURL.path(percentEncoded: false) @@ -588,22 +621,19 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { ) } catch { log.warning("Workspace cleanup could not unregister worktree at \(entryPath): \(error)") - } - // Branch deletion must follow the worktree removal — git refuses to - // delete a branch that still has a checkout. - if deleteBranchEntryIDs.contains(entry.id), let branchName = entry.branchName { - do { - try await gitRunner.run( - ProjectWorkspaceGitCommand( - arguments: ["-C", sourceLocation, "branch", "-D", branchName], - currentDirectoryURL: nil - ) - ) - } catch { - log.warning("Workspace cleanup could not delete branch \(branchName): \(error)") - } + failedRemovals.append(entry.name) } } + return failedRemovals + } + + // Deletes the workspace folder itself. Separate from `removeWorktrees` so the + // caller can gate it on whether every worktree was successfully unregistered. + static func removeWorkspaceFolder( + at rootURL: URL, + fileManager: FileManager = .default + ) { + let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) do { try fileManager.removeItem(at: rootURL) } catch { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 17ae0feb..4ba089f4 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -763,6 +763,20 @@ extension RepositoriesFeature { return .send( .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) + case .alert(.presented(.confirmWorkspaceRootDeletion(let repositoryID, let rootPath, let selectionWasRemoved))): + state.alert = nil + let rootURL = URL(fileURLWithPath: rootPath) + return .run { send in + ProjectWorkspace.removeWorkspaceFolder(at: rootURL) + await send( + .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + } + + case .alert(.presented(.keepWorkspaceFolderAfterCleanupFailure(let repositoryID, let selectionWasRemoved))): + state.alert = nil + return .send( + .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + case .presentAlert(let title, let message): state.alert = messageAlert(title: title, message: message) return .none diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 0a2b744c..c128b742 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -229,23 +229,81 @@ extension RepositoriesFeature { ) ) } + let repositoryID = repository.id let rootURL = repository.rootURL - let deleteBranchEntryIDs = Set(confirmation.branchOptions.filter(\.isSelected).map(\.id)) + // Resolve the (source repo, branch) pairs the user opted to delete. The + // deletion itself is routed through GitClient's guarded entry point so a + // protected branch (main/master/default) can never be force-deleted. + let branchDeletions: [WorkspaceBranchDeletion] = + confirmation.branchOptions + .filter(\.isSelected) + .compactMap { option in + guard let entry = workspace.repositories.first(where: { $0.id == option.id }), + let sourceLocation = entry.sourceLocation, + let branchName = entry.branchName + else { + return nil + } + return WorkspaceBranchDeletion(sourceLocation: sourceLocation, branchName: branchName) + } let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) + let gitClient = self.gitClient return .run { send in - await ProjectWorkspace.cleanup( + let failedRepositoryNames = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, - deleteBranchEntryIDs: deleteBranchEntryIDs, gitRunner: gitRunner ) - await send( - .repositoryManagement( - .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved) + for deletion in branchDeletions { + let outcome = try? await gitClient.deleteLocalBranch( + deletion.branchName, + URL(fileURLWithPath: deletion.sourceLocation), + true ) - ) + if case .protected? = outcome { + workspaceRemovalLog.warning( + "Skipped deleting protected branch \(deletion.branchName) in \(deletion.sourceLocation)" + ) + } + } + // Only delete the workspace folder when every worktree was unregistered; + // otherwise ask the user, since deleting it would orphan a live worktree + // registration in the source repository. + if failedRepositoryNames.isEmpty { + ProjectWorkspace.removeWorkspaceFolder(at: rootURL) + await send( + .repositoryManagement( + .repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved) + ) + ) + } else { + await send( + .repositoryManagement( + .workspaceCleanupReportedFailures( + repositoryID: repositoryID, + rootPath: rootURL.path(percentEncoded: false), + failedRepositoryNames: failedRepositoryNames, + selectionWasRemoved: selectionWasRemoved + ) + ) + ) + } } + case .workspaceCleanupReportedFailures( + let repositoryID, + let rootPath, + let failedRepositoryNames, + let selectionWasRemoved + ): + state.alert = workspaceCleanupFailureAlert( + repositoryID: repositoryID, + rootPath: rootPath, + failedRepositoryNames: failedRepositoryNames, + selectionWasRemoved: selectionWasRemoved + ) + return .none + case .removeFailedRepository(let repositoryID): state.alert = nil state.loadFailuresByID.removeValue(forKey: repositoryID) @@ -312,6 +370,50 @@ extension RepositoriesFeature { return reduceRepositoryManagement(state: &state, action: action) } } + + func workspaceCleanupFailureAlert( + repositoryID: Repository.ID, + rootPath: String, + failedRepositoryNames: [String], + selectionWasRemoved: Bool + ) -> AlertState { + AlertState { + TextState("Some worktrees couldn't be removed") + } actions: { + ButtonState( + role: .destructive, + action: .confirmWorkspaceRootDeletion( + repositoryID: repositoryID, + rootPath: rootPath, + selectionWasRemoved: selectionWasRemoved + ) + ) { + TextState("Delete Folder Anyway") + } + ButtonState( + role: .cancel, + action: .keepWorkspaceFolderAfterCleanupFailure( + repositoryID: repositoryID, + selectionWasRemoved: selectionWasRemoved + ) + ) { + TextState("Keep Folder") + } + } message: { + TextState( + "Couldn't unregister worktrees for \(failedRepositoryNames.joined(separator: ", ")). " + + "Deleting the workspace folder now would leave those worktrees registered in their " + + "source repositories. Delete it anyway?" + ) + } + } +} + +nonisolated private let workspaceRemovalLog = SupaLogger("workspace") + +nonisolated struct WorkspaceBranchDeletion: Sendable, Equatable { + let sourceLocation: String + let branchName: String } // Path-based URL APIs append a trailing slash only while the directory exists diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 6747f95f..7b0aa5a6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -34,12 +34,29 @@ extension RepositoriesFeature { case .promptRequested: let candidates = state.workspaceCreationCandidates let title = "Workspace" + // Seed with the collision-free base path synchronously (pure path math), + // then resolve a unique, non-colliding folder name off the main actor so + // the reducer body performs no filesystem I/O. + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + let requestedRootPath = Self.workspaceRootPath(folderName: folderName, suffix: nil) state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: defaultWorkspaceRootURL(title: title).path(percentEncoded: false), + rootPath: requestedRootPath, openedRepositoryCandidates: candidates ) + return .run { send in + let resolved = Self.uniqueWorkspaceRootPath(folderName: folderName) + await send(.workspaceCreation(.defaultRootPathResolved(path: resolved, requestedRootPath: requestedRootPath))) + } + + case .defaultRootPathResolved(let path, let requestedRootPath): + // Only adopt the de-duplicated path if the user has not edited the field + // since the prompt opened. + guard state.workspaceCreationPrompt?.rootPath == requestedRootPath else { + return .none + } + state.workspaceCreationPrompt?.rootPath = path return .none case .promptCanceled, .promptDismissed: @@ -123,16 +140,26 @@ extension RepositoriesFeature { } } - private func defaultWorkspaceRootURL(title: String) -> URL { - let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) - let baseURL = SupacodePaths.workspacesDirectory - var candidateURL = baseURL.appending(path: folderName, directoryHint: .isDirectory) - var suffix = 2 - while FileManager.default.fileExists(atPath: candidateURL.path(percentEncoded: false)) { - candidateURL = baseURL.appending(path: "\(folderName)-\(suffix)", directoryHint: .isDirectory) - suffix += 1 + nonisolated private static func workspaceRootPath(folderName: String, suffix: Int?) -> String { + let component = suffix.map { "\(folderName)-\($0)" } ?? folderName + return SupacodePaths.workspacesDirectory + .appending(path: component, directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + } + + // Resolves a workspace folder path that does not collide with an existing + // directory. Touches the filesystem, so it must run inside an effect rather + // than the reducer body. + nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { + var suffix: Int? + while true { + let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) + if !FileManager.default.fileExists(atPath: candidate) { + return candidate + } + suffix = (suffix ?? 1) + 1 } - return candidateURL.standardizedFileURL } private static func trimmedNonEmpty(_ value: String?) -> String? { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 0aee3c16..6e2f4de4 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -240,6 +240,12 @@ struct RepositoriesFeature { case removeWorkspaceDeleteBranchChanged(String, Bool) case removeWorkspacePromptDismissed case removeWorkspacePromptConfirmed + case workspaceCleanupReportedFailures( + repositoryID: Repository.ID, + rootPath: String, + failedRepositoryNames: [String], + selectionWasRemoved: Bool + ) case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) case openRepositorySettings(Repository.ID) } @@ -247,6 +253,7 @@ struct RepositoriesFeature { @CasePathable enum WorkspaceCreationAction: Equatable { case promptRequested + case defaultRootPathResolved(path: String, requestedRootPath: String) case promptCanceled case promptDismissed case refreshBaseRefs(Repository.ID) @@ -479,6 +486,8 @@ struct RepositoriesFeature { case confirmArchiveWorktrees([ArchiveWorktreeTarget]) case confirmForceDeleteBranch(ForceDeleteBranchRequest) case confirmRemoveRepository(Repository.ID) + case confirmWorkspaceRootDeletion(repositoryID: Repository.ID, rootPath: String, selectionWasRemoved: Bool) + case keepWorkspaceFolderAfterCleanupFailure(repositoryID: Repository.ID, selectionWasRemoved: Bool) } enum PullRequestAction: Equatable { diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 82b7277a..888f378d 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -74,6 +74,7 @@ struct WorkspaceCreationPromptFeature { case repositorySourceLocationChanged(Repository.ID, String) case repositoryBranchNameChanged(Repository.ID, String) case repositoryBaseRefChanged(Repository.ID, String) + case repositoryResetLocalBranchChanged(Repository.ID, Bool) case rootPathChosen(String) case cancelButtonTapped case createButtonTapped @@ -326,10 +327,17 @@ struct WorkspaceCreationPromptFeature { return .none } repository.baseRef = trimmed.isEmpty ? nil : trimmed + // A new ref selection invalidates any previous keep/reset choice. + repository.resetLocalBranchToRemote = false state.repositories[id: repositoryID] = repository state.validationMessage = nil return .none + case .repositoryResetLocalBranchChanged(let repositoryID, let resetToRemote): + state.repositories[id: repositoryID]?.resetLocalBranchToRemote = resetToRemote + state.validationMessage = nil + return .none + case .rootPathChosen(let path): state.rootPath = path state.validationMessage = nil @@ -432,7 +440,14 @@ struct WorkspaceCreationPromptFeature { guard !branchName.isEmpty else { return .failure(.missingExistingRef(displayName)) } - checkout = .trackRemoteRef(remoteRef: baseRef, branchName: branchName) + if let localBranchName = repository.resettableLocalBranchName, !repository.resetLocalBranchToRemote { + // A same-named local branch already exists and the user chose to keep + // it: check out the local branch directly rather than resetting it to + // the remote ref with `-B`, which would discard local-only commits. + checkout = .useExistingRef(localBranchName) + } else { + checkout = .trackRemoteRef(remoteRef: baseRef, branchName: branchName) + } } } return .success( diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 109aadfb..5145608d 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -309,6 +309,28 @@ struct WorkspaceCreationPromptView: View { store.send(.repositoryBaseRefChanged(repository.id, ref)) } .disabled(store.isCreating || repository.baseRefOptions.isEmpty) + + if let localBranchName = repository.resettableLocalBranchName { + VStack(alignment: .leading, spacing: 4) { + Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Picker( + "Local branch “\(localBranchName)”", + selection: Binding( + get: { repository.resetLocalBranchToRemote }, + set: { store.send(.repositoryResetLocalBranchChanged(repository.id, $0)) } + ) + ) { + Text("Use local branch").tag(false) + Text("Reset to \(repository.baseRef ?? "remote")").tag(true) + } + .pickerStyle(.radioGroup) + .labelsHidden() + .disabled(store.isCreating) + } + } } } } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 85121f31..796fab14 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -374,16 +374,18 @@ struct AppFeatureCommandPaletteTests { AppFeature() } + let title = "Workspace" await store.send(.commandPalette(.delegate(.newWorkspace))) await store.receive(\.repositories.workspaceCreation.promptRequested) { - let title = "Workspace" - let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: expectedRootPath + rootPath: defaultWorkspaceBaseRootPath(for: title) ) } + await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) { + $0.repositories.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + } } @Test(.dependencies) func refreshWorktreesDispatchesRefresh() async { diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index 308159b1..b2f1bafb 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -80,7 +80,7 @@ struct GitClientBranchRefsTests { #expect(calls[1].contains("refs/remotes")) } - @Test func branchRefOptionsSpellRemoteHeadExplicitly() async throws { + @Test func branchRefOptionsOmitsRemoteHeadPointer() async throws { let shell = ShellClient( run: { _, arguments, _ in if arguments.contains("refs/heads") { @@ -101,8 +101,10 @@ struct GitClientBranchRefsTests { let options = try await client.branchRefOptions(for: repoRoot) #expect(refs == ["main", "origin/main"]) - #expect(options.contains(GitBranchRefOption(ref: "origin/HEAD", kind: .remoteTracking))) - #expect(!options.contains(GitBranchRefOption(ref: "origin", kind: .remoteTracking))) + // `/HEAD` pointers must be filtered so the remote-tracking checkout + // path never derives an invalid `HEAD` branch name. + #expect(!options.contains(GitBranchRefOption(ref: "origin/HEAD", kind: .remoteTracking))) + #expect(options.contains(GitBranchRefOption(ref: "origin/main", kind: .remoteTracking))) } @Test func remoteBranchRefsParsesDefaultHeadAndBranches() async throws { diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 332bc025..67876e07 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -422,6 +422,50 @@ struct ProjectWorkspaceTests { ) } + @Test func planKeepsExistingLocalBranchWhenNotResetting() { + let repository = ProjectWorkspaceCreationRepository( + id: "maker", + name: "maker.git", + sourceKind: .bareRepository, + sourceLocation: "/tmp/maker.git", + checkoutMode: .useExistingRef, + baseRef: "origin/chore/x", + baseRefOptions: [ + GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking), + GitBranchRefOption(ref: "chore/x", kind: .local), + ] + ) + + // A same-named local branch exists and the user keeps it (default): check + // out the local branch directly instead of resetting it to the remote. + #expect(repository.resettableLocalBranchName == "chore/x") + #expect( + WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + == .success(.useExistingRef("chore/x")) + ) + } + + @Test func planResetsLocalBranchToRemoteWhenChosen() { + var repository = ProjectWorkspaceCreationRepository( + id: "maker", + name: "maker.git", + sourceKind: .bareRepository, + sourceLocation: "/tmp/maker.git", + checkoutMode: .useExistingRef, + baseRef: "origin/chore/x", + baseRefOptions: [ + GitBranchRefOption(ref: "origin/chore/x", kind: .remoteTracking), + GitBranchRefOption(ref: "chore/x", kind: .local), + ] + ) + repository.resetLocalBranchToRemote = true + + #expect( + WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) + ) + } + @Test func defaultRepositoryNameStripsGitSuffix() { #expect( WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker.git")) @@ -715,7 +759,7 @@ struct ProjectWorkspaceTests { ) let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) - await ProjectWorkspace.cleanup( + let failures = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { command in @@ -723,15 +767,19 @@ struct ProjectWorkspaceTests { } ) + #expect(failures.isEmpty) #expect( commands.value.map(\.arguments) == [ ["-C", bareURL.path(percentEncoded: false), "worktree", "remove", "--force", apiPath] ]) + // removeWorktrees must leave the folder and the linked source intact. + #expect(FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + ProjectWorkspace.removeWorkspaceFolder(at: rootURL) #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) #expect(FileManager.default.fileExists(atPath: linkedSourceURL.path(percentEncoded: false))) } - @Test func cleanupDeletesSelectedBranchesAfterWorktreeRemoval() async throws { + @Test func removeWorktreesDoesNotDeleteBranches() async throws { let rootURL = try makeTemporaryWorkspaceRoot() let bareURL = try makeTemporaryWorkspaceRoot() defer { @@ -757,22 +805,59 @@ struct ProjectWorkspaceTests { ) let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) - await ProjectWorkspace.cleanup( + let failures = await ProjectWorkspace.removeWorktrees( workspace, rootURL: rootURL, - deleteBranchEntryIDs: ["api"], gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } } ) let barePath = bareURL.path(percentEncoded: false) + #expect(failures.isEmpty) + // Branch deletion is routed through GitClient's guarded entry point, never + // the workspace git runner — so no `branch -D` is issued here. #expect( commands.value.map(\.arguments) == [ - ["-C", barePath, "worktree", "remove", "--force", apiPath], - ["-C", barePath, "branch", "-D", "chore/x"], + ["-C", barePath, "worktree", "remove", "--force", apiPath] ]) - #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + } + + @Test func removeWorktreesReportsFailedRemovals() async throws { + let rootURL = try makeTemporaryWorkspaceRoot() + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + branchName: "chore/x" + ) + ] + ) + let failures = await ProjectWorkspace.removeWorktrees( + workspace, + rootURL: rootURL, + gitRunner: ProjectWorkspaceGitRunner { _ in + throw ProjectWorkspaceCreationError.gitCommandFailed(command: "git worktree remove", message: "broken") + } + ) + + #expect(failures == ["API"]) + // A failed worktree removal must not delete the folder. + #expect(FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) } @Test func listRuntimeContextsReportWorkspaceKind() { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 89581455..7989d8c9 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -588,13 +588,12 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } + let title = "Workspace" await store.send(.workspaceCreation(.promptRequested)) { - let title = "Workspace" - let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: expectedRootPath, + rootPath: defaultWorkspaceBaseRootPath(for: title), openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: "/tmp/repo-a", @@ -604,6 +603,9 @@ struct RepositoriesFeatureTests { ] ) } + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + } } @Test func workspaceCreationPromptAddsOpenedRepositoryOnRequest() async { @@ -630,6 +632,40 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(repoRootA))) } + @Test func resetLocalBranchChoiceTogglesAndClearsOnRefChange() async { + let repoID = "/tmp/repo-a" + let repo = ProjectWorkspaceCreationRepository( + id: repoID, + name: "Repo A", + sourceKind: .bareRepository, + sourceLocation: "/tmp/repo-a.git", + checkoutMode: .useExistingRef, + baseRef: "origin/feature", + baseRefOptions: [ + GitBranchRefOption(ref: "origin/feature", kind: .remoteTracking), + GitBranchRefOption(ref: "feature", kind: .local), + ] + ) + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [repo], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + ) { + WorkspaceCreationPromptFeature() + } + + await store.send(.repositoryResetLocalBranchChanged(repoID, true)) { + $0.repositories[id: repoID]?.resetLocalBranchToRemote = true + } + // Selecting a different ref invalidates the keep/reset choice. + await store.send(.repositoryBaseRefChanged(repoID, "feature")) { + $0.repositories[id: repoID]?.baseRef = "feature" + $0.repositories[id: repoID]?.resetLocalBranchToRemote = false + } + } + @Test func workspaceCreationPromptIgnoresDuplicateOpenedRepository() async { let repoRootA = "/tmp/repo-a" let candidate = ProjectWorkspaceCreationRepository( @@ -664,13 +700,12 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } + let title = "Workspace" await store.send(.workspaceCreation(.promptRequested)) { - let title = "Workspace" - let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: expectedRootPath, + rootPath: defaultWorkspaceBaseRootPath(for: title), openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -685,6 +720,9 @@ struct RepositoriesFeatureTests { ] ) } + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + } } @Test func workspaceCreationPromptLoadsBaseRefsForAddedOpenedRepository() async { @@ -716,13 +754,12 @@ struct RepositoriesFeatureTests { } } + let title = "Workspace" await store.send(.workspaceCreation(.promptRequested)) { - let title = "Workspace" - let expectedRootPath = expectedDefaultWorkspaceRootPath(for: title) $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: expectedRootPath, + rootPath: defaultWorkspaceBaseRootPath(for: title), openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -737,6 +774,9 @@ struct RepositoriesFeatureTests { ] ) } + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + } await store.send(.workspaceCreationPrompt(.presented(.addOpenedRepository(repoRootA)))) { $0.workspaceCreationPrompt?.repositories.append( ProjectWorkspaceCreationRepository( @@ -1230,6 +1270,181 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func removeWorkspaceRoutesBranchDeletionThroughGuardedClient() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-ws-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let workspaceID = rootURL.path(percentEncoded: false) + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git", + branchName: "chore/x" + ) + ] + ) + let repository = makeRepository( + id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace + ) + struct DeleteBranchCall: Sendable, Equatable { + let name: String + let root: String + let force: Bool + } + let deleteCalls = LockIsolated<[DeleteBranchCall]>([]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.gitClient.deleteLocalBranch = { name, url, force in + deleteCalls.withValue { + $0.append(DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) + } + return .deleted + } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) + await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) + await store.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged("api", true))) + await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) + await store.receive(\.repositoryManagement.repositoryRemoved) + await store.finish() + + // Branch deletion must go through the guarded GitClient entry point with + // force = true, never a raw `git branch -D`. + #expect(deleteCalls.value.count == 1) + #expect(deleteCalls.value.first?.name == "chore/x") + #expect(deleteCalls.value.first?.root == "/tmp/api.git") + #expect(deleteCalls.value.first?.force == true) + // All worktrees unregistered, so the folder is deleted without prompting. + #expect(!FileManager.default.fileExists(atPath: workspaceID)) + } + + @Test func removeWorkspaceAsksBeforeDeletingFolderWhenWorktreeRemovalFails() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-ws-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let workspaceID = rootURL.path(percentEncoded: false) + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git" + ) + ] + ) + let repository = makeRepository( + id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.shellClient.run = { _, _, _ in + throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) + } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) + await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) + await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) + await store.receive(\.repositoryManagement.workspaceCleanupReportedFailures) + + // The folder must NOT be deleted yet — the user is asked first. + #expect(store.state.alert != nil) + #expect(FileManager.default.fileExists(atPath: workspaceID)) + + await store.send( + .alert( + .presented( + .confirmWorkspaceRootDeletion( + repositoryID: workspaceID, + rootPath: workspaceID, + selectionWasRemoved: false + )))) + await store.receive(\.repositoryManagement.repositoryRemoved) + await store.finish() + + #expect(!FileManager.default.fileExists(atPath: workspaceID)) + } + + @Test func keepWorkspaceFolderAfterCleanupFailureRemovesEntryButKeepsFolder() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-ws-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let workspaceID = rootURL.path(percentEncoded: false) + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git" + ) + ] + ) + let repository = makeRepository( + id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.shellClient.run = { _, _, _ in + throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) + } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) + await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) + await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) + await store.receive(\.repositoryManagement.workspaceCleanupReportedFailures) + + await store.send( + .alert( + .presented( + .keepWorkspaceFolderAfterCleanupFailure( + repositoryID: workspaceID, + selectionWasRemoved: false + )))) + await store.receive(\.repositoryManagement.repositoryRemoved) + await store.finish() + + // Entry removed from Prowl, but the on-disk folder is preserved. + #expect(FileManager.default.fileExists(atPath: workspaceID)) + } + @Test func repositoryRemovedDropsEntryDespiteTrailingSlashMismatch() async { let repository = makeRepository( id: "/tmp/ws/", @@ -6446,7 +6661,17 @@ struct RepositoriesFeatureTests { } } -// Mirrors RepositoriesFeature.defaultWorkspaceRootURL: the default folder name +// The collision-free base path seeded synchronously by `.promptRequested` +// before the unique-path effect resolves (no filesystem lookup). +func defaultWorkspaceBaseRootPath(for title: String) -> String { + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + return SupacodePaths.workspacesDirectory + .appending(path: folderName, directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) +} + +// Mirrors RepositoriesFeature.uniqueWorkspaceRootPath: the default folder name // is uniqued against directories that already exist on the test machine. func expectedDefaultWorkspaceRootPath(for title: String) -> String { let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) -- 2.51.2 From 160a3329f8f9f879191f7411c360f20742b0deb5 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Mon, 15 Jun 2026 14:48:16 +0800 Subject: [PATCH 459/528] Fix workspace child PR refresh after GitHub account changes --- .../Reducer/RepositoriesFeature+WorkspaceChildren.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index 419b687f..abda4dfb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -86,7 +86,8 @@ extension RepositoriesFeature { remote.host, remote.owner, remote.repo, - [branch] + [branch], + nil ) return pullRequestsByBranch?[branch] } -- 2.51.2 From 133db9418427fc7e4970e7ca4b8f930dc8858c29 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Mon, 15 Jun 2026 15:44:43 +0800 Subject: [PATCH 460/528] Fix workspace prompt path tests --- .../AppFeatureCommandPaletteTests.swift | 12 +++++-- supacodeTests/RepositoriesFeatureTests.swift | 36 ++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 796fab14..53efde3d 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -375,16 +375,22 @@ struct AppFeatureCommandPaletteTests { } let title = "Workspace" + let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.commandPalette(.delegate(.newWorkspace))) await store.receive(\.repositories.workspaceCreation.promptRequested) { $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: defaultWorkspaceBaseRootPath(for: title) + rootPath: requestedRootPath ) } - await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) { - $0.repositories.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + if resolvedRootPath == requestedRootPath { + await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) + } else { + await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) { + $0.repositories.workspaceCreationPrompt?.rootPath = resolvedRootPath + } } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 7989d8c9..6ebb1a9b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -589,11 +589,13 @@ struct RepositoriesFeatureTests { } let title = "Workspace" + let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: defaultWorkspaceBaseRootPath(for: title), + rootPath: requestedRootPath, openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: "/tmp/repo-a", @@ -603,8 +605,12 @@ struct RepositoriesFeatureTests { ] ) } - await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + if resolvedRootPath == requestedRootPath { + await store.receive(\.workspaceCreation.defaultRootPathResolved) + } else { + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + } } } @@ -701,11 +707,13 @@ struct RepositoriesFeatureTests { } let title = "Workspace" + let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: defaultWorkspaceBaseRootPath(for: title), + rootPath: requestedRootPath, openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -720,8 +728,12 @@ struct RepositoriesFeatureTests { ] ) } - await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + if resolvedRootPath == requestedRootPath { + await store.receive(\.workspaceCreation.defaultRootPathResolved) + } else { + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + } } } @@ -755,11 +767,13 @@ struct RepositoriesFeatureTests { } let title = "Workspace" + let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, - rootPath: defaultWorkspaceBaseRootPath(for: title), + rootPath: requestedRootPath, openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -774,8 +788,12 @@ struct RepositoriesFeatureTests { ] ) } - await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = expectedDefaultWorkspaceRootPath(for: title) + if resolvedRootPath == requestedRootPath { + await store.receive(\.workspaceCreation.defaultRootPathResolved) + } else { + await store.receive(\.workspaceCreation.defaultRootPathResolved) { + $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + } } await store.send(.workspaceCreationPrompt(.presented(.addOpenedRepository(repoRootA)))) { $0.workspaceCreationPrompt?.repositories.append( -- 2.51.2 From b663797b4d27b6fa1a5eb456ed2d284f143d2f0a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 15 Jun 2026 22:14:27 +0900 Subject: [PATCH 461/528] Fix PR refresh across fork remotes --- docs/components/github-pull-requests.md | 5 + supacode/Clients/Git/GitClient.swift | 83 +++++- .../Clients/Github/GithubRemoteInfo.swift | 6 +- .../Repositories/GitClientDependency.swift | 4 + .../PullRequestRefreshCoordinator.swift | 237 +++++++++++++----- ...epositoriesFeature+GithubIntegration.swift | 87 ++++--- ...atchedPullRequestRefreshReducerTests.swift | 24 +- supacodeTests/GitRemoteInfoTests.swift | 21 ++ .../PullRequestRefreshCoordinatorTests.swift | 41 +++ 9 files changed, 396 insertions(+), 112 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index 4387e8ae..b2e209fa 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -14,6 +14,11 @@ with a worktree's branch and exposes its status and actions. It works through th **`gh` CLI**, so it uses your existing `gh auth` — Prowl never handles tokens itself. +If a repository has multiple GitHub remotes, Prowl checks each remote for a PR on +the worktree branch. `upstream` is preferred, other named remotes come next, and +`origin` is used as the fallback, so fork-based worktrees can show upstream PRs +without changing `origin` or restarting the app. + ## What it shows - PR number, title, state (open/closed/merged), draft status. diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 98b735e6..96a418c2 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -462,6 +462,20 @@ struct GitClient { await remoteWebInfo(for: repositoryRoot)?.repositoryURL } + nonisolated func githubRemoteInfos(for repositoryRoot: URL) async -> [GithubRemoteInfo] { + let candidates = await remoteWebCandidates(for: repositoryRoot).compactMap { + candidate -> ( + name: String, + info: GithubRemoteInfo + )? in + guard let info = Self.parseGithubRemoteInfo(candidate.info) else { + return nil + } + return (name: candidate.name, info: info) + } + return Self.prioritizedGithubRemoteInfos(candidates) + } + nonisolated func remoteInfo(for repositoryRoot: URL) async -> GithubRemoteInfo? { guard let remoteWebInfo = await remoteWebInfo(for: repositoryRoot) else { return nil @@ -470,6 +484,13 @@ struct GitClient { } nonisolated private func remoteWebInfo(for repositoryRoot: URL) async -> GitRemoteWebInfo? { + let candidates = await remoteWebCandidates(for: repositoryRoot) + return Self.originFirstRemoteWebCandidates(candidates).first?.info + } + + nonisolated private func remoteWebCandidates( + for repositoryRoot: URL + ) async -> [(name: String, info: GitRemoteWebInfo)] { let path = repositoryRoot.path(percentEncoded: false) guard let remotesOutput = try? await runGit( @@ -477,20 +498,15 @@ struct GitClient { arguments: ["-C", path, "remote"] ) else { - return nil + return [] } let remotes = remotesOutput .split(whereSeparator: \.isNewline) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - let orderedRemotes: [String] - if remotes.contains("origin") { - orderedRemotes = ["origin"] + remotes.filter { $0 != "origin" } - } else { - orderedRemotes = remotes - } - for remote in orderedRemotes { + var candidates: [(name: String, info: GitRemoteWebInfo)] = [] + for remote in remotes { guard let remoteURL = try? await runGit( operation: .remoteInfo, @@ -500,10 +516,59 @@ struct GitClient { continue } if let info = Self.parseRepositoryWebInfo(remoteURL) { + candidates.append((name: remote, info: info)) + } + } + return candidates + } + + nonisolated private static func originFirstRemoteWebCandidates( + _ candidates: [(name: String, info: GitRemoteWebInfo)] + ) -> [(name: String, info: GitRemoteWebInfo)] { + guard candidates.contains(where: { $0.name == "origin" }) else { + return candidates + } + return candidates.filter { $0.name == "origin" } + candidates.filter { $0.name != "origin" } + } + + nonisolated static func prioritizedGithubRemoteInfos( + _ candidates: [(name: String, info: GithubRemoteInfo)] + ) -> [GithubRemoteInfo] { + var seen = Set() + return + candidates + .enumerated() + .sorted { lhs, rhs in + let lhsPriority = githubPullRequestRemotePriority(lhs.element.name) + let rhsPriority = githubPullRequestRemotePriority(rhs.element.name) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs.offset < rhs.offset + } + .compactMap { entry in + let info = entry.element.info + let key = [ + info.host.lowercased(), + info.owner.lowercased(), + info.repo.lowercased(), + ].joined(separator: "/") + guard seen.insert(key).inserted else { + return nil + } return info } + } + + nonisolated private static func githubPullRequestRemotePriority(_ name: String) -> Int { + switch name.lowercased() { + case "upstream": + 0 + case "origin": + 2 + default: + 1 } - return nil } nonisolated func remoteNames(for repoRoot: URL) async throws -> [String] { diff --git a/supacode/Clients/Github/GithubRemoteInfo.swift b/supacode/Clients/Github/GithubRemoteInfo.swift index 5d855049..e8f7dd75 100644 --- a/supacode/Clients/Github/GithubRemoteInfo.swift +++ b/supacode/Clients/Github/GithubRemoteInfo.swift @@ -1,7 +1,11 @@ import Foundation -struct GithubRemoteInfo: Equatable, Sendable { +nonisolated struct GithubRemoteInfo: Equatable, Sendable { let host: String let owner: String let repo: String + + nonisolated var key: RepoKey { + RepoKey(owner: owner, repo: repo) + } } diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index e52ab0ff..ecf17ee7 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -32,6 +32,7 @@ struct GitClientDependency: Sendable { var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? var renameBranch: @Sendable (_ worktreeURL: URL, _ branchName: String) async throws -> Void var repositoryWebURL: @Sendable (_ repositoryRoot: URL) async -> URL? + var githubRemoteInfos: @Sendable (_ repositoryRoot: URL) async -> [GithubRemoteInfo] var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void @@ -80,6 +81,9 @@ extension GitClientDependency: DependencyKey { repositoryWebURL: { repositoryRoot in await GitClient().repositoryWebURL(for: repositoryRoot) }, + githubRemoteInfos: { repositoryRoot in + await GitClient().githubRemoteInfos(for: repositoryRoot) + }, remoteInfo: { repositoryRoot in await GitClient().remoteInfo(for: repositoryRoot) }, diff --git a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift index 824463ed..ba7a1ce6 100644 --- a/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift +++ b/supacode/Features/Repositories/BusinessLogic/PullRequestRefreshCoordinator.swift @@ -7,11 +7,62 @@ final class PullRequestRefreshCoordinator { let repositoryID: Repository.ID let repositoryRootURL: URL let host: String - let owner: String - let repo: String + let repositories: [GithubRemoteInfo] let accountOverride: GithubAccountOverride? let branches: [String] let worktreeIDs: [Worktree.ID] + + var owner: String { + repositories.first?.owner ?? "" + } + + var repo: String { + repositories.first?.repo ?? "" + } + + init( + repositoryID: Repository.ID, + repositoryRootURL: URL, + host: String, + owner: String, + repo: String, + accountOverride: GithubAccountOverride?, + branches: [String], + worktreeIDs: [Worktree.ID] + ) { + self.init( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + host: host, + repositories: [GithubRemoteInfo(host: host, owner: owner, repo: repo)], + accountOverride: accountOverride, + branches: branches, + worktreeIDs: worktreeIDs + ) + } + + init( + repositoryID: Repository.ID, + repositoryRootURL: URL, + host: String, + repositories: [GithubRemoteInfo], + accountOverride: GithubAccountOverride?, + branches: [String], + worktreeIDs: [Worktree.ID] + ) { + self.repositoryID = repositoryID + self.repositoryRootURL = repositoryRootURL + self.host = host + self.repositories = Self.deduplicateRepositories(repositories) + self.accountOverride = accountOverride + self.branches = branches + self.worktreeIDs = worktreeIDs + } + + private static func deduplicateRepositories(_ repositories: [GithubRemoteInfo]) -> [GithubRemoteInfo] { + var seen = Set() + return repositories.filter { seen.insert($0.key).inserted } + } } nonisolated enum Outcome: Sendable, Equatable { @@ -65,19 +116,21 @@ final class PullRequestRefreshCoordinator { let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } - guard !cleanedBranches.isEmpty else { + guard !cleanedBranches.isEmpty, !request.repositories.isEmpty else { return } let normalized = Request( repositoryID: request.repositoryID, repositoryRootURL: request.repositoryRootURL, host: request.host, - owner: request.owner, - repo: request.repo, + repositories: request.repositories.filter { $0.host == request.host }, accountOverride: request.accountOverride, branches: cleanedBranches, worktreeIDs: request.worktreeIDs ) + guard !normalized.repositories.isEmpty else { + return + } let key = BatchKey(host: normalized.host, accountOverride: normalized.accountOverride) if inflightHosts.contains(key) { @@ -125,12 +178,16 @@ final class PullRequestRefreshCoordinator { for worktreeID in request.worktreeIDs where workseen.insert(worktreeID).inserted { workCombined.append(worktreeID) } + var seenRepositories = Set(existing.repositories.map(\.key)) + var combinedRepositories = existing.repositories + for repository in request.repositories where seenRepositories.insert(repository.key).inserted { + combinedRepositories.append(repository) + } hostBucket[request.repositoryID] = Request( repositoryID: request.repositoryID, repositoryRootURL: request.repositoryRootURL, host: request.host, - owner: request.owner, - repo: request.repo, + repositories: combinedRepositories, accountOverride: request.accountOverride, branches: combined, worktreeIDs: workCombined @@ -170,7 +227,7 @@ final class PullRequestRefreshCoordinator { } private func processBatch(key: BatchKey, requests: [Request]) async { - let groupsByKey = groupRequestsByRepo(requests) + let groupsByKey = groupBranchesByRepo(requests) let crossRepoRequests = groupsByKey.values.map { group in CrossRepoPullRequestRequest( owner: group.key.owner, @@ -184,42 +241,66 @@ final class PullRequestRefreshCoordinator { requests: crossRepoRequests, accountOverride: key.accountOverride ) - for (key, prsByBranch) in result.successByRepo { - guard let group = groupsByKey[key] else { - continue - } - for request in group.requests { - resultHandler( - .refreshed( - repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, - worktreeIDs: request.worktreeIDs, - prsByBranch: prsByBranch.filter { request.branches.contains($0.key) } - ) - ) + var prsByRepo = result.successByRepo + var failedMessagesByRepo = result.failedRepos.mapValues { String(describing: $0) } + if !result.failedRepos.isEmpty { + let failedGroups = result.failedRepos.keys.compactMap { groupsByKey[$0] } + let fallback = await fetchFallbackResults(key: key, groups: failedGroups) + for (repoKey, prsByBranch) in fallback.successByRepo { + prsByRepo[repoKey] = prsByBranch + failedMessagesByRepo.removeValue(forKey: repoKey) } + failedMessagesByRepo.merge(fallback.failedMessagesByRepo) { _, new in new } } - let failedRequests = result.failedRepos.keys.flatMap { key in - groupsByKey[key]?.requests ?? [] - } - if !failedRequests.isEmpty { - await fanOutFallback(failedRequests) - } + emitOutcomes( + requests, + prsByRepo: prsByRepo, + failedMessagesByRepo: failedMessagesByRepo + ) } catch { - await fanOutFallback(requests) + let fallback = await fetchFallbackResults(key: key, groups: Array(groupsByKey.values)) + emitOutcomes( + requests, + prsByRepo: fallback.successByRepo, + failedMessagesByRepo: fallback.failedMessagesByRepo + ) } } - private func fanOutFallback(_ requests: [Request]) async { - let groups = Array(groupRequestsByRepo(requests).values) + private func fetchFallbackResults( + key: BatchKey, + groups: [RepoRequestGroup] + ) async -> RepoFetchResults { // Run per-repo fallback requests concurrently; serial awaits here would multiply // a slow recovery path by the number of repos in the batch. - await withTaskGroup(of: Void.self) { group in - for requestGroup in groups { - group.addTask { [weak self] in - await self?.fallbackPerRepo(requestGroup) + await withTaskGroup(of: RepoFetchOutcome.self) { taskGroup in + let githubCLI = self.githubCLI + for repoGroup in groups { + taskGroup.addTask { + do { + let prs = try await githubCLI.batchPullRequests( + key.host, + repoGroup.key.owner, + repoGroup.key.repo, + repoGroup.branches, + key.accountOverride + ) + return .success(repoGroup.key, prs) + } catch { + return .failed(repoGroup.key, String(describing: error)) + } + } + } + var results = RepoFetchResults() + for await outcome in taskGroup { + switch outcome { + case .success(let repoKey, let prsByBranch): + results.successByRepo[repoKey] = prsByBranch + case .failed(let repoKey, let message): + results.failedMessagesByRepo[repoKey] = message } } + return results } } @@ -253,50 +334,79 @@ final class PullRequestRefreshCoordinator { } } - private func fallbackPerRepo(_ group: RepoRequestGroup) async { - do { - let prs = try await githubCLI.batchPullRequests( - group.requests[0].host, - group.key.owner, - group.key.repo, - group.branches, - group.requests[0].accountOverride - ) - for request in group.requests { + private func emitOutcomes( + _ requests: [Request], + prsByRepo: [RepoKey: [String: GithubPullRequest]], + failedMessagesByRepo: [RepoKey: String] + ) { + for request in requests { + let prsByBranch = mergedPullRequests(for: request, prsByRepo: prsByRepo) + let candidateKeys = request.repositories.map(\.key) + let allCandidatesFailed = + !candidateKeys.isEmpty + && candidateKeys.allSatisfy { prsByRepo[$0] == nil && failedMessagesByRepo[$0] != nil } + if allCandidatesFailed { resultHandler( - .refreshed( + .failed( repositoryID: request.repositoryID, - repositoryRootURL: request.repositoryRootURL, worktreeIDs: request.worktreeIDs, - prsByBranch: prs.filter { request.branches.contains($0.key) } + message: failureMessage(for: candidateKeys, failedMessagesByRepo: failedMessagesByRepo) ) ) - } - } catch { - for request in group.requests { + } else { resultHandler( - .failed( + .refreshed( repositoryID: request.repositoryID, + repositoryRootURL: request.repositoryRootURL, worktreeIDs: request.worktreeIDs, - message: String(describing: error) + prsByBranch: prsByBranch ) ) } } } - private func groupRequestsByRepo(_ requests: [Request]) -> [RepoKey: RepoRequestGroup] { + private func mergedPullRequests( + for request: Request, + prsByRepo: [RepoKey: [String: GithubPullRequest]] + ) -> [String: GithubPullRequest] { + var prsByBranch: [String: GithubPullRequest] = [:] + for branch in request.branches { + for repository in request.repositories { + if let pullRequest = prsByRepo[repository.key]?[branch] { + prsByBranch[branch] = pullRequest + break + } + } + } + return prsByBranch + } + + private func failureMessage( + for repoKeys: [RepoKey], + failedMessagesByRepo: [RepoKey: String] + ) -> String { + let messages = repoKeys.compactMap { repoKey -> String? in + guard let message = failedMessagesByRepo[repoKey] else { + return nil + } + return "\(repoKey.owner)/\(repoKey.repo): \(message)" + } + return messages.isEmpty ? "GitHub pull request refresh failed." : messages.joined(separator: "; ") + } + + private func groupBranchesByRepo(_ requests: [Request]) -> [RepoKey: RepoRequestGroup] { var groupsByKey: [RepoKey: RepoRequestGroup] = [:] for request in requests { - let key = RepoKey(owner: request.owner, repo: request.repo) - groupsByKey[key, default: RepoRequestGroup(key: key)].append(request) + for repository in request.repositories { + groupsByKey[repository.key, default: RepoRequestGroup(key: repository.key)].append(branches: request.branches) + } } return groupsByKey } private struct RepoRequestGroup: Sendable { let key: RepoKey - private(set) var requests: [Request] = [] private(set) var branches: [String] = [] private var seenBranches: Set = [] @@ -304,9 +414,8 @@ final class PullRequestRefreshCoordinator { self.key = key } - mutating func append(_ request: Request) { - requests.append(request) - for branch in request.branches where seenBranches.insert(branch).inserted { + mutating func append(branches newBranches: [String]) { + for branch in newBranches where seenBranches.insert(branch).inserted { branches.append(branch) } } @@ -316,6 +425,16 @@ final class PullRequestRefreshCoordinator { case completed(CrossRepoPullRequestResult) case timedOut } + + private struct RepoFetchResults: Sendable { + var successByRepo: [RepoKey: [String: GithubPullRequest]] = [:] + var failedMessagesByRepo: [RepoKey: String] = [:] + } + + private enum RepoFetchOutcome: Sendable { + case success(RepoKey, [String: GithubPullRequest]) + case failed(RepoKey, String) + } } enum PullRequestRefreshCoordinatorError: Error, Equatable { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 38245aab..d0c31f9e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -21,6 +21,37 @@ extension RepositoriesFeature { } return await githubCLI.resolveRemoteInfo(repositoryRootURL) } + + static func resolveGithubRemoteInfos( + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> [GithubRemoteInfo] { + let remoteInfos = await gitClient.githubRemoteInfos(repositoryRootURL) + if !remoteInfos.isEmpty { + return remoteInfos + } + if let remoteInfo = await githubCLI.resolveRemoteInfo(repositoryRootURL) { + return [remoteInfo] + } + return [] + } + + static func resolveGithubRemoteInfo( + for pullRequest: GithubPullRequest, + repositoryRootURL: URL, + githubCLI: GithubCLIClient, + gitClient: GitClientDependency + ) async -> GithubRemoteInfo? { + if let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) { + return remoteInfo + } + return await resolveGithubRemoteInfo( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + } } extension RepositoriesFeature { @@ -87,8 +118,7 @@ extension RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktrees: worktrees, - branches: branches, - cachedRemoteInfo: state.remoteInfoByRepositoryID[repositoryID] + branches: branches ) case .unknown: queuePullRequestRefresh( @@ -366,6 +396,7 @@ extension RepositoriesFeature { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -416,6 +447,7 @@ extension RepositoriesFeature { do { guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -466,6 +498,7 @@ extension RepositoriesFeature { @Shared(.repositorySettings(repoRoot)) var repositorySettings guard let remoteInfo = await Self.resolveGithubRemoteInfo( + for: pullRequest, repositoryRootURL: repoRoot, githubCLI: githubCLI, gitClient: gitClient @@ -713,47 +746,37 @@ extension RepositoriesFeature { repositoryID: Repository.ID, repositoryRootURL: URL, worktrees: [Worktree], - branches: [String], - cachedRemoteInfo: GithubRemoteInfo? + branches: [String] ) -> Effect { let worktreeIDs = worktrees.map(\.id) let coordinatorClient = pullRequestRefreshCoordinator let githubCLI = self.githubCLI let gitClient = self.gitClient return .run { send in - let resolvedRemoteInfo: GithubRemoteInfo? - if let cachedRemoteInfo { - resolvedRemoteInfo = cachedRemoteInfo - } else { - let info = await RepositoriesFeature.resolveGithubRemoteInfo( - repositoryRootURL: repositoryRootURL, - githubCLI: githubCLI, - gitClient: gitClient - ) - if let info { - await send( - .githubIntegration(.cacheRemoteInfo(repositoryID: repositoryID, remoteInfo: info)) - ) - } - resolvedRemoteInfo = info - } - guard let info = resolvedRemoteInfo else { + let remoteInfos = await RepositoriesFeature.resolveGithubRemoteInfos( + repositoryRootURL: repositoryRootURL, + githubCLI: githubCLI, + gitClient: gitClient + ) + guard !remoteInfos.isEmpty else { await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings - coordinatorClient.enqueue( - PullRequestRefreshCoordinator.Request( - repositoryID: repositoryID, - repositoryRootURL: repositoryRootURL, - host: info.host, - owner: info.owner, - repo: info.repo, - accountOverride: repositorySettings.githubAccountOverride, - branches: branches, - worktreeIDs: worktreeIDs + let remoteInfosByHost = Dictionary(grouping: remoteInfos, by: \.host) + for (host, hostRemoteInfos) in remoteInfosByHost { + coordinatorClient.enqueue( + PullRequestRefreshCoordinator.Request( + repositoryID: repositoryID, + repositoryRootURL: repositoryRootURL, + host: host, + repositories: hostRemoteInfos, + accountOverride: repositorySettings.githubAccountOverride, + branches: branches, + worktreeIDs: worktreeIDs + ) ) - ) + } } } diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index e4386281..a44306cf 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -8,17 +8,23 @@ import Testing @MainActor struct BatchedPullRequestRefreshReducerTests { - @Test func refreshDispatchesViaCoordinatorWhenRemoteInfoCached() async { + @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfosWhenCacheExists() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + initialState.remoteInfoByRepositoryID[context.repository.id] = GithubRemoteInfo( + host: "github.com", + owner: "stale", + repo: "cached" + ) + let upstreamInfo = GithubRemoteInfo(host: "github.com", owner: "khoi", repo: "upstream") let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, upstreamInfo] } $0.githubCLI.resolveRemoteInfo = { _ in - Issue.record("Should not resolve when cache hit") + Issue.record("gh resolveRemoteInfo should not run when git remotes resolve") return nil } $0.githubCLI.batchPullRequests = { _, _, _, _, _ in @@ -51,12 +57,11 @@ struct BatchedPullRequestRefreshReducerTests { #expect(snapshot.count == 1) let request = snapshot[0] #expect(request.host == "github.com") - #expect(request.owner == "khoi") - #expect(request.repo == "alpha") + #expect(request.repositories == [context.remoteInfo, upstreamInfo]) #expect(request.branches == ["main", "feature"]) } - @Test func refreshResolvesAndCachesRemoteInfoOnFirstRun() async { + @Test func refreshResolvesRemoteInfosOnFirstRun() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) let initialState = context.state @@ -64,9 +69,9 @@ struct BatchedPullRequestRefreshReducerTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in context.remoteInfo } + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo] } $0.githubCLI.resolveRemoteInfo = { _ in - Issue.record("gh resolveRemoteInfo should not run when git remote resolves") + Issue.record("gh resolveRemoteInfo should not run when git remotes resolve") return nil } $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( @@ -89,9 +94,6 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } - await store.receive(\.githubIntegration.cacheRemoteInfo) { - $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo - } await store.finish() #expect(enqueued.value.count == 1) diff --git a/supacodeTests/GitRemoteInfoTests.swift b/supacodeTests/GitRemoteInfoTests.swift index b592e504..be87cbc7 100644 --- a/supacodeTests/GitRemoteInfoTests.swift +++ b/supacodeTests/GitRemoteInfoTests.swift @@ -42,6 +42,11 @@ struct GitRemoteInfoTests { #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) } + @Test func parsePullRequestURLRemote() { + let info = GitClient.parseGithubRemoteInfo("https://github.com/octo/repo/pull/123") + #expect(info == GithubRemoteInfo(host: "github.com", owner: "octo", repo: "repo")) + } + @Test func parseEnterpriseRemote() { let info = GitClient.parseGithubRemoteInfo("git@github.acme.com:team/repo.git") #expect(info == GithubRemoteInfo(host: "github.acme.com", owner: "team", repo: "repo")) @@ -51,4 +56,20 @@ struct GitRemoteInfoTests { let info = GitClient.parseGithubRemoteInfo("https://gitlab.com/group/repo.git") #expect(info == nil) } + + @Test func prioritizesGithubRemotesForPullRequestLookup() { + let fork = GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") + let upstream = GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + let team = GithubRemoteInfo(host: "github.com", owner: "team", repo: "project") + let duplicateTeam = GithubRemoteInfo(host: "github.com", owner: "TEAM", repo: "project") + + let infos = GitClient.prioritizedGithubRemoteInfos([ + (name: "origin", info: fork), + (name: "team", info: team), + (name: "upstream", info: upstream), + (name: "backup", info: duplicateTeam), + ]) + + #expect(infos == [upstream, team, fork]) + } } diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 16835954..5ebb5f33 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -299,6 +299,47 @@ struct PullRequestRefreshCoordinatorTests { #expect(refreshed.first { $0.0 == "alpha-b" }?.1 == ["feat-2"]) } + @Test func sameLocalRepositoryWithDifferentRemoteReposQueriesAllCandidatesBeforeEmitting() async throws { + let clock = TestClock() + let probe = CoordinatorProbe() + let outcomes = OutcomeCollector() + let coordinator = makeCoordinator( + probe: probe, + clock: clock, + outcomes: outcomes, + batched: { _, requests in + var dict: [RepoKey: [String: GithubPullRequest]] = [:] + for request in requests { + if request.repo == "upstream" { + dict[request.key] = ["feat-1": makeFixturePullRequest(repo: "upstream")] + } else { + dict[request.key] = [:] + } + } + return CrossRepoPullRequestResult(successByRepo: dict) + } + ) + + coordinator.enqueue(request(repo: "fork", repositoryID: "local")) + coordinator.enqueue(request(repo: "upstream", repositoryID: "local")) + await clock.advance(by: .milliseconds(250)) + await Task.yield() + await Task.yield() + + let calls = await probe.batchedCalls() + #expect(calls.count == 1) + #expect(Set(calls.first?.requests.map(\.repo) ?? []) == ["fork", "upstream"]) + + let refreshed = await outcomes.snapshot().compactMap { outcome -> [String: GithubPullRequest]? in + if case .refreshed("local", _, _, let prsByBranch) = outcome { + return prsByBranch + } + return nil + } + #expect(refreshed.count == 1) + #expect(refreshed.first?["feat-1"]?.title == "PR-upstream") + } + @Test func duplicateRepoKeysFallbackOnceAndFanOutToEachRepository() async throws { let clock = TestClock() let probe = CoordinatorProbe() -- 2.51.2 From 01da613fb0bda6ed6e2d5e9ce1bab3e1c7cb9de9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 15 Jun 2026 22:40:03 +0900 Subject: [PATCH 462/528] Prefer origin for PR remote lookup --- docs/components/github-pull-requests.md | 6 +++--- supacode/Clients/Git/GitClient.swift | 12 ++++++++---- supacodeTests/GitRemoteInfoTests.swift | 8 +++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index b2e209fa..166c03b8 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -15,9 +15,9 @@ with a worktree's branch and exposes its status and actions. It works through th itself. If a repository has multiple GitHub remotes, Prowl checks each remote for a PR on -the worktree branch. `upstream` is preferred, other named remotes come next, and -`origin` is used as the fallback, so fork-based worktrees can show upstream PRs -without changing `origin` or restarting the app. +the worktree branch. `origin` is preferred, `upstream` comes next, and other +named remotes are used alphabetically, so fork-based worktrees can show upstream +PRs without changing `origin` or restarting the app. ## What it shows diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 96a418c2..62ee7def 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -544,6 +544,10 @@ struct GitClient { if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + let nameComparison = lhs.element.name.localizedStandardCompare(rhs.element.name) + if nameComparison != .orderedSame { + return nameComparison == .orderedAscending + } return lhs.offset < rhs.offset } .compactMap { entry in @@ -562,12 +566,12 @@ struct GitClient { nonisolated private static func githubPullRequestRemotePriority(_ name: String) -> Int { switch name.lowercased() { - case "upstream": - 0 case "origin": - 2 - default: + 0 + case "upstream": 1 + default: + 2 } } diff --git a/supacodeTests/GitRemoteInfoTests.swift b/supacodeTests/GitRemoteInfoTests.swift index be87cbc7..576b7430 100644 --- a/supacodeTests/GitRemoteInfoTests.swift +++ b/supacodeTests/GitRemoteInfoTests.swift @@ -61,15 +61,17 @@ struct GitRemoteInfoTests { let fork = GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project") let upstream = GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") let team = GithubRemoteInfo(host: "github.com", owner: "team", repo: "project") + let zed = GithubRemoteInfo(host: "github.com", owner: "zed", repo: "project") let duplicateTeam = GithubRemoteInfo(host: "github.com", owner: "TEAM", repo: "project") let infos = GitClient.prioritizedGithubRemoteInfos([ + (name: "zed", info: zed), + (name: "upstream", info: upstream), (name: "origin", info: fork), (name: "team", info: team), - (name: "upstream", info: upstream), - (name: "backup", info: duplicateTeam), + (name: "zz-team", info: duplicateTeam), ]) - #expect(infos == [upstream, team, fork]) + #expect(infos == [fork, upstream, team, zed]) } } -- 2.51.2 From 87bc47fa2ff1d891d4e496b613f98733a80454d0 Mon Sep 17 00:00:00 2001 From: onevpaw Date: Tue, 16 Jun 2026 00:33:14 +0900 Subject: [PATCH 463/528] Add Canvas card tab context menu --- .../Canvas/Views/CanvasCardView.swift | 8 ++++ .../Features/Canvas/Views/CanvasView.swift | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index 69bfd8b6..e7b67d41 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -22,6 +22,9 @@ struct CanvasCardView: View { let isFocused: Bool let isSelected: Bool let hasUnseenNotification: Bool + let tabId: TerminalTabID + let tabs: [TerminalTabItem] + let tabContextMenuActions: TerminalTabContextMenuActions let cardSize: CGSize /// Whether this card is currently expanded in place (near-fullscreen). When /// true the title-bar button restores instead of expands, resize handles and @@ -171,6 +174,11 @@ struct CanvasCardView: View { }, isEnabled: !isExpanded ) + .terminalTabContextMenu( + tabId: tabId, + tabs: tabs, + actions: tabContextMenuActions + ) } private var titleBarActions: some View { diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 39ee0af8..dd5545a8 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -326,6 +326,9 @@ struct CanvasView: View { isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), + tabId: tab.id, + tabs: state.tabManager.tabs, + tabContextMenuActions: tabContextMenuActions(for: state), cardSize: renderSize, isExpanded: isCardExpanded, expandHelp: expandHelp, @@ -379,6 +382,9 @@ struct CanvasView: View { state.closeTab(tab.id) } ) + .sheet(item: iconPickerBinding(for: tab.id, in: state)) { tabId in + iconPickerSheet(state: state, tabId: tabId) + } } // Animatable progress is interpolated by binding the animation to this // card's expanded state. A plain withAnimation around expandedTabID doesn't @@ -389,6 +395,39 @@ struct CanvasView: View { .zIndex(zIndex(for: tab.id, cardKey: cardKey)) } + func tabContextMenuActions(for state: WorktreeTerminalState) -> TerminalTabContextMenuActions { + TerminalTabContextMenuActions( + renameTab: { state.promptChangeTabTitle($0) }, + changeIcon: { state.presentIconPicker(for: $0) }, + closeTab: { state.closeTab($0) }, + closeOthers: { state.closeOtherTabs(keeping: $0) }, + closeToRight: { state.closeTabsToRight(of: $0) }, + closeAll: { state.closeAllTabs() } + ) + } + + func iconPickerBinding(for tabId: TerminalTabID, in state: WorktreeTerminalState) -> Binding { + Binding( + get: { state.iconPickerTabId == tabId ? tabId : nil }, + set: { state.iconPickerTabId = $0 } + ) + } + + func iconPickerSheet(state: WorktreeTerminalState, tabId: TerminalTabID) -> some View { + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon + return TabIconPickerView( + initialIcon: currentIcon, + defaultIcon: state.defaultIcon(for: tabId), + onApply: { newIcon in + state.applyIconChange(tabId, icon: newIcon) + state.dismissIconPicker() + }, + onCancel: { + state.dismissIconPicker() + } + ) + } + // MARK: - Canvas Gestures var canvasPanGesture: some Gesture { -- 2.51.2 From 940854e57682bf3c32eab34a15330a50964c1200 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 21:51:03 +0900 Subject: [PATCH 464/528] Sync docs for 2026.6.16 --- docs/.sync-meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 6a8e0bcc..5bcd2cd7 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "7b5bd85be27faff68b63eb5e041998f6f3734c3c", - "last_synced_date": "2026-06-13", - "note": "No doc edits needed: the three user-facing features in range were already documented in their feature PRs. (1) Open In — Automatic project-aware app selection + many added apps (CLion, GoLand, PhpStorm, Rider, RubyMine, Sublime Text, Tower, iTerm2) and the 'Automatic' clear-pin menu entry (#439) are covered in repositories-and-worktrees.md, verified against OpenWorktreeAction.swift / WorktreeProjectKind.swift. (2) Per-repo GitHub identity override + multi-account GitHub settings tab (#437) are covered in github-pull-requests.md and settings-fields.md (githubAccountOverride row), verified against RepositorySettings.swift / GithubSettingsView.swift. (3) Working-state hold generalized to all agents at ~3s + viewer-overlay last-state handling (#438) are covered generically in agent-detection.md, verified against PaneAgentState.swift. Remaining changes are internal wiring (gh client/reducers/coordinators) and a tooltip tweak. Baseline bump only." + "last_synced_commit": "371bb945b030d3a1f030057e6eeccdd4da50474b", + "last_synced_date": "2026-06-16", + "note": "No doc edits needed: all user-facing features in range were already documented in their feature PRs. (1) External diff tools (#449) — configurable diff tool (built-in/Hunk/FileMerge/Kaleidoscope/custom command) with disabled display for unavailable tools; covered in diff-view.md and settings-fields.md (externalDiffToolID/externalDiffCustomCommand rows). (2) CLI agents command (#442) — `prowl agents` listing detected agent panes; covered in cli.md. (3) Oh My Pi agent detection (#440) — added `omp`/`oh-my-pi` as process-name aliases for Pi detection; covered in agent-detection.md. Remaining changes: agent detection scheduling internal improvement (#441), CLI JSON control character fix (#445), GhosttyKit prebuilt artifact downloader (#450). Baseline bump only." } -- 2.51.2 From e44ddbad5ef59f42c54d3deee86643037e39e5c0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 21:52:17 +0900 Subject: [PATCH 465/528] bump v2026.6.16 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 30c3a584..fedf96ac 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260613; + CURRENT_PROJECT_VERSION = 20260616; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.13; + MARKETING_VERSION = 2026.6.16; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260613; + CURRENT_PROJECT_VERSION = 20260616; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.13; + MARKETING_VERSION = 2026.6.16; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260613; + CURRENT_PROJECT_VERSION = 20260616; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.13; + MARKETING_VERSION = 2026.6.16; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260613; + CURRENT_PROJECT_VERSION = 20260616; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.13; + MARKETING_VERSION = 2026.6.16; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index e7822487..91c3eb11 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.13" + public static let current = "2026.6.16" } -- 2.51.2 From 9a0333fc5efd9fdc14204e9444ba8c5d48375452 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 21:52:18 +0900 Subject: [PATCH 466/528] Update CHANGELOG for 2026.6.16 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb29cd30..53e40a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2026.6.16](https://github.com/onevcat/Prowl/releases/tag/v2026.6.16) + +### New + +- **Configurable diff tool**: Choose your preferred diff viewer in Settings — Built-in, Hunk (opens in a Prowl terminal tab), FileMerge, Kaleidoscope, or a custom command. The diff badge and Show Diff shortcut both route through this setting. Tools not installed on your system appear disabled in the picker. +- **`prowl agents` CLI command**: Run `prowl agents` (or `prowl agents --json`) to get a live roster of all active agent panes, including their project, worktree, tab, and current working/idle status. +- **Oh My Pi support**: Prowl now recognizes Oh My Pi (`omp` / `oh-my-pi`) as an agent, detects its working and interrupting states, and shows the Pi icon on tabs running it. + +### Improved + +- Active agent detection now uses per-pane lazy scheduling: cold panes stay idle, recently used panes are checked briefly, and full polling only starts after a known agent runtime is detected. This reduces unnecessary background work. +- Command aliases (such as wrapped `bun` invocations for Oh My Pi) are now shown with the correct icon in the Active Agents list. + +### Fixed + +- `prowl read --json` and other JSON CLI output no longer corrupts responses that contain terminal control characters. + ## [2026.6.13](https://github.com/onevcat/Prowl/releases/tag/v2026.6.13) This release improves multi-account GitHub workflows, expands editor support in the Open In menu, and stabilizes agent status detection. -- 2.51.2 From 92ef4a7f05de99088c3882f2445906d5105f7886 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 23:30:55 +0900 Subject: [PATCH 467/528] Adapt tab context menu labels per layout variant Introduce TerminalTabContextMenuVariant to tailor menu items to each view layout: Shelf uses "Close Tabs Below", Canvas uses worktree-scoped labels and hides the positional "Close to Right" item. --- .../Canvas/Views/CanvasCardView.swift | 3 +- .../Features/Shelf/Views/ShelfSpineView.swift | 3 +- .../TabBar/Views/TerminalTabContextMenu.swift | 35 +++++++++++++++---- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index e7b67d41..dbe41cb9 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -177,7 +177,8 @@ struct CanvasCardView: View { .terminalTabContextMenu( tabId: tabId, tabs: tabs, - actions: tabContextMenuActions + actions: tabContextMenuActions, + variant: .canvas ) } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 81e1a216..2928d828 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -332,7 +332,8 @@ struct ShelfSpineView: View { closeOthers: { terminalState.closeOtherTabs(keeping: $0) }, closeToRight: { terminalState.closeTabsToRight(of: $0) }, closeAll: { terminalState.closeAllTabs() } - ) + ), + variant: .shelf ) } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift index 250eab80..0c621b93 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift @@ -1,16 +1,27 @@ import SwiftUI +enum TerminalTabContextMenuVariant { + /// Horizontal tab bar — shows all items; "Close Tabs to the Right". + case tabBar + /// Vertical shelf spine — shows all items; "Close Tabs Below". + case shelf + /// 2D canvas cards — worktree-scoped labels; hides "Close Tabs to the Right". + case canvas +} + extension View { func terminalTabContextMenu( tabId: TerminalTabID, tabs: [TerminalTabItem], - actions: TerminalTabContextMenuActions + actions: TerminalTabContextMenuActions, + variant: TerminalTabContextMenuVariant = .tabBar ) -> some View { modifier( TerminalTabContextMenu( tabId: tabId, tabs: tabs, - actions: actions + actions: actions, + variant: variant ) ) } @@ -20,6 +31,7 @@ struct TerminalTabContextMenu: ViewModifier { let tabId: TerminalTabID let tabs: [TerminalTabItem] let actions: TerminalTabContextMenuActions + let variant: TerminalTabContextMenuVariant func body(content: Content) -> some View { content.contextMenu { @@ -39,22 +51,31 @@ struct TerminalTabContextMenu: ViewModifier { actions.closeTab(tabId) } - Button("Close Other Tabs") { + Button(variant == .canvas ? "Close Other Tabs in This Worktree" : "Close Other Tabs") { actions.closeOthers(tabId) } .disabled(tabs.count <= 1) - Button("Close Tabs to the Right") { - actions.closeToRight(tabId) + if variant != .canvas { + Button(closeToTrailingLabel) { + actions.closeToRight(tabId) + } + .disabled(isLastTab) } - .disabled(isLastTab) - Button("Close All") { + Button(variant == .canvas ? "Close All Tabs in This Worktree" : "Close All") { actions.closeAll() } } } + private var closeToTrailingLabel: String { + switch variant { + case .shelf: "Close Tabs Below" + case .tabBar, .canvas: "Close Tabs to the Right" + } + } + private var isLastTab: Bool { guard let last = tabs.last else { return true } return last.id == tabId -- 2.51.2 From 16e63537692d8ed973bd1283d38bf77225a99131 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 23:38:54 +0900 Subject: [PATCH 468/528] Show tab icon in Canvas card title bar --- .../Features/Canvas/Views/CanvasCardView.swift | 15 +++++++++++---- supacode/Features/Canvas/Views/CanvasView.swift | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index dbe41cb9..44d2fa12 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -22,6 +22,7 @@ struct CanvasCardView: View { let isFocused: Bool let isSelected: Bool let hasUnseenNotification: Bool + let tabIcon: String? let tabId: TerminalTabID let tabs: [TerminalTabItem] let tabContextMenuActions: TerminalTabContextMenuActions @@ -144,10 +145,16 @@ struct CanvasCardView: View { Text(repositoryName) .font(.caption.bold()) .lineLimit(1) - Text("/ \(worktreeName)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 3) { + Text("/") + if let tabIcon { + TabIconImage(rawName: tabIcon, pointSize: 10) + } + Text(worktreeName) + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) Spacer() titleBarActions } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index dd5545a8..bd1c11e0 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -326,6 +326,7 @@ struct CanvasView: View { isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), + tabIcon: tab.icon, tabId: tab.id, tabs: state.tabManager.tabs, tabContextMenuActions: tabContextMenuActions(for: state), -- 2.51.2 From 93f9b7dcace233a82abbd6fb4b5ff96bd5a25d6a Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 16 Jun 2026 23:41:42 +0900 Subject: [PATCH 469/528] Hide default terminal icon in Canvas card title bar --- supacode/Features/Canvas/Views/CanvasView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index bd1c11e0..cf8db06e 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -326,7 +326,7 @@ struct CanvasView: View { isFocused: selectionState.primaryTabID == tab.id, isSelected: selectionState.selectedTabIDs.contains(tab.id), hasUnseenNotification: state.hasUnseenNotification(for: tab.id), - tabIcon: tab.icon, + tabIcon: tab.iconLock != .auto ? tab.icon : nil, tabId: tab.id, tabs: state.tabManager.tabs, tabContextMenuActions: tabContextMenuActions(for: state), -- 2.51.2 From fd460dcf156655698b285d0ccf8820cc43606177 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 00:20:12 +0900 Subject: [PATCH 470/528] Fix layout restore race where scenePhase save clears the snapshot on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentView's .task sends scenePhaseChanged with the initial scene phase (.background) before terminal states are populated. The async save finds no active states and clears the previously saved snapshot, so the restore that runs later finds nothing on disk. Skip the scenePhase-triggered save while launchRestoreMode is .restoreLayout — the restore hasn't consumed the snapshot yet. Introduced by 73d1b07a (Refresh worktrees from git registry changes) which added the initial .task { scenePhaseChanged(scenePhase) } send. --- .../Features/App/Reducer/AppFeature.swift | 5 ++- ...AppFeatureTerminalLayoutRestoreTests.swift | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index b32897d1..cb265aaa 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -152,7 +152,10 @@ struct AppFeature { ) case .inactive, .background: var effects: [Effect] = [.cancel(id: CancelID.periodicRefresh)] - if state.settings.restoreTerminalLayoutOnLaunch, !state.suppressLayoutSaveUntilRelaunch { + if state.settings.restoreTerminalLayoutOnLaunch, + !state.suppressLayoutSaveUntilRelaunch, + state.launchRestoreMode != .restoreLayout + { appLogger.info("[LayoutRestore] scenePhase=\(String(describing: phase)), saving layout snapshot") effects.append(.run { _ in await terminalClient.send(.saveLayoutSnapshot) }) } diff --git a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift index 533cfde3..d3b86316 100644 --- a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift +++ b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift @@ -372,7 +372,43 @@ struct AppFeatureTerminalLayoutRestoreTests { await store.finish() } - @Test(.dependencies) func scenePhaseInactiveSavesLayoutSnapshot() async { + @Test(.dependencies) func scenePhaseInactiveSavesLayoutSnapshotAfterRestoreConsumed() async { + let worktree = makeWorktree() + let repository = makeRepository(worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.snapshotPersistencePhase = .active + var settings = SettingsFeature.State() + settings.restoreTerminalLayoutOnLaunch = true + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState, settings: settings) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + $0.worktreeInfoWatcher.send = { _ in } + } + store.exhaustivity = .off + + // Consume the restore mode via repositoriesChanged + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { + $0.launchRestoreMode = .lastFocusedWorktree + $0.repositories.selection = nil + } + await store.finish() + sentCommands.withValue { $0.removeAll() } + + // Now scenePhase inactive should trigger save + await store.send(.scenePhaseChanged(.inactive)) + await store.finish() + + #expect(sentCommands.value == [.saveLayoutSnapshot]) + } + + @Test(.dependencies) func scenePhaseInactiveSkipsSaveDuringPendingRestore() async { let sentCommands = LockIsolated<[TerminalClient.Command]>([]) var settings = SettingsFeature.State() settings.restoreTerminalLayoutOnLaunch = true @@ -385,10 +421,12 @@ struct AppFeatureTerminalLayoutRestoreTests { } store.exhaustivity = .off + // launchRestoreMode is .restoreLayout — save must be skipped + // to prevent clearing a snapshot before restore has a chance to load it await store.send(.scenePhaseChanged(.inactive)) await store.finish() - #expect(sentCommands.value == [.saveLayoutSnapshot]) + #expect(!sentCommands.value.contains(.saveLayoutSnapshot)) } @Test(.dependencies) func scenePhaseInactiveSkipsSaveWhenRestoreDisabled() async { -- 2.51.2 From 0e6e4caa6f9f78a91f28140789a53a99d0f78bb8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 01:04:07 +0900 Subject: [PATCH 471/528] Harden multi-remote PR refresh --- ...epositoriesFeature+GithubIntegration.swift | 122 ++++++++++++++-- .../Reducer/RepositoriesFeature.swift | 5 +- ...atchedPullRequestRefreshReducerTests.swift | 117 ++++++++++++---- supacodeTests/RepositoriesFeatureTests.swift | 132 ++++++++++++++++++ 4 files changed, 333 insertions(+), 43 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index d0c31f9e..41b9b0d7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -178,6 +178,8 @@ extension RepositoriesFeature { } state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() return .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) @@ -212,6 +214,8 @@ extension RepositoriesFeature { case .repositoryPullRequestRefreshCompleted(let repositoryID): state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) guard state.githubIntegrationAvailability == .available, let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( forKey: repositoryID @@ -228,6 +232,13 @@ extension RepositoriesFeature { ) ) + case .pullRequestRefreshBatchCountResolved(let repositoryID, let count): + guard state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) else { + return .none + } + state.prRefreshBatchCountsByRepositoryID[repositoryID] = max(1, count) + return .none + case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): guard let repository = state.repositories[id: repositoryID] else { return .none @@ -675,6 +686,8 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .send(.githubIntegration(.refreshGithubIntegrationAvailability)) @@ -684,6 +697,8 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() let worktreeIDs = Array(state.worktreeInfoByID.keys) for worktreeID in worktreeIDs { updateWorktreePullRequest( @@ -701,10 +716,6 @@ extension RepositoriesFeature { state.mergedWorktreeAction = action return .none - case .cacheRemoteInfo(let repositoryID, let remoteInfo): - state.remoteInfoByRepositoryID[repositoryID] = remoteInfo - return .none - case .pullRequestRefreshBatchOutcome(let outcome): return reduceBatchOutcome(state: &state, outcome: outcome) } @@ -718,14 +729,27 @@ extension RepositoriesFeature { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) return .none } - var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] - for worktreeID in worktreeIDs { - if let worktree = repository.worktrees[id: worktreeID] { - prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] - } + mergePullRequestRefreshResults( + repositoryID: repositoryID, + prsByBranch: prsByBranch, + state: &state + ) + guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { + return .none } + let mergedPRsByBranch = + state.prRefreshResultsByRepositoryID.removeValue( + forKey: repositoryID + ) ?? [:] + let prsByWorktreeID = pullRequestsByWorktreeID( + repository: repository, + worktreeIDs: worktreeIDs, + prsByBranch: mergedPRsByBranch + ) return .merge( .send( .githubIntegration( @@ -737,11 +761,79 @@ extension RepositoriesFeature { ), .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) ) - case .failed(let repositoryID, _, _): - return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + case .failed(let repositoryID, let worktreeIDs, _): + guard consumePullRequestRefreshBatch(repositoryID: repositoryID, state: &state) else { + return .none + } + let mergedPRsByBranch = + state.prRefreshResultsByRepositoryID.removeValue( + forKey: repositoryID + ) ?? [:] + guard !mergedPRsByBranch.isEmpty, + let repository = state.repositories[id: repositoryID] + else { + return .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + } + return .merge( + .send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: pullRequestsByWorktreeID( + repository: repository, + worktreeIDs: worktreeIDs, + prsByBranch: mergedPRsByBranch + ) + ) + ) + ), + .send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) + ) } } + private func mergePullRequestRefreshResults( + repositoryID: Repository.ID, + prsByBranch: [String: GithubPullRequest], + state: inout State + ) { + guard !prsByBranch.isEmpty else { + return + } + var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] + for (branch, pullRequest) in prsByBranch where merged[branch] == nil { + merged[branch] = pullRequest + } + state.prRefreshResultsByRepositoryID[repositoryID] = merged + } + + private func consumePullRequestRefreshBatch( + repositoryID: Repository.ID, + state: inout State + ) -> Bool { + let remaining = (state.prRefreshBatchCountsByRepositoryID[repositoryID] ?? 1) - 1 + guard remaining > 0 else { + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + return true + } + state.prRefreshBatchCountsByRepositoryID[repositoryID] = remaining + return false + } + + private func pullRequestsByWorktreeID( + repository: Repository, + worktreeIDs: [Worktree.ID], + prsByBranch: [String: GithubPullRequest] + ) -> [Worktree.ID: GithubPullRequest?] { + var prsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [:] + for worktreeID in worktreeIDs { + if let worktree = repository.worktrees[id: worktreeID] { + prsByWorktreeID[worktreeID] = prsByBranch[worktree.name] + } + } + return prsByWorktreeID + } + func enqueueBatchedPullRequestRefresh( repositoryID: Repository.ID, repositoryRootURL: URL, @@ -764,6 +856,14 @@ extension RepositoriesFeature { } @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings let remoteInfosByHost = Dictionary(grouping: remoteInfos, by: \.host) + await send( + .githubIntegration( + .pullRequestRefreshBatchCountResolved( + repositoryID: repositoryID, + count: remoteInfosByHost.count + ) + ) + ) for (host, hostRemoteInfos) in remoteInfosByHost { coordinatorClient.enqueue( PullRequestRefreshCoordinator.Request( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index cc6f3eab..f9276e64 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -185,6 +185,7 @@ struct RepositoriesFeature { case refreshGithubIntegrationAvailability case githubIntegrationAvailabilityUpdated(Bool) case repositoryPullRequestRefreshCompleted(Repository.ID) + case pullRequestRefreshBatchCountResolved(repositoryID: Repository.ID, count: Int) case repositoryPullRequestsLoaded( repositoryID: Repository.ID, pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] @@ -192,7 +193,6 @@ struct RepositoriesFeature { case setGithubIntegrationEnabled(Bool) case setMergedWorktreeAction(MergedWorktreeAction?) case pullRequestAction(Worktree.ID, PullRequestAction) - case cacheRemoteInfo(repositoryID: Repository.ID, remoteInfo: GithubRemoteInfo) case pullRequestRefreshBatchOutcome(PullRequestRefreshCoordinator.Outcome) } @@ -263,8 +263,9 @@ struct RepositoriesFeature { var githubIntegrationAvailability: GithubIntegrationAvailability = .unknown var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var inFlightPullRequestRefreshRepositoryIDs: Set = [] + var prRefreshBatchCountsByRepositoryID: [Repository.ID: Int] = [:] + var prRefreshResultsByRepositoryID: [Repository.ID: [String: GithubPullRequest]] = [:] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] - var remoteInfoByRepositoryID: [Repository.ID: GithubRemoteInfo] = [:] var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] @Shared(.appStorage("prowlCreatedWorktreeIDs")) var prowlCreatedWorktreeIDs: [Worktree.ID] = [] diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index a44306cf..e8d78f69 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -8,18 +8,12 @@ import Testing @MainActor struct BatchedPullRequestRefreshReducerTests { - @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfosWhenCacheExists() async { + @Test func refreshDispatchesViaCoordinatorUsingCurrentRemoteInfos() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) - var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = GithubRemoteInfo( - host: "github.com", - owner: "stale", - repo: "cached" - ) let upstreamInfo = GithubRemoteInfo(host: "github.com", owner: "khoi", repo: "upstream") - let store = TestStore(initialState: initialState) { + let store = TestStore(initialState: context.state) { RepositoriesFeature() } withDependencies: { $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, upstreamInfo] } @@ -51,6 +45,9 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + } await store.finish() let snapshot = enqueued.value @@ -61,6 +58,83 @@ struct BatchedPullRequestRefreshReducerTests { #expect(request.branches == ["main", "feature"]) } + @Test func refreshWaitsForAllHostBatchesBeforeCompleting() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + let githubPullRequest = makePullRequestFixture() + let enterpriseInfo = GithubRemoteInfo(host: "ghe.example", owner: "khoi", repo: "alpha") + + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, enterpriseInfo] } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + } + + #expect(Set(enqueued.value.map(\.host)) == ["github.com", "ghe.example"]) + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": githubPullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": githubPullRequest] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: [:] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = githubPullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + $0.prRefreshBatchCountsByRepositoryID = [:] + } + await store.finish() + } + @Test func refreshResolvesRemoteInfosOnFirstRun() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) @@ -94,6 +168,9 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + } await store.finish() #expect(enqueued.value.count == 1) @@ -154,34 +231,14 @@ struct BatchedPullRequestRefreshReducerTests { await store.finish() } - @Test func cacheRemoteInfoStoresMappingInState() async { - let context = makeContext() - let store = TestStore(initialState: context.state) { - RepositoriesFeature() - } withDependencies: { - $0.pullRequestRefreshCoordinator = .unimplemented - } - - await store.send( - .githubIntegration( - .cacheRemoteInfo(repositoryID: context.repository.id, remoteInfo: context.remoteInfo) - ) - ) { - $0.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo - } - await store.finish() - } - @Test(.dependencies) func refreshSkippedWhenPullRequestStateFetchDisabled() async { let context = makeContext() let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) - var initialState = context.state - initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo @Shared(.repositorySettings(context.repoRootURL)) var repositorySettings $repositorySettings.withLock { $0.fetchPullRequestState = false } - let store = TestStore(initialState: initialState) { + let store = TestStore(initialState: context.state) { RepositoriesFeature() } withDependencies: { $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( @@ -212,7 +269,7 @@ struct BatchedPullRequestRefreshReducerTests { @MainActor private func makeContext() -> RefreshTestContext { - let repoRoot = "/tmp/coord-repo" + let repoRoot = "/tmp/coord-repo-\(UUID().uuidString)" let mainWorktree = Worktree( id: repoRoot, name: "main", diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index c8d4f90f..640f6b49 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -4065,6 +4065,104 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func pullRequestMergeUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-merge") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.mergePullRequest = { _, remoteInfo, _, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .merge))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Merging pull request…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request merged") + } + await store.receive(\.worktreeInfoEvent) + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + + @Test func pullRequestCloseUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-close") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.closePullRequest = { _, remoteInfo, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .close))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Closing pull request…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request closed") + } + await store.receive(\.worktreeInfoEvent) + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + + @Test func pullRequestMarkReadyUsesPullRequestURLRemoteInfo() async { + let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-ready") + let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: fixture.state) { + RepositoriesFeature() + } withDependencies: { + $0.githubIntegration.isAvailable = { true } + $0.gitClient.remoteInfo = { _ in + Issue.record("git remoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.resolveRemoteInfo = { _ in + Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") + return nil + } + $0.githubCLI.markPullRequestReady = { _, remoteInfo, _, _ in + remoteInfos.withValue { $0.append(remoteInfo) } + } + } + store.exhaustivity = .off + + await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .markReadyForReview))) + await store.receive(\.showToast) { + $0.statusToast = .inProgress("Marking PR ready…") + } + await store.receive(\.showToast) { + $0.statusToast = .success("Pull request marked ready") + } + #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) + await store.finish() + } + @Test func pullRequestActionMergeRequiresResolvedRemoteInfo() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -5322,6 +5420,40 @@ struct RepositoriesFeatureTests { return state } + private func makePullRequestURLRemoteInfoFixture(repoRoot: String) -> PullRequestURLRemoteInfoFixture { + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let pullRequest = makePullRequest( + state: "OPEN", + headRefName: featureWorktree.name, + number: 456, + url: "https://github.com/onevcat/Prowl/pull/456" + ) + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .disabled + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( + addedLines: nil, + removedLines: nil, + pullRequest: pullRequest + ) + return PullRequestURLRemoteInfoFixture( + featureWorktree: featureWorktree, + state: state, + expectedRemoteInfo: GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "Prowl") + ) + } + + private struct PullRequestURLRemoteInfoFixture { + let featureWorktree: Worktree + let state: RepositoriesFeature.State + let expectedRemoteInfo: GithubRemoteInfo + } + @Test func loadPersistedRepositoriesStartsFetchesConcurrentlyAndPreservesRootOrder() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" -- 2.51.2 From 725bd3290cd90736f0e5f8705b8a1533fd45fef3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 02:09:15 +0900 Subject: [PATCH 472/528] Stabilize cross-host PR refresh priority --- ...epositoriesFeature+GithubIntegration.swift | 87 ++++++++++--- .../Reducer/RepositoriesFeature.swift | 10 +- ...atchedPullRequestRefreshReducerTests.swift | 114 +++++++++++++++++- 3 files changed, 193 insertions(+), 18 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 41b9b0d7..01fe76f7 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -178,8 +178,7 @@ extension RepositoriesFeature { } state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) return .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: githubIntegrationRecoveryInterval) @@ -214,8 +213,7 @@ extension RepositoriesFeature { case .repositoryPullRequestRefreshCompleted(let repositoryID): state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) - state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) - state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) guard state.githubIntegrationAvailability == .available, let pending = state.queuedPullRequestRefreshByRepositoryID.removeValue( forKey: repositoryID @@ -232,11 +230,13 @@ extension RepositoriesFeature { ) ) - case .pullRequestRefreshBatchCountResolved(let repositoryID, let count): + case .pullRequestRefreshBatchCountResolved(let repositoryID, let count, let remotePriorities): guard state.inFlightPullRequestRefreshRepositoryIDs.contains(repositoryID) else { return .none } state.prRefreshBatchCountsByRepositoryID[repositoryID] = max(1, count) + state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] = remotePriorities + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) return .none case .repositoryPullRequestsLoaded(let repositoryID, let pullRequestsByWorktreeID): @@ -686,8 +686,7 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) return .merge( .cancel(id: CancelID.githubIntegrationRecovery), .send(.githubIntegration(.refreshGithubIntegrationAvailability)) @@ -697,8 +696,7 @@ extension RepositoriesFeature { state.pendingPullRequestRefreshByRepositoryID.removeAll() state.queuedPullRequestRefreshByRepositoryID.removeAll() state.inFlightPullRequestRefreshRepositoryIDs.removeAll() - state.prRefreshBatchCountsByRepositoryID.removeAll() - state.prRefreshResultsByRepositoryID.removeAll() + clearAllPullRequestRefreshTracking(state: &state) let worktreeIDs = Array(state.worktreeInfoByID.keys) for worktreeID in worktreeIDs { updateWorktreePullRequest( @@ -729,8 +727,7 @@ extension RepositoriesFeature { case .refreshed(let repositoryID, _, let worktreeIDs, let prsByBranch): guard let repository = state.repositories[id: repositoryID] else { state.inFlightPullRequestRefreshRepositoryIDs.remove(repositoryID) - state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) - state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + clearPullRequestRefreshTracking(repositoryID: repositoryID, state: &state) return .none } mergePullRequestRefreshResults( @@ -745,6 +742,7 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) let prsByWorktreeID = pullRequestsByWorktreeID( repository: repository, worktreeIDs: worktreeIDs, @@ -769,6 +767,7 @@ extension RepositoriesFeature { state.prRefreshResultsByRepositoryID.removeValue( forKey: repositoryID ) ?? [:] + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) guard !mergedPRsByBranch.isEmpty, let repository = state.repositories[id: repositoryID] else { @@ -801,10 +800,46 @@ extension RepositoriesFeature { return } var merged = state.prRefreshResultsByRepositoryID[repositoryID] ?? [:] - for (branch, pullRequest) in prsByBranch where merged[branch] == nil { - merged[branch] = pullRequest + var resultPriorities = state.prRefreshResultPrioritiesByRepositoryID[repositoryID] ?? [:] + let remotePriorities = state.prRefreshRemotePrioritiesByRepositoryID[repositoryID] ?? [:] + // Host batches race independently. Use the returned PR URL to recover its + // source repo, then compare against the original remote order before replacing. + for (branch, pullRequest) in prsByBranch { + let priority = remotePriority(for: pullRequest, remotePriorities: remotePriorities) + if merged[branch] == nil || priority < (resultPriorities[branch] ?? .max) { + merged[branch] = pullRequest + resultPriorities[branch] = priority + } } state.prRefreshResultsByRepositoryID[repositoryID] = merged + state.prRefreshResultPrioritiesByRepositoryID[repositoryID] = resultPriorities + } + + private func remotePriority( + for pullRequest: GithubPullRequest, + remotePriorities: [String: Int] + ) -> Int { + guard let remoteInfo = GitClient.parseGithubRemoteInfo(pullRequest.url) else { + return .max + } + return remotePriorities[Self.pullRequestRefreshRemotePriorityKey(remoteInfo)] ?? .max + } + + private func clearPullRequestRefreshTracking( + repositoryID: Repository.ID, + state: inout State + ) { + state.prRefreshBatchCountsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultsByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshRemotePrioritiesByRepositoryID.removeValue(forKey: repositoryID) + state.prRefreshResultPrioritiesByRepositoryID.removeValue(forKey: repositoryID) + } + + private func clearAllPullRequestRefreshTracking(state: inout State) { + state.prRefreshBatchCountsByRepositoryID.removeAll() + state.prRefreshResultsByRepositoryID.removeAll() + state.prRefreshRemotePrioritiesByRepositoryID.removeAll() + state.prRefreshResultPrioritiesByRepositoryID.removeAll() } private func consumePullRequestRefreshBatch( @@ -860,7 +895,8 @@ extension RepositoriesFeature { .githubIntegration( .pullRequestRefreshBatchCountResolved( repositoryID: repositoryID, - count: remoteInfosByHost.count + count: remoteInfosByHost.count, + remotePriorities: Self.pullRequestRefreshRemotePriorities(remoteInfos) ) ) ) @@ -890,6 +926,29 @@ extension RepositoriesFeature { return pullRequest } + nonisolated private static func pullRequestRefreshRemotePriorities( + _ remoteInfos: [GithubRemoteInfo] + ) -> [String: Int] { + var priorities: [String: Int] = [:] + for (index, remoteInfo) in remoteInfos.enumerated() { + let key = pullRequestRefreshRemotePriorityKey(remoteInfo) + if priorities[key] == nil { + priorities[key] = index + } + } + return priorities + } + + nonisolated private static func pullRequestRefreshRemotePriorityKey( + _ remoteInfo: GithubRemoteInfo + ) -> String { + [ + remoteInfo.host.lowercased(), + remoteInfo.owner.lowercased(), + remoteInfo.repo.lowercased(), + ].joined(separator: "/") + } + nonisolated private static func validWebURL(_ raw: String) -> URL? { guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f9276e64..f59a99fc 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -185,7 +185,11 @@ struct RepositoriesFeature { case refreshGithubIntegrationAvailability case githubIntegrationAvailabilityUpdated(Bool) case repositoryPullRequestRefreshCompleted(Repository.ID) - case pullRequestRefreshBatchCountResolved(repositoryID: Repository.ID, count: Int) + case pullRequestRefreshBatchCountResolved( + repositoryID: Repository.ID, + count: Int, + remotePriorities: [String: Int] + ) case repositoryPullRequestsLoaded( repositoryID: Repository.ID, pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] @@ -265,6 +269,10 @@ struct RepositoriesFeature { var inFlightPullRequestRefreshRepositoryIDs: Set = [] var prRefreshBatchCountsByRepositoryID: [Repository.ID: Int] = [:] var prRefreshResultsByRepositoryID: [Repository.ID: [String: GithubPullRequest]] = [:] + /// Cross-host PR refresh batches complete independently; keep the intended remote + /// order so same-branch collisions are resolved by priority, not arrival time. + var prRefreshRemotePrioritiesByRepositoryID: [Repository.ID: [String: Int]] = [:] + var prRefreshResultPrioritiesByRepositoryID: [Repository.ID: [String: Int]] = [:] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var codeHostByRepositoryID: [Repository.ID: CodeHost] = [:] var sidebarSelectedWorktreeIDs: Set = [] diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index e8d78f69..2a1d5da2 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -47,6 +47,10 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0, + "github.com/khoi/upstream": 1, + ] } await store.finish() @@ -90,6 +94,10 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "ghe.example/khoi/alpha": 1, + "github.com/khoi/alpha": 0, + ] } #expect(Set(enqueued.value.map(\.host)) == ["github.com", "ghe.example"]) @@ -107,6 +115,7 @@ struct BatchedPullRequestRefreshReducerTests { ) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": githubPullRequest] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": .max] } await store.send( @@ -122,6 +131,7 @@ struct BatchedPullRequestRefreshReducerTests { ) { $0.prRefreshBatchCountsByRepositoryID = [:] $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] } await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { var entry = WorktreeInfoEntry() @@ -131,6 +141,98 @@ struct BatchedPullRequestRefreshReducerTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshRemotePrioritiesByRepositoryID = [:] + } + await store.finish() + } + + @Test func refreshPrefersHigherPriorityRemoteWhenHostBatchResultsRace() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + let enterpriseInfo = GithubRemoteInfo(host: "github.enterprise.test", owner: "khoi", repo: "alpha") + let enterprisePullRequest = makePullRequestFixture( + title: "Enterprise PR", + url: "https://github.enterprise.test/khoi/alpha/pull/8" + ) + let originPullRequest = makePullRequestFixture( + title: "Origin PR", + url: "https://github.com/khoi/alpha/pull/7" + ) + + let store = TestStore(initialState: context.state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [context.remoteInfo, enterpriseInfo] } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 2 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0, + "github.enterprise.test/khoi/alpha": 1, + ] + } + + #expect(Set(enqueued.value.map(\.host)) == ["github.com", "github.enterprise.test"]) + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": enterprisePullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshResultsByRepositoryID[context.repository.id] = ["feature": enterprisePullRequest] + $0.prRefreshResultPrioritiesByRepositoryID[context.repository.id] = ["feature": 1] + } + + await store.send( + .githubIntegration( + .pullRequestRefreshBatchOutcome( + .refreshed( + repositoryID: context.repository.id, + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs, + prsByBranch: ["feature": originPullRequest] + ) + )) + ) { + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshResultsByRepositoryID = [:] + $0.prRefreshResultPrioritiesByRepositoryID = [:] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + var entry = WorktreeInfoEntry() + entry.pullRequest = originPullRequest + $0.worktreeInfoByID[context.featureWorktree.id] = entry + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + $0.prRefreshBatchCountsByRepositoryID = [:] + $0.prRefreshRemotePrioritiesByRepositoryID = [:] } await store.finish() } @@ -170,6 +272,9 @@ struct BatchedPullRequestRefreshReducerTests { } await store.receive(\.githubIntegration.pullRequestRefreshBatchCountResolved) { $0.prRefreshBatchCountsByRepositoryID[context.repository.id] = 1 + $0.prRefreshRemotePrioritiesByRepositoryID[context.repository.id] = [ + "github.com/khoi/alpha": 0 + ] } await store.finish() @@ -317,10 +422,13 @@ private struct RefreshTestContext { var worktreeIDs: [Worktree.ID] { [mainWorktree.id, featureWorktree.id] } } -nonisolated private func makePullRequestFixture() -> GithubPullRequest { +nonisolated private func makePullRequestFixture( + title: String = "Coord PR", + url: String = "https://example.com/coord-pr/7" +) -> GithubPullRequest { GithubPullRequest( number: 7, - title: "Coord PR", + title: title, state: "OPEN", additions: 0, deletions: 0, @@ -329,7 +437,7 @@ nonisolated private func makePullRequestFixture() -> GithubPullRequest { mergeable: nil, mergeStateStatus: nil, updatedAt: nil, - url: "https://example.com/coord-pr/7", + url: url, headRefName: "feature", baseRefName: "main", commitsCount: 1, -- 2.51.2 From eaff84b826f7c1e72aa1add85c89d34f1b2edf31 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 02:17:45 +0900 Subject: [PATCH 473/528] Stabilize PR refresh debounce tests --- .../PullRequestRefreshCoordinatorTests.swift | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/supacodeTests/PullRequestRefreshCoordinatorTests.swift b/supacodeTests/PullRequestRefreshCoordinatorTests.swift index 16835954..fdce1300 100644 --- a/supacodeTests/PullRequestRefreshCoordinatorTests.swift +++ b/supacodeTests/PullRequestRefreshCoordinatorTests.swift @@ -19,7 +19,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "beta")) coordinator.enqueue(request(repo: "gamma")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -40,7 +40,7 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha")) - await clock.advance(by: .milliseconds(100)) + await advanceCoordinatorClock(clock, by: .milliseconds(100)) await Task.yield() #expect(await probe.batchedCalls().isEmpty) @@ -59,7 +59,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "beta", host: "github.com")) coordinator.enqueue(request(repo: "gamma", host: "ghe.example")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -82,7 +82,7 @@ struct PullRequestRefreshCoordinatorTests { request(repo: "alpha", accountOverride: GithubAccountOverride(host: "github.com", login: "one"))) coordinator.enqueue(request(repo: "beta", accountOverride: GithubAccountOverride(host: "github.com", login: "two"))) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -120,7 +120,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha")) coordinator.enqueue(request(repo: "beta")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -150,7 +150,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha")) coordinator.enqueue(request(repo: "beta")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -175,7 +175,7 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -199,7 +199,7 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await waitUntil { await probe.batchedCalls().count == 1 } coordinator.enqueue(request(repo: "beta")) @@ -223,7 +223,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha", branches: [])) coordinator.enqueue(request(repo: "beta", branches: ["", " "])) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() #expect(await probe.batchedCalls().isEmpty) @@ -240,7 +240,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha", branches: ["feat-1"])) coordinator.enqueue(request(repo: "alpha", branches: ["feat-2", "feat-1"])) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() let calls = await probe.batchedCalls() @@ -275,7 +275,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue( request(repo: "alpha", repositoryID: "alpha-b", branches: ["feat-2"]) ) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -325,7 +325,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue( request(repo: "alpha", repositoryID: "alpha-b", branches: ["feat-2"]) ) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -356,9 +356,9 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await waitUntil { await probe.batchedCalls().count >= 1 } - await clock.advance(by: .seconds(6)) + await advanceCoordinatorClock(clock, by: .seconds(6)) await waitUntil { await probe.legacyCalls().count >= 1 } let legacyCalls = await probe.legacyCalls() @@ -377,7 +377,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha")) coordinator.reset() - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() #expect(await probe.batchedCalls().isEmpty) @@ -395,7 +395,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue(request(repo: "alpha", host: "host-a")) coordinator.enqueue(request(repo: "beta", host: "host-b")) coordinator.cancelHost("host-a") - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -422,7 +422,7 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha", branches: ["feat-1"])) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -450,16 +450,16 @@ struct PullRequestRefreshCoordinatorTests { ) coordinator.enqueue(request(repo: "alpha")) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() #expect(await probe.batchedCalls().count == 1) coordinator.enqueue(request(repo: "beta")) - await clock.advance(by: .milliseconds(100)) + await advanceCoordinatorClock(clock, by: .milliseconds(100)) await Task.yield() #expect(await probe.batchedCalls().count == 1) - await clock.advance(by: .milliseconds(150)) + await advanceCoordinatorClock(clock, by: .milliseconds(150)) await Task.yield() await Task.yield() #expect(await probe.batchedCalls().count == 2) @@ -486,7 +486,7 @@ struct PullRequestRefreshCoordinatorTests { coordinator.enqueue( request(repo: "alpha", host: "ghe.example", branches: ["feat-x", "feat-y"]) ) - await clock.advance(by: .milliseconds(250)) + await advanceCoordinatorClock(clock, by: .milliseconds(250)) await Task.yield() await Task.yield() @@ -547,6 +547,16 @@ private func waitUntil( } } +private func advanceCoordinatorClock( + _ clock: TestClock, + by duration: Duration +) async { + // Let debounce tasks register and wake around TestClock advancement. + await Task.yield() + await clock.advance(by: duration) + await Task.yield() +} + nonisolated private func request( repo: String, repositoryID: Repository.ID? = nil, -- 2.51.2 From 1ed489ceb30353390ba207a1c4cea166dcaaad17 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 13:49:49 +0800 Subject: [PATCH 474/528] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Alex --- .../ToolbarNotificationsPopoverButton.swift | 3 ++- .../Views/WorktreeDetailTitleView.swift | 24 +++---------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift index 0d64040d..13e49310 100644 --- a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift +++ b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift @@ -24,8 +24,9 @@ struct ToolbarNotificationsPopoverButton: View { Button { togglePresentation() } label: { - HStack(spacing: 6) { + HStack(spacing: 4) { Image(systemName: unseenWorktreeCount > 0 ? "bell.badge.fill" : "bell.fill") + .imageScale(.medium) .foregroundStyle(unseenWorktreeCount > 0 ? .orange : .secondary) .accessibilityHidden(true) if notificationCount > 0 { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 0d36143f..998814e8 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -62,32 +62,14 @@ struct WorktreeDetailTitleView: View { } private var labelContent: some View { - HStack(spacing: horizontalSpacing) { - Image(systemName: title.systemImage) + HStack(spacing: 6) { + Image(systemName: (title.supportsRename && isHovered) ? "pencil" : title.systemImage) .foregroundStyle(.secondary) .accessibilityHidden(true) - .frame(width: iconWidth, alignment: .center) + .frame(width: 18, height: 18) Text(title.text) - if title.supportsRename && isHovered { - Image(systemName: "pencil") - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } } .font(.headline) - .padding(.horizontal, horizontalPadding) - } - - private var iconWidth: CGFloat { - 16 - } - - private var horizontalSpacing: CGFloat { - 6 - } - - private var horizontalPadding: CGFloat { - title.supportsRename ? 0 : 6 } } -- 2.51.2 From c535027805188af78b717f49631a7cc4a0028505 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 09:09:13 +0900 Subject: [PATCH 475/528] Avoid unnecessary build-app input churn --- Makefile | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index a2d64589..a8103b38 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,14 @@ GHOSTTY_BUILD_OUTPUTS := $(GHOSTTY_XCFRAMEWORK_PATH) $(GHOSTTY_RESOURCE_PATH) $( GHOSTTY_BUILD_STAMP := $(CURRENT_MAKEFILE_DIR)/.ghostty_build_stamp GHOSTTY_HASH_FILE := $(CURRENT_MAKEFILE_DIR)/.ghostty_hash SPM_CACHE_DIR := $(HOME)/Library/Caches/supacode-spm-cache/SourcePackages +CLI_DEBUG_RESOURCE_PATH := $(CURRENT_MAKEFILE_DIR)/Resources/prowl-cli/prowl +CLI_SOURCE_DIRS := $(CURRENT_MAKEFILE_DIR)/ProwlCLI $(CURRENT_MAKEFILE_DIR)/supacode/CLIService/Shared +CLI_SOURCE_INPUTS := \ + $(CURRENT_MAKEFILE_PATH) \ + $(CURRENT_MAKEFILE_DIR)/Package.swift \ + $(CURRENT_MAKEFILE_DIR)/Package.resolved \ + $(CURRENT_MAKEFILE_DIR)/supacode.xcodeproj/project.pbxproj \ + $(shell find $(CLI_SOURCE_DIRS) -type f 2>/dev/null) VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= @@ -105,7 +113,13 @@ sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion. @version="$$(/usr/bin/awk -F' = ' '/MARKETING_VERSION = [0-9.]*;/{gsub(/;/,"",$$2);print $$2; exit}' \ "$(CURRENT_MAKEFILE_DIR)/supacode.xcodeproj/project.pbxproj")"; \ dst="$(CURRENT_MAKEFILE_DIR)/supacode/CLIService/Shared/ProwlVersion.swift"; \ - printf '// Auto-generated by Makefile (sync-cli-version). Do not edit.\n\npublic enum ProwlVersion {\n public static let current = "%s"\n}\n' "$$version" > "$$dst" + tmp="$$(mktemp)"; \ + trap 'rm -f "$$tmp"' EXIT; \ + printf '// Auto-generated by Makefile (sync-cli-version). Do not edit.\n\npublic enum ProwlVersion {\n public static let current = "%s"\n}\n' "$$version" > "$$tmp"; \ + if [ ! -f "$$dst" ] || ! cmp -s "$$tmp" "$$dst"; then \ + mv "$$tmp" "$$dst"; \ + echo "synced CLI version $$version"; \ + fi build-cli: sync-cli-version # Build Swift CLI binary (SPM) swift build --product prowl @@ -113,12 +127,19 @@ build-cli: sync-cli-version # Build Swift CLI binary (SPM) build-cli-release: sync-cli-version # Build universal CLI binary in release mode swift build -c release --arch arm64 --arch x86_64 --product prowl -embed-cli-debug: build-cli # Build debug CLI and copy into Resources for dev builds +embed-cli-debug: $(CLI_DEBUG_RESOURCE_PATH) # Build debug CLI and copy into Resources for dev builds + +$(CLI_DEBUG_RESOURCE_PATH): $(CLI_SOURCE_INPUTS) + $(MAKE) build-cli @set -euo pipefail; \ bin="$$(swift build --show-bin-path)/prowl"; \ dst="$(CURRENT_MAKEFILE_DIR)/Resources/prowl-cli"; \ mkdir -p "$$dst"; \ - cp "$$bin" "$$dst/prowl"; \ + if [ ! -f "$$dst/prowl" ] || ! cmp -s "$$bin" "$$dst/prowl"; then \ + cp "$$bin" "$$dst/prowl"; \ + else \ + touch "$$dst/prowl"; \ + fi; \ chmod +x "$$dst/prowl"; \ echo "embedded CLI binary at $$dst/prowl" -- 2.51.2 From 443565525a048967e528fc81b87d62e0982c0d4f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jun 2026 21:20:02 +0800 Subject: [PATCH 476/528] revert Signed-off-by: Alex --- .../Repositories/Views/ToolbarNotificationsPopoverButton.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift index 13e49310..0d64040d 100644 --- a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift +++ b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift @@ -24,9 +24,8 @@ struct ToolbarNotificationsPopoverButton: View { Button { togglePresentation() } label: { - HStack(spacing: 4) { + HStack(spacing: 6) { Image(systemName: unseenWorktreeCount > 0 ? "bell.badge.fill" : "bell.fill") - .imageScale(.medium) .foregroundStyle(unseenWorktreeCount > 0 ? .orange : .secondary) .accessibilityHidden(true) if notificationCount > 0 { -- 2.51.2 From f53097bfc2b9197527966e4d7ee2093f1ad9686b Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 22:29:53 +0900 Subject: [PATCH 477/528] Add failing test for stale PR after remote removal --- ...atchedPullRequestRefreshReducerTests.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 2a1d5da2..30607aab 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -281,6 +281,51 @@ struct BatchedPullRequestRefreshReducerTests { #expect(enqueued.value.count == 1) } + @Test func refreshClearsStalePullRequestsWhenGithubRemotesDisappear() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + let stalePullRequest = makePullRequestFixture(url: "https://github.com/khoi/alpha/pull/7") + var initialState = context.state + var staleEntry = WorktreeInfoEntry() + staleEntry.pullRequest = stalePullRequest + initialState.worktreeInfoByID[context.featureWorktree.id] = staleEntry + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.inFlightPullRequestRefreshRepositoryIDs = [context.repository.id] + } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) { + $0.worktreeInfoByID.removeValue(forKey: context.featureWorktree.id) + } + await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { + $0.inFlightPullRequestRefreshRepositoryIDs = [] + } + await store.finish() + + #expect(enqueued.value.isEmpty) + } + @Test func coordinatorOutcomeRefreshedTranslatesToLoadedAndCompleted() async { let context = makeContext() var initialState = context.state -- 2.51.2 From 699e712a6880a866d7b6deb6f2d413e1f042bcc8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 22:56:06 +0900 Subject: [PATCH 478/528] Refresh PR state when git remotes change --- docs/components/github-pull-requests.md | 5 + .../WorktreeInfoWatcherClient.swift | 1 + .../BusinessLogic/WorktreeInfoMonitors.swift | 220 +++++++++++++----- .../WorktreeInfoWatcherManager.swift | 63 +++++ .../RepositoriesFeature+CoreReducer.swift | 26 +++ ...epositoriesFeature+GithubIntegration.swift | 11 + ...epositoriesFeature+RepositoryLoading.swift | 7 +- supacodeTests/RepositoriesFeatureTests.swift | 57 ++++- .../WorktreeInfoWatcherManagerTests.swift | 81 +++++++ 9 files changed, 396 insertions(+), 75 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index 166c03b8..a1c4081c 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -19,6 +19,11 @@ the worktree branch. `origin` is preferred, `upstream` comes next, and other named remotes are used alphabetically, so fork-based worktrees can show upstream PRs without changing `origin` or restarting the app. +Prowl also watches the repository's git config while the app is running. When +remote URLs are added, removed, or changed, it refreshes the repository's PR +state and code-host label automatically; if the repository no longer has a +GitHub remote, stale PR badges are cleared. + ## What it shows - PR number, title, state (open/closed/merged), draft status. diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index bc3f4038..7a22db16 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -18,6 +18,7 @@ struct WorktreeInfoWatcherClient { case branchChanged(worktreeID: Worktree.ID) case filesChanged(worktreeID: Worktree.ID) case repositoryWorktreesChanged(repositoryRootURL: URL) + case repositoryRemoteConfigurationChanged(repositoryRootURL: URL) case repositoryPullRequestRefresh(repositoryRootURL: URL, worktreeIDs: [Worktree.ID]) } } diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift index 5ccf6553..460d982e 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift @@ -13,6 +13,11 @@ protocol WorktreeRegistryMonitoring: AnyObject { func cancel() } +@MainActor +protocol RemoteConfigMonitoring: AnyObject { + func cancel() +} + final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { private let onEvent: @MainActor @Sendable () -> Void private var stream: FSEventStreamRef? @@ -76,9 +81,71 @@ final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { } } +private enum GitCommonDirectory { + static func url(for repositoryRootURL: URL, fileManager: FileManager) -> URL? { + let repositoryRootURL = repositoryRootURL.standardizedFileURL + let dotGitURL = repositoryRootURL.appending(path: ".git") + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { + if isDirectory.boolValue { + return dotGitURL.standardizedFileURL + } + guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { + return nil + } + return commonDirectoryURL(from: gitdirURL) + } + if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), + fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) + { + return repositoryRootURL + } + return nil + } + + static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory = ObjCBool(false) + return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + && isDirectory.boolValue + } + + private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { + guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "gitdir:" + guard trimmed.hasPrefix(prefix) else { + return nil + } + let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return nil + } + return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) + .standardizedFileURL + } + + private static func commonDirectoryURL(from gitdirURL: URL) -> URL { + let commondirURL = gitdirURL.appending(path: "commondir") + guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return gitdirURL.standardizedFileURL + } + let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return gitdirURL.standardizedFileURL + } + return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) + .standardizedFileURL + } +} + @MainActor final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { - private let commonGitDirectoryURL: URL private let worktreesDirectoryURL: URL private let onEvent: @MainActor @Sendable () -> Void private var commonDirectorySource: DispatchSourceFileSystemObject? @@ -90,18 +157,12 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { onEvent: @escaping @MainActor @Sendable () -> Void, fileManager: FileManager = .default ) { - guard - let commonGitDirectoryURL = Self.commonGitDirectoryURL( - for: repositoryRootURL, - fileManager: fileManager - ) - else { + guard let commonGitDirectoryURL = GitCommonDirectory.url(for: repositoryRootURL, fileManager: fileManager) else { return nil } - self.commonGitDirectoryURL = commonGitDirectoryURL worktreesDirectoryURL = commonGitDirectoryURL.appending(path: "worktrees") self.onEvent = onEvent - isWorktreesDirectoryPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: fileManager) + isWorktreesDirectoryPresent = GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: fileManager) commonDirectorySource = Self.makeDirectorySource(url: commonGitDirectoryURL) { [weak self] in self?.handleCommonDirectoryEvent() } @@ -121,7 +182,7 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { } private func handleCommonDirectoryEvent() { - let isPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: .default) + let isPresent = GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: .default) guard isPresent != isWorktreesDirectoryPresent else { return } @@ -136,7 +197,7 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { } private func handleWorktreesDirectoryEvent() { - guard Self.isDirectory(worktreesDirectoryURL, fileManager: .default) else { + guard GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: .default) else { if isWorktreesDirectoryPresent { isWorktreesDirectoryPresent = false worktreesDirectorySource?.cancel() @@ -179,68 +240,105 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { source.resume() return source } +} - private static func commonGitDirectoryURL( - for repositoryRootURL: URL, - fileManager: FileManager - ) -> URL? { - let repositoryRootURL = repositoryRootURL.standardizedFileURL - let dotGitURL = repositoryRootURL.appending(path: ".git") - var isDirectory = ObjCBool(false) - if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { - if isDirectory.boolValue { - return dotGitURL.standardizedFileURL - } - guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { - return nil - } - return commonDirectoryURL(from: gitdirURL, fileManager: fileManager) - } - if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), - fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) - { - return repositoryRootURL - } - return nil - } +@MainActor +final class GitRemoteConfigMonitor: RemoteConfigMonitoring { + private let configURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private var commonDirectorySource: DispatchSourceFileSystemObject? + private var configSource: DispatchSourceFileSystemObject? + private var configFingerprint: Data? - private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { - guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { + init?( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void, + fileManager: FileManager = .default + ) { + guard let commonGitDirectoryURL = GitCommonDirectory.url(for: repositoryRootURL, fileManager: fileManager) else { return nil } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "gitdir:" - guard trimmed.hasPrefix(prefix) else { - return nil + configURL = commonGitDirectoryURL.appending(path: "config") + self.onEvent = onEvent + configFingerprint = Self.configFingerprint(configURL) + commonDirectorySource = Self.makeFileSource( + url: commonGitDirectoryURL, eventMask: [.write, .rename, .delete, .attrib] + ) { + [weak self] in + self?.handleCommonDirectoryEvent() } - let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { + startConfigSourceIfNeeded() + guard commonDirectorySource != nil || configSource != nil else { return nil } - return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) - .standardizedFileURL } - private static func commonDirectoryURL(from gitdirURL: URL, fileManager: FileManager) -> URL { - let commondirURL = gitdirURL.appending(path: "commondir") - guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { - return gitdirURL.standardizedFileURL + func cancel() { + commonDirectorySource?.cancel() + configSource?.cancel() + commonDirectorySource = nil + configSource = nil + } + + private func handleCommonDirectoryEvent() { + restartConfigSource() + emitIfConfigChanged() + } + + private func handleConfigEvent() { + emitIfConfigChanged() + restartConfigSource() + } + + private func startConfigSourceIfNeeded() { + guard configSource == nil else { + return } - let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { - return gitdirURL.standardizedFileURL + configSource = Self.makeFileSource(url: configURL, eventMask: [.write, .rename, .delete, .attrib]) { [weak self] in + self?.handleConfigEvent() } - return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) - .standardizedFileURL } - private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { - var isDirectory = ObjCBool(false) - return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) - && isDirectory.boolValue + private func restartConfigSource() { + configSource?.cancel() + configSource = nil + startConfigSourceIfNeeded() + } + + private func emitIfConfigChanged() { + let latestFingerprint = Self.configFingerprint(configURL) + guard latestFingerprint != configFingerprint else { + return + } + configFingerprint = latestFingerprint + onEvent() + } + + private static func configFingerprint(_ url: URL) -> Data? { + try? Data(contentsOf: url) + } + + private static func makeFileSource( + url: URL, + eventMask: DispatchSource.FileSystemEvent, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> DispatchSourceFileSystemObject? { + let fileDescriptor = open(url.path(percentEncoded: false), O_EVTONLY) + guard fileDescriptor >= 0 else { + return nil + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: eventMask, + queue: .main + ) + source.setEventHandler { + onEvent() + } + source.setCancelHandler { + close(fileDescriptor) + } + source.resume() + return source } } diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 4172d9ee..c8bda1f5 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -16,6 +16,11 @@ final class WorktreeInfoWatcherManager { _ repositoryRootURL: URL, _ onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeRegistryMonitoring? + typealias RemoteConfigMonitorFactory = + @MainActor @Sendable ( + _ repositoryRootURL: URL, + _ onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? private struct HeadWatcher { let headURL: URL @@ -48,6 +53,7 @@ final class WorktreeInfoWatcherManager { private let filesChangedDebounceInterval: Duration private let repositoryWorktreesEventDebounceInterval: Duration + private let remoteConfigEventDebounceInterval: Duration private let lineChangesEventDebounceInterval: Duration private let lineChangesSafetyRefreshInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration @@ -56,14 +62,17 @@ final class WorktreeInfoWatcherManager { private let pullRequestPhaseOffset: RepositoryPhaseOffset private let worktreeFileEventMonitorFactory: WorktreeFileEventMonitorFactory private let worktreeRegistryMonitorFactory: WorktreeRegistryMonitorFactory + private let remoteConfigMonitorFactory: RemoteConfigMonitorFactory private let sleep: @Sendable (Duration) async throws -> Void private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] private var worktreeFileEventMonitors: [Worktree.ID: WorktreeFileEventMonitoring] = [:] private var worktreeRegistryMonitors: [URL: WorktreeRegistryMonitoring] = [:] + private var remoteConfigMonitors: [URL: RemoteConfigMonitoring] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var repositoryWorktreesDebounceTasks: [URL: Task] = [:] + private var repositoryRemoteConfigDebounceTasks: [URL: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var pullRequestTasks: [URL: RefreshTask] = [:] private var lineChangeSafetyTasks: [Worktree.ID: RefreshTask] = [:] @@ -81,6 +90,7 @@ final class WorktreeInfoWatcherManager { unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), repositoryWorktreesEventDebounceInterval: Duration = .seconds(2), + remoteConfigEventDebounceInterval: Duration = .seconds(2), lineChangesEventDebounceInterval: Duration = .seconds(30), lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), @@ -90,11 +100,14 @@ final class WorktreeInfoWatcherManager { WorktreeInfoWatcherManager.defaultWorktreeFileEventMonitorFactory, worktreeRegistryMonitorFactory: @escaping WorktreeRegistryMonitorFactory = WorktreeInfoWatcherManager.defaultWorktreeRegistryMonitorFactory, + remoteConfigMonitorFactory: @escaping RemoteConfigMonitorFactory = + WorktreeInfoWatcherManager.defaultRemoteConfigMonitorFactory, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval self.repositoryWorktreesEventDebounceInterval = repositoryWorktreesEventDebounceInterval + self.remoteConfigEventDebounceInterval = remoteConfigEventDebounceInterval self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown @@ -102,6 +115,7 @@ final class WorktreeInfoWatcherManager { self.pullRequestPhaseOffset = pullRequestPhaseOffset self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory self.worktreeRegistryMonitorFactory = worktreeRegistryMonitorFactory + self.remoteConfigMonitorFactory = remoteConfigMonitorFactory self.sleep = { duration in try await clock.sleep(for: duration) } @@ -163,6 +177,7 @@ final class WorktreeInfoWatcherManager { } let repositoryRoots = Set(worktrees.map(\.repositoryRootURL)) syncWorktreeRegistryMonitors(repositoryRoots: repositoryRoots) + syncRemoteConfigMonitors(repositoryRoots: repositoryRoots) for repositoryRootURL in repositoryRoots { updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) } @@ -373,15 +388,23 @@ final class WorktreeInfoWatcherManager { for monitor in worktreeRegistryMonitors.values { monitor.cancel() } + for monitor in remoteConfigMonitors.values { + monitor.cancel() + } for task in repositoryWorktreesDebounceTasks.values { task.cancel() } + for task in repositoryRemoteConfigDebounceTasks.values { + task.cancel() + } headWatchers.removeAll() worktreeFileEventMonitors.removeAll() worktreeRegistryMonitors.removeAll() + remoteConfigMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() repositoryWorktreesDebounceTasks.removeAll() + repositoryRemoteConfigDebounceTasks.removeAll() restartTasks.removeAll() pullRequestTasks.removeAll() lineChangeSafetyTasks.removeAll() @@ -592,6 +615,20 @@ final class WorktreeInfoWatcherManager { } } + private func syncRemoteConfigMonitors(repositoryRoots: Set) { + let normalizedRoots = Set(repositoryRoots.map { $0.standardizedFileURL }) + let obsoleteRoots = remoteConfigMonitors.keys.filter { !normalizedRoots.contains($0) } + for repositoryRootURL in obsoleteRoots { + remoteConfigMonitors.removeValue(forKey: repositoryRootURL)?.cancel() + repositoryRemoteConfigDebounceTasks.removeValue(forKey: repositoryRootURL)?.cancel() + } + for repositoryRootURL in normalizedRoots where remoteConfigMonitors[repositoryRootURL] == nil { + remoteConfigMonitors[repositoryRootURL] = remoteConfigMonitorFactory(repositoryRootURL) { [weak self] in + self?.scheduleRepositoryRemoteConfigurationChanged(repositoryRootURL: repositoryRootURL) + } + } + } + private func scheduleRepositoryWorktreesChanged(repositoryRootURL: URL) { let normalizedRootURL = repositoryRootURL.standardizedFileURL repositoryWorktreesDebounceTasks[normalizedRootURL]?.cancel() @@ -611,6 +648,25 @@ final class WorktreeInfoWatcherManager { repositoryWorktreesDebounceTasks[normalizedRootURL] = task } + private func scheduleRepositoryRemoteConfigurationChanged(repositoryRootURL: URL) { + let normalizedRootURL = repositoryRootURL.standardizedFileURL + repositoryRemoteConfigDebounceTasks[normalizedRootURL]?.cancel() + let debounceInterval = remoteConfigEventDebounceInterval + let sleep = self.sleep + let task = Task { [weak self, sleep] in + do { + try await sleep(debounceInterval) + } catch { + return + } + await MainActor.run { + self?.repositoryRemoteConfigDebounceTasks.removeValue(forKey: normalizedRootURL) + self?.emit(.repositoryRemoteConfigurationChanged(repositoryRootURL: normalizedRootURL)) + } + } + repositoryRemoteConfigDebounceTasks[normalizedRootURL] = task + } + private func updateRepeatingTask( _ request: RepeatingTaskRequest, tasks: inout [Worktree.ID: RefreshTask] @@ -686,6 +742,13 @@ final class WorktreeInfoWatcherManager { GitWorktreeRegistryMonitor(repositoryRootURL: repositoryRootURL, onEvent: onEvent) } + private static func defaultRemoteConfigMonitorFactory( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? { + GitRemoteConfigMonitor(repositoryRootURL: repositoryRootURL, onEvent: onEvent) + } + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { let intervalMilliseconds = durationMilliseconds(interval) guard intervalMilliseconds > 0 else { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 63b04ec6..2586c897 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -808,6 +808,32 @@ extension RepositoriesFeature { } case .repositoryWorktreesChanged: return .send(.reloadRepositories(animated: true)) + case .repositoryRemoteConfigurationChanged(let repositoryRootURL): + guard + let repository = state.repositories.first(where: { + $0.rootURL.standardizedFileURL == repositoryRootURL.standardizedFileURL + }) + else { + return .none + } + let repositories = IdentifiedArrayOf(uniqueElements: [repository]) + var effects: [Effect] = [] + let worktreeIDs = repository.worktrees.map(\.id) + if repository.capabilities.supportsPullRequests, !worktreeIDs.isEmpty { + effects.append( + .send( + .githubIntegration( + .repositoryPullRequestRefreshRequested( + repositoryRootURL: repository.rootURL, + worktreeIDs: worktreeIDs + ) + )) + ) + } + if let effect = detectCodeHostsEffect(for: repositories, includeUnknown: true) { + effects.append(effect) + } + return effects.isEmpty ? .none : .concatenate(effects) case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): return .send( .githubIntegration( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 01fe76f7..11c7afa0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -886,6 +886,17 @@ extension RepositoriesFeature { gitClient: gitClient ) guard !remoteInfos.isEmpty else { + let clearedPullRequestsByWorktreeID = Dictionary( + uniqueKeysWithValues: worktreeIDs.map { ($0, Optional.none) } + ) + await send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: clearedPullRequestsByWorktreeID + ) + ) + ) await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 31d85b71..3f9ad3fe 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -4,7 +4,10 @@ import IdentifiedCollections import SwiftUI extension RepositoriesFeature { - func detectCodeHostsEffect(for repositories: IdentifiedArrayOf) -> Effect? { + func detectCodeHostsEffect( + for repositories: IdentifiedArrayOf, + includeUnknown: Bool = false + ) -> Effect? { let targets = repositories .filter { $0.capabilities.supportsCodeHost } @@ -26,7 +29,7 @@ extension RepositoriesFeature { } // `codeHost(for:)` defaults to `.unknown`, so storing `.unknown` // explicitly is a no-op. Skip the round trip when nothing is known. - let meaningful = detected.filter { $0.value != .unknown } + let meaningful = includeUnknown ? detected : detected.filter { $0.value != .unknown } guard !meaningful.isEmpty else { return } await send(.codeHostsDetected(meaningful)) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 640f6b49..ed3ab6de 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -346,6 +346,36 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.repositoriesChanged) } + @Test func repositoryRemoteConfigurationChangedRefreshesPullRequestsAndCodeHost() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .unavailable + initialState.codeHostByRepositoryID[repository.id] = .github + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.repositoryWebURL = { _ in nil } + } + + await store.send( + .worktreeInfoEvent( + .repositoryRemoteConfigurationChanged(repositoryRootURL: repository.rootURL) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: repository.rootURL, + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) + } + await store.receive(\.codeHostsDetected) { + $0.codeHostByRepositoryID[repository.id] = .unknown + } + } + @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -4411,10 +4441,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4429,6 +4459,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -4539,10 +4570,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4554,6 +4585,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -4637,10 +4669,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4654,6 +4686,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index dcda4906..02efdd3a 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -242,6 +242,40 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func repositoryRemoteConfigEventEmitsChangedEventAfterDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let remoteConfigMonitorStore = TestRemoteConfigMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + remoteConfigEventDebounceInterval: .milliseconds(80), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + remoteConfigMonitorFactory: remoteConfigMonitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + let monitor = try #require(remoteConfigMonitorStore.monitor(for: tempRepository.tempRoot)) + monitor.emit() + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func removedRepositoryCancelsRegistryMonitor() async throws { let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) @@ -445,6 +479,17 @@ actor EventCollector { } } } + + func repositoryRemoteConfigurationChangedCount(repositoryRootURL: URL) -> Int { + let expectedURL = repositoryRootURL.standardizedFileURL + return events.reduce(into: 0) { result, event in + if case .repositoryRemoteConfigurationChanged(let rootURL) = event, + rootURL.standardizedFileURL == expectedURL + { + result += 1 + } + } + } } private struct TempWorktree { @@ -590,3 +635,39 @@ private final class TestWorktreeRegistryMonitor: WorktreeRegistryMonitoring { isCanceled = true } } + +@MainActor +private final class TestRemoteConfigMonitorStore { + private var monitors: [URL: TestRemoteConfigMonitor] = [:] + + func makeMonitor( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? { + let monitor = TestRemoteConfigMonitor(onEvent: onEvent) + monitors[repositoryRootURL.standardizedFileURL] = monitor + return monitor + } + + func monitor(for repositoryRootURL: URL) -> TestRemoteConfigMonitor? { + monitors[repositoryRootURL.standardizedFileURL] + } +} + +@MainActor +private final class TestRemoteConfigMonitor: RemoteConfigMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private(set) var isCanceled = false + + init(onEvent: @escaping @MainActor @Sendable () -> Void) { + self.onEvent = onEvent + } + + func emit() { + onEvent() + } + + func cancel() { + isCanceled = true + } +} -- 2.51.2 From 90d9164618b1edb96bad5a71aefdee5d07e0f9bf Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 00:39:12 +0900 Subject: [PATCH 479/528] Sync docs for 2026.6.18 --- docs/.sync-meta.json | 6 +++--- docs/components/canvas.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 5bcd2cd7..f5926baf 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "371bb945b030d3a1f030057e6eeccdd4da50474b", - "last_synced_date": "2026-06-16", - "note": "No doc edits needed: all user-facing features in range were already documented in their feature PRs. (1) External diff tools (#449) — configurable diff tool (built-in/Hunk/FileMerge/Kaleidoscope/custom command) with disabled display for unavailable tools; covered in diff-view.md and settings-fields.md (externalDiffToolID/externalDiffCustomCommand rows). (2) CLI agents command (#442) — `prowl agents` listing detected agent panes; covered in cli.md. (3) Oh My Pi agent detection (#440) — added `omp`/`oh-my-pi` as process-name aliases for Pi detection; covered in agent-detection.md. Remaining changes: agent detection scheduling internal improvement (#441), CLI JSON control character fix (#445), GhosttyKit prebuilt artifact downloader (#450). Baseline bump only." + "last_synced_commit": "40afc8c21b81833844b1667f3f4b101bb34aa183", + "last_synced_date": "2026-06-18", + "note": "Canvas card title bar now supports right-click context menu (Rename Tab, Change Icon, Close Tab, Close Other/All Tabs in This Worktree) — added to canvas.md. Shelf context menu now shows 'Close Tabs Below' instead of 'Close Tabs to the Right' (label-only, tolerated). PR refresh fixes (#456, #469) and layout restore race fix (#459) are internal behavior fixes, not new features. Toolbar icon fix (#467) cosmetic only." } diff --git a/docs/components/canvas.md b/docs/components/canvas.md index 1b8140c7..70175c0d 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -47,6 +47,7 @@ Selection controls: | Resize a card | Drag its edges/corners | | Expand / restore a card | `⌘⌥E`, double-click the title bar, or the title-bar expand button — fills the viewport with that card; click the dimmed background or `⌘⌥E` again to restore | | Close a card (its tab) | The **×** in the card title bar | +| Right-click a card's title bar | Context menu: Rename Tab, Change Icon, Close Tab, Close Other/All Tabs in This Worktree | | Pan the board | Drag empty space (or middle-drag) | | Zoom | `⌘` + scroll, or pinch (≈0.25×–2×) | -- 2.51.2 From 579eb320abcd9f7e77b49fa9a8388718a8711bc0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 00:41:23 +0900 Subject: [PATCH 480/528] bump v2026.6.18 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index fedf96ac..d6d60894 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260616; + CURRENT_PROJECT_VERSION = 20260618; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -493,7 +493,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.16; + MARKETING_VERSION = 2026.6.18; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -528,7 +528,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260616; + CURRENT_PROJECT_VERSION = 20260618; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -551,7 +551,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.16; + MARKETING_VERSION = 2026.6.18; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -581,12 +581,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260616; + CURRENT_PROJECT_VERSION = 20260618; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.16; + MARKETING_VERSION = 2026.6.18; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -603,12 +603,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260616; + CURRENT_PROJECT_VERSION = 20260618; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.16; + MARKETING_VERSION = 2026.6.18; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 91c3eb11..585e2db3 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.16" + public static let current = "2026.6.18" } -- 2.51.2 From ec7fe96eb7684885b8c07095a0e40beab60c310f Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 00:41:24 +0900 Subject: [PATCH 481/528] Update CHANGELOG for 2026.6.18 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e40a7e..5a9d9cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026.6.18](https://github.com/onevcat/Prowl/releases/tag/v2026.6.18) + +### New + +- Canvas card title bars now have a tab context menu matching the one in Default and Shelf layouts. Right-click or use the title bar button to rename the tab, change its icon, or close it. + +### Fixed + +- PR status now refreshes automatically when a repository's git remotes change — adding, removing, or updating a remote URL no longer requires a restart to see updated PR badges. +- PR refresh now works correctly in fork setups with multiple remotes (e.g. `origin` + `upstream`). Prowl queries all GitHub-connected remotes and resolves PR actions against the repo that owns the PR. +- Tabs now restore correctly after quitting and relaunching the app. A race condition on launch could cause the saved layout snapshot to be cleared before restore had a chance to read it. +- The branch name icon in the toolbar no longer causes a layout jump on hover. The pencil edit icon now replaces the branch icon in place rather than appearing alongside it. + ## [2026.6.16](https://github.com/onevcat/Prowl/releases/tag/v2026.6.16) ### New -- 2.51.2 From d251c67bafa3b7d2e21248fe1cf0d330b16aee3f Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 16 Jun 2026 15:56:53 +0800 Subject: [PATCH 482/528] Improve workspace creation and child repo flows --- README.md | 5 +- docs/components/repositories-and-worktrees.md | 4 +- docs/components/workspaces.md | 47 +- .../Clients/Terminal/TerminalClient.swift | 1 + supacode/Domain/ProjectWorkspace.swift | 105 +++- .../RepositoriesFeature+CoreReducer.swift | 70 ++- ...epositoriesFeature+RepositoryLoading.swift | 3 + ...sitoriesFeature+RepositoryManagement.swift | 2 + .../RepositoriesFeature+Selection.swift | 1 + ...epositoriesFeature+WorkspaceChildren.swift | 5 + ...epositoriesFeature+WorktreeLifecycle.swift | 2 + .../Reducer/RepositoriesFeature.swift | 8 +- .../WorkspaceCreationPromptFeature.swift | 227 ++++++-- .../Repositories/Views/EmptyStateView.swift | 31 +- .../Views/RepositorySectionView.swift | 15 +- .../Repositories/Views/SidebarListView.swift | 53 +- .../Views/WorkspaceChildRowsView.swift | 91 ++-- .../Views/WorkspaceCreationPromptView.swift | 415 ++++++++++----- .../Views/WorkspaceDetailView.swift | 7 +- .../Repositories/Views/WorktreeRow.swift | 69 ++- .../Views/RepositorySettingsView.swift | 20 +- .../WorktreeTerminalManager.swift | 2 + .../WorktreeTerminalState+Surfaces.swift | 2 + .../Models/WorktreeTerminalState.swift | 64 ++- supacodeTests/ProjectWorkspaceTests.swift | 71 ++- supacodeTests/RepositoriesFeatureTests.swift | 487 +++++++++++++++--- 26 files changed, 1414 insertions(+), 393 deletions(-) diff --git a/README.md b/README.md index 736cd549..80d0ee0a 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,9 @@ A personal fork of [Supacode](https://github.com/supabitapp/supacode), built on ```bash make build-ghostty-xcframework # Build GhosttyKit from Zig source make build-app # Build the macOS app (Debug) -make run-app # Build, launch, and stream logs -make install-dev-build # Build Debug and install to /Applications +make run-app # Build, install, and launch Debug from /Applications/Prowl Debug.app +make install-debug # Build Debug and install to /Applications/Prowl Debug.app +make install-dev-build # Alias-compatible Debug install target make install-release # Build Release, sign locally, install to /Applications ``` diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index aff35d8c..a1d3b365 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -32,8 +32,8 @@ root; "not a git repository" → plain folder). ## Adding a repository - **Shortcut:** `⌘⇧O` (`open_repository`) -- **Toolbar:** the **Add Repository** button (folder-with-plus icon) at the top of - the sidebar, next to **New Workspace** (person-in-folder icon). +- **Toolbar:** the **Add...** button (folder-with-plus icon) at the top of the + sidebar. Choose **Add Local Repository/Folder**. - **Command Palette:** "Open Repository". Pick one or more directories. Prowl detects git vs plain, de-duplicates, and diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 04c5a631..bed4f7ef 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -18,11 +18,11 @@ When you open a workspace in Prowl: ## Folder layout -Use **New Workspace** from the sidebar toolbar (the -person-in-folder button next to **Add Repository**), the Worktrees menu, or the -command palette to create a workspace. Prowl creates the shared folder, materializes the -selected repositories, writes `.prowl/workspace.json`, and opens the workspace -as a runnable folder. A workspace needs at least two repositories. +Use **Add...** from the sidebar toolbar and choose **Add Workspace**, or use the +Worktrees menu or command palette to create a workspace. Prowl creates the +shared folder, materializes the selected repositories, writes +`.prowl/workspace.json`, and opens the workspace as a runnable folder. A +workspace needs at least two repositories. While a workspace is being created the prompt shows a spinner. **Cancel** stops the creation and rolls back everything created so far: cloned folders, created @@ -44,12 +44,17 @@ Repository sources can be mixed in one workspace: - Local repository folders are selected from disk (`source_kind: local_repository`). - Remote repositories are added through a URL prompt that loads remote heads - before inserting the row. They are cloned into the workspace folder with - `source_kind: remote`. The inserted row defaults to **Use Existing** on the - detected default remote branch. -- Bare repositories are materialized with `git worktree add` and recorded with - `source_kind: bare_repository`. If both branch and base ref are supplied, - Prowl creates the worktree branch from that base ref. + before inserting the row. Loading can be canceled while the prompt is open. + They are cloned into the workspace folder with `source_kind: remote`. The + inserted row defaults to **Use Existing** on the detected default remote + branch. +- Bare repositories are supported by the metadata and materialization layer as + `source_kind: bare_repository`, but the first workspace UI keeps that + advanced source hidden. + +Opened and local repository rows show their source as read-only provenance +rather than a mode selector because both follow the same materialization rules +after they have been added. For already opened and local repositories, the branch action decides how the folder is materialized: @@ -64,8 +69,8 @@ folder is materialized: remote-tracking ref creates a local tracking branch instead of a detached worktree. Git rejects a branch that is already checked out elsewhere. -The creation prompt detects base-ref candidates for already opened, local, and -bare repositories by reading local git refs, preferring the detected default +The creation prompt detects base-ref candidates for already opened and local +repositories by reading local git refs, preferring the detected default branch such as `main` or `master`. Refs are grouped as local branches, remote tracking branches, or fetched remote branches, and the picker supports simple text search. `/HEAD` symbolic pointers are omitted from the picker @@ -73,6 +78,13 @@ because they only alias a branch that is already listed. Base refs are selected from detected refs so workspace creation does not try to checkout an arbitrary, nonexistent branch. +When creation validation fails inside a repository row, the repository list +scrolls to that row and highlights the invalid field with a red border. + +The **Folder** path follows the workspace **Title** while it is still generated +by Prowl. Once you edit or choose the folder directly, Prowl treats it as a +manual path and stops changing it when the title changes. + Branch behavior is explicit: - **Create Branch** uses `branch_name` plus the selected base ref to create a @@ -93,6 +105,12 @@ Branch behavior is explicit: branch). This prevents an unnoticed reset of a local branch that is ahead of the remote. +Workspace rows expand to show child repository rows. Each child row displays its +current branch, uncommitted line counts, and pull request badge when available, +including immediately after a newly created workspace is opened. Click a child +row to select it and focus its terminal tab rooted at that repository folder +inside the workspace, creating that tab the first time it is selected. + ## Removing a workspace **Remove Repository** on a workspace opens a dedicated confirmation. By default @@ -121,6 +139,7 @@ Example `.prowl/workspace.json`: ```json { + "schema_version": "prowl.workspace.v1", "title": "Checkout Flow", "description": "Update app UI, API contract, and shared package together.", "task_links": [ @@ -157,6 +176,8 @@ Example `.prowl/workspace.json`: Top-level fields: +- `schema_version` — metadata format version. Defaults to + `prowl.workspace.v1` when omitted. - `id` — optional stable identifier. Defaults to the workspace root path. - `title` — display title. Defaults to the folder name. - `description` — optional task summary shown in the detail view. diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 0894218f..7def5e4a 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -33,6 +33,7 @@ struct TerminalClient { customCommandIcon: String? = nil ) case createTabInDirectory(Worktree, directory: URL) + case focusOrCreateTabInDirectory(Worktree, directory: URL, title: String?) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case runScript(Worktree, script: String) case insertText(Worktree, text: String) diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 38f6be34..c33b3bd1 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -1,6 +1,8 @@ import Foundation -nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, Sendable { +nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, + Sendable +{ case remote case localRepository = "local_repository" case bareRepository = "bare_repository" @@ -40,7 +42,9 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl } } -nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, Sendable { +nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, + Sendable +{ case link case createBranch = "create_branch" case useExistingRef = "use_existing_ref" @@ -84,7 +88,8 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.path = path sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) - self.checkoutMode = checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode + self.checkoutMode = + checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) @@ -128,8 +133,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send else { return nil } - let localName = selectedRef.split(separator: "/").dropFirst().joined(separator: "/") - guard !localName.isEmpty, + guard let localName = Self.localBranchName(forRemoteRef: selectedRef), baseRefOptions.contains(where: { $0.kind == .local && $0.ref == localName }) else { return nil @@ -137,6 +141,19 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send return localName } + /// Converts refs loaded as `/` into the local branch name Git + /// would create or reset for a tracking checkout. Branch names can contain + /// slashes, so only the first path component is the remote name. + nonisolated static func localBranchName(forRemoteRef ref: String) -> String? { + let parts = ref.trimmingCharacters(in: .whitespacesAndNewlines).split( + separator: "/", maxSplits: 1) + guard parts.count == 2 else { + return nil + } + let branchName = String(parts[1]) + return branchName.isEmpty ? nil : branchName + } + nonisolated static func baseRefOptions( automaticBaseRef: String?, options: [GitBranchRefOption] @@ -151,7 +168,9 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send return normalizedBaseRefOptions(values) } - nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) -> String? { + nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) + -> String? + { let refs = Set(options.map(\.ref)) if let trimmed = automaticBaseRef?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty, @@ -163,7 +182,9 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send ?? options.first?.ref } - nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) -> [GitBranchRefOption] { + nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) + -> [GitBranchRefOption] + { var seen = Set() var result: [GitBranchRefOption] = [] for value in values { @@ -274,7 +295,9 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda } } -nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, Identifiable { +nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, + Identifiable +{ var id: String var name: String var role: String? @@ -347,8 +370,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { nonisolated static let metadataDirectoryName = ".prowl" nonisolated static let metadataFileName = "workspace.json" + nonisolated static let currentSchemaVersion = "prowl.workspace.v1" nonisolated private static let log = SupaLogger("workspace") + var schemaVersion: String var id: String var title: String var description: String @@ -358,6 +383,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { var updatedAt: Date? enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" case id case title case description @@ -368,6 +394,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } init( + schemaVersion: String = currentSchemaVersion, id: String = "", title: String = "", description: String = "", @@ -376,6 +403,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { createdAt: Date? = nil, updatedAt: Date? = nil ) { + self.schemaVersion = schemaVersion self.id = id self.title = title self.description = description @@ -387,11 +415,15 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = + try container.decodeIfPresent(String.self, forKey: .schemaVersion) + ?? Self.currentSchemaVersion id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" taskLinks = try container.decodeIfPresent([String].self, forKey: .taskLinks) ?? [] - repositories = try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] + repositories = + try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) } @@ -423,13 +455,18 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { workspace.id = normalizedRoot } if workspace.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - workspace.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent + workspace.title = + rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent } return workspace.normalized(relativeTo: rootURL) } func normalized(relativeTo rootURL: URL) -> ProjectWorkspace { var copy = self + copy.schemaVersion = copy.schemaVersion.trimmingCharacters(in: .whitespacesAndNewlines) + if copy.schemaVersion.isEmpty { + copy.schemaVersion = Self.currentSchemaVersion + } let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) if copy.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { copy.id = normalizedRoot @@ -451,11 +488,14 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } if entry.name.isEmpty { let resolvedURL = entry.resolvedURL(relativeTo: rootURL) - entry.name = resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent + entry.name = + resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent } entry.role = entry.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - entry.sourceLocation = entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - entry.branchName = entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.sourceLocation = + entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.branchName = + entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty entry.baseRef = entry.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty return entry } @@ -492,7 +532,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { ledger.createdRoot = true } - let metadataDirectoryURL = rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory) + let metadataDirectoryURL = rootURL.appending( + path: metadataDirectoryName, directoryHint: .isDirectory) let metadataPath = metadataDirectoryURL.path(percentEncoded: false) let metadataURL = metadataURL(for: rootURL) if fileManager.fileExists(atPath: metadataURL.path(percentEncoded: false)) { @@ -570,7 +611,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { : []) for url in removableURLs { let path = url.path(percentEncoded: false) - guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true else { + guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true + else { continue } do { @@ -609,7 +651,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } let isSymbolicLink = (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true - guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation else { + guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation + else { continue } do { @@ -661,13 +704,15 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { guard !sourceLocation.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositorySource(name) } - let checkout = try validatedCheckout(repository.checkout, sourceKind: repository.sourceKind, name: name) + let checkout = try validatedCheckout( + repository.checkout, sourceKind: repository.sourceKind, name: name) let workspacePath = uniqueRepositoryPath( for: repository, displayName: name, occupiedNames: &ledger.occupiedNames ) - let destinationURL = workspaceRootURL.appending(path: workspacePath, directoryHint: .isDirectory) + let destinationURL = workspaceRootURL.appending( + path: workspacePath, directoryHint: .isDirectory) let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) guard !fileManager.fileExists(atPath: destinationPath) else { throw ProjectWorkspaceCreationError.linkAlreadyExists(destinationPath) @@ -676,7 +721,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let normalizedSourceLocation: String switch repository.sourceKind { case .existingPath, .localRepository: - let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + let sourcePath = try localRepositoryPath( + sourceLocation: sourceLocation, fileManager: fileManager) switch checkout { case .link: let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL @@ -707,7 +753,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { normalizedSourceLocation = sourceLocation case .bareRepository: - let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + let sourcePath = try localRepositoryPath( + sourceLocation: sourceLocation, fileManager: fileManager) try await gitRunner.run( worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) @@ -758,7 +805,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } return .link case .createBranch(let branchName, let baseRef): - guard let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { + guard + let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + else { throw ProjectWorkspaceCreationError.missingBranchName(name) } return .createBranch( @@ -771,7 +820,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } return .useExistingRef(trimmedRef) case .trackRemoteRef(let remoteRef, let branchName): - guard let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + guard + let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { throw ProjectWorkspaceCreationError.missingExistingRef(name) @@ -829,7 +879,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) case .useExistingRef(let ref): return ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", "--end-of-options", remoteCloneExistingCheckoutRef(ref)], + arguments: [ + "-C", destinationPath, "checkout", "--end-of-options", + remoteCloneExistingCheckoutRef(ref), + ], currentDirectoryURL: nil ) case .trackRemoteRef(_, let branchName): @@ -859,7 +912,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { sourceLocation: String, fileManager: FileManager ) throws -> String { - let repositoryPath = normalizedPath(URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) + let repositoryPath = normalizedPath( + URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) var repositoryIsDirectory = ObjCBool(false) guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), repositoryIsDirectory.boolValue @@ -933,7 +987,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } private static func normalizedPath(_ url: URL, resolvingSymlinks: Bool) -> String { - var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path(percentEncoded: false) + var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path( + percentEncoded: false) while path.count > 1, path.hasSuffix("/") { path.removeLast() } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 4ba089f4..d119f623 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -10,7 +10,8 @@ extension RepositoriesFeature { action: Action ) -> Effect { switch action { - case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement, + case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, + .repositoryManagement, .workspaceCreation: return .none @@ -346,6 +347,7 @@ extension RepositoriesFeature { state.isShelfActive = false recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .archivedWorktrees + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] return .send(.delegate(.selectedWorktreeChanged(nil))) @@ -356,6 +358,7 @@ extension RepositoriesFeature { state.preCanvasTerminalTargetID = canvasSeedWorktree?.id state.isShelfActive = false state.selection = .canvas + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] // Canvas only renders cards for worktrees that already have a live // terminal surface. Normal/Shelf get the previously-focused worktree @@ -525,7 +528,9 @@ extension RepositoriesFeature { case .setSidebarSelectedWorktreeIDs(let worktreeIDs): let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) - if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { + if let selectedWorktreeID = state.selectedWorktreeID, + validWorktreeIDs.contains(selectedWorktreeID) + { nextWorktreeIDs.insert(selectedWorktreeID) } state.sidebarSelectedWorktreeIDs = nextWorktreeIDs @@ -539,6 +544,7 @@ extension RepositoriesFeature { guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .repository(repositoryID) + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] if state.repositories[id: repositoryID]?.kind == .plain { // Plain folder selection opens the folder as a Shelf book. @@ -546,6 +552,37 @@ extension RepositoriesFeature { } return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) + case .openWorkspaceChild(let childID): + guard let child = state.allResolvedWorkspaceChildren().first(where: { $0.id == childID }), + let workspaceRepository = state.repositories[id: child.workspaceID] + else { + return .none + } + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) + state.selection = .repository(child.workspaceID) + state.selectedWorkspaceChildID = child.id + state.sidebarSelectedWorktreeIDs = [] + state.openedWorktreeIDs.insert(child.workspaceID) + state.pendingTerminalFocusWorktreeIDs.insert(child.workspaceID) + let workspaceWorktree = Worktree( + id: workspaceRepository.id, + name: workspaceRepository.name, + detail: workspaceRepository.rootURL.path(percentEncoded: false), + workingDirectory: workspaceRepository.rootURL, + repositoryRootURL: workspaceRepository.rootURL + ) + return .merge( + .send(.delegate(.selectedWorktreeChanged(workspaceWorktree))), + .run { _ in + await terminalClient.send( + .focusOrCreateTabInDirectory( + workspaceWorktree, + directory: child.workingDirectory, + title: child.repositoryName + )) + } + ) + case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } @@ -567,7 +604,8 @@ extension RepositoriesFeature { } requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + await terminalClient.send( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) } case .focusCanvasWorktree(let worktreeID): @@ -578,7 +616,8 @@ extension RepositoriesFeature { } requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + await terminalClient.send( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) } case .selectNextWorktree: @@ -740,7 +779,8 @@ extension RepositoriesFeature { case .alert(.presented(.confirmArchiveWorktrees(let targets))): return .merge( targets.map { target in - .send(.worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) + .send( + .worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) } ) @@ -761,21 +801,28 @@ extension RepositoriesFeature { repository.worktrees.contains(where: { $0.id == id }) } ?? false return .send( - .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) - case .alert(.presented(.confirmWorkspaceRootDeletion(let repositoryID, let rootPath, let selectionWasRemoved))): + case .alert( + .presented( + .confirmWorkspaceRootDeletion(let repositoryID, let rootPath, let selectionWasRemoved))): state.alert = nil let rootURL = URL(fileURLWithPath: rootPath) return .run { send in ProjectWorkspace.removeWorkspaceFolder(at: rootURL) await send( - .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) } - case .alert(.presented(.keepWorkspaceFolderAfterCleanupFailure(let repositoryID, let selectionWasRemoved))): + case .alert( + .presented(.keepWorkspaceFolderAfterCleanupFailure(let repositoryID, let selectionWasRemoved)) + ): state.alert = nil return .send( - .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) case .presentAlert(let title, let message): state.alert = messageAlert(title: title, message: message) @@ -824,7 +871,8 @@ extension RepositoriesFeature { let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) return .run { send in if let changes = await gitClient.lineChanges(worktreeURL) { - let nextLineChanges = normalizedLineChanges(added: changes.added, removed: changes.removed) + let nextLineChanges = normalizedLineChanges( + added: changes.added, removed: changes.removed) guard !lineChangesEqual(nextLineChanges, previousLineChanges) else { return } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 42018c1a..26c78f03 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -310,6 +310,7 @@ extension RepositoriesFeature { !isSidebarSelectionValid(state.selection, state: state) { state.selection = nil + state.selectedWorkspaceChildID = nil } if state.shouldRestoreLastFocusedWorktree { state.shouldRestoreLastFocusedWorktree = false @@ -317,11 +318,13 @@ extension RepositoriesFeature { isSelectionValid(state.lastFocusedWorktreeID, state: state) { state.selection = state.lastFocusedWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } if state.selection == nil, state.shouldSelectFirstAfterReload { state.selection = firstAvailableWorktreeID(from: repositories, state: state) .map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil state.shouldSelectFirstAfterReload = false } pruneWorkspaceChildInfo(state: &state) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index c128b742..14c6ae34 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -160,6 +160,7 @@ extension RepositoriesFeature { if let effect = detectCodeHostsEffect(for: state.repositories) { allEffects.append(effect) } + allEffects.append(refreshWorkspaceChildrenEffect(state: state)) return .merge(allEffects) case .requestRemoveRepository(let repositoryID): @@ -333,6 +334,7 @@ extension RepositoriesFeature { state.removingRepositoryIDs.remove(repositoryID) if selectionWasRemoved { state.selection = nil + state.selectedWorkspaceChildID = nil state.shouldSelectFirstAfterReload = true } let selectedWorktree = state.worktree(for: state.selectedWorktreeID) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift index 47bf243e..bc6387b9 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -141,6 +141,7 @@ func setSingleWorktreeSelection( recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: worktreeID, state: &state) } state.selection = worktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil if let worktreeID { state.sidebarSelectedWorktreeIDs = [worktreeID] } else { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index abda4dfb..c9202e2b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -128,4 +128,9 @@ func pruneWorkspaceChildInfo(state: inout RepositoriesFeature.State) { let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) state.workspaceChildInfoByID = state.workspaceChildInfoByID.filter { validIDs.contains($0.key) } state.workspaceChildBranchByID = state.workspaceChildBranchByID.filter { validIDs.contains($0.key) } + if let selectedWorkspaceChildID = state.selectedWorkspaceChildID, + !validIDs.contains(selectedWorkspaceChildID) + { + state.selectedWorkspaceChildID = nil + } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift index f12fc7b2..5ad3d2a8 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift @@ -219,6 +219,7 @@ extension RepositoriesFeature { if selectionWasRemoved { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } let archivedWorktrees = state.archivedWorktrees @@ -457,6 +458,7 @@ extension RepositoriesFeature { if selectionNeedsUpdate { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } let roots = state.repositories.map(\.rootURL) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 6e2f4de4..e7893826 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -292,6 +292,7 @@ struct RepositoriesFeature { // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] var workspaceChildBranchByID: [String: String] = [:] + var selectedWorkspaceChildID: String? var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false var isInitialLoadComplete = false @@ -419,6 +420,7 @@ struct RepositoriesFeature { case markWorktreeClosed(Worktree.ID) case setSidebarSelectedWorktreeIDs(Set) case selectRepository(Repository.ID?) + case openWorkspaceChild(String) case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) case focusCanvasRepository(Repository.ID) case focusCanvasWorktree(Worktree.ID) @@ -486,8 +488,10 @@ struct RepositoriesFeature { case confirmArchiveWorktrees([ArchiveWorktreeTarget]) case confirmForceDeleteBranch(ForceDeleteBranchRequest) case confirmRemoveRepository(Repository.ID) - case confirmWorkspaceRootDeletion(repositoryID: Repository.ID, rootPath: String, selectionWasRemoved: Bool) - case keepWorkspaceFolderAfterCleanupFailure(repositoryID: Repository.ID, selectionWasRemoved: Bool) + case confirmWorkspaceRootDeletion( + repositoryID: Repository.ID, rootPath: String, selectionWasRemoved: Bool) + case keepWorkspaceFolderAfterCleanupFailure( + repositoryID: Repository.ID, selectionWasRemoved: Bool) } enum PullRequestAction: Equatable { diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 888f378d..782d31d2 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -4,13 +4,20 @@ import IdentifiedCollections @Reducer struct WorkspaceCreationPromptFeature { + private enum CancelID { + static let remoteRepositoryPromptLoad = "workspaceCreationPrompt.remoteRepositoryPromptLoad" + } + @ObservableState struct State: Equatable { var repositories: IdentifiedArrayOf var openedRepositoryCandidates: IdentifiedArrayOf var title: String var rootPath: String + var isRootPathDirty = false var validationMessage: String? + var validationTarget: ValidationTarget? + var validationRequestID = 0 var isCreating = false var remoteRepositoryPrompt: RemoteRepositoryPromptState? @@ -36,6 +43,30 @@ struct WorkspaceCreationPromptFeature { self.title = title self.rootPath = rootPath } + + mutating func clearValidation() { + validationMessage = nil + validationTarget = nil + } + + mutating func setValidation(_ message: String, target: ValidationTarget?) { + validationMessage = message + validationTarget = target + validationRequestID += 1 + } + } + + enum ValidationTarget: Equatable, Sendable { + case title + case rootPath + case repository(Repository.ID, RepositoryField) + } + + enum RepositoryField: Equatable, Sendable { + case name + case source + case branchName + case baseRef } @ObservableState @@ -55,6 +86,9 @@ struct WorkspaceCreationPromptFeature { enum Action: BindableAction, Equatable { case binding(BindingAction) + case titleChanged(String) + case rootPathChanged(String) + case automaticRootPathResolved(path: String, requestedRootPath: String) case addOpenedRepository(Repository.ID) case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) case addRemoteButtonTapped @@ -96,7 +130,35 @@ struct WorkspaceCreationPromptFeature { Reduce { state, action in switch action { case .binding: - state.validationMessage = nil + state.clearValidation() + return .none + + case .titleChanged(let title): + state.title = title + state.clearValidation() + guard !state.isRootPathDirty else { + return .none + } + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + let requestedRootPath = Self.workspaceRootPath(folderName: folderName, suffix: nil) + state.rootPath = requestedRootPath + return .run { send in + let resolved = Self.uniqueWorkspaceRootPath(folderName: folderName) + await send( + .automaticRootPathResolved(path: resolved, requestedRootPath: requestedRootPath)) + } + + case .rootPathChanged(let rootPath): + state.rootPath = rootPath + state.isRootPathDirty = true + state.clearValidation() + return .none + + case .automaticRootPathResolved(let path, let requestedRootPath): + guard !state.isRootPathDirty, state.rootPath == requestedRootPath else { + return .none + } + state.rootPath = path return .none case .addOpenedRepository(let repositoryID): @@ -106,12 +168,12 @@ struct WorkspaceCreationPromptFeature { return .none } state.repositories.append(repository) - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .addRemoteButtonTapped: state.remoteRepositoryPrompt = RemoteRepositoryPromptState() - state.validationMessage = nil + state.clearValidation() return .none case .remoteRepositoryPromptURLChanged(let url): @@ -119,7 +181,9 @@ struct WorkspaceCreationPromptFeature { state.remoteRepositoryPrompt?.validationMessage = nil state.remoteRepositoryPrompt?.branchOptions = [] state.remoteRepositoryPrompt?.defaultBaseRef = nil - if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty == true + { state.remoteRepositoryPrompt?.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) } return .none @@ -149,12 +213,13 @@ struct WorkspaceCreationPromptFeature { let gitClient = gitClient return .run { send in do { - let refs = try await gitClient.remoteBranchRefs(url) + let refs = try await Self.loadRemoteBranchRefs(url, gitClient: gitClient) await send(.remoteRepositoryPromptLoaded(url, refs)) } catch { await send(.remoteRepositoryPromptFailed(error.localizedDescription)) } } + .cancellable(id: CancelID.remoteRepositoryPromptLoad, cancelInFlight: true) case .remoteRepositoryPromptLoaded(let url, let refs): guard var prompt = state.remoteRepositoryPrompt, @@ -204,17 +269,19 @@ struct WorkspaceCreationPromptFeature { ) ) state.remoteRepositoryPrompt = nil - state.validationMessage = nil + state.clearValidation() return .none case .remoteRepositoryPromptDismissed: state.remoteRepositoryPrompt = nil - return .none + return .cancel(id: CancelID.remoteRepositoryPromptLoad) case .addRepositoryFromURL(let sourceKind, let path): guard let rootPath = PathPolicy.normalizePath(path) else { - state.validationMessage = - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, + target: nil + ) return .none } let url = URL(fileURLWithPath: rootPath).standardizedFileURL @@ -227,12 +294,12 @@ struct WorkspaceCreationPromptFeature { sourceLocation: rootPath ) ) - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(id))) case .removeRepository(let repositoryID): state.repositories.remove(id: repositoryID) - state.validationMessage = nil + state.clearValidation() return .none case .repositoryCheckoutModeChanged(let repositoryID, let checkoutMode): @@ -244,7 +311,7 @@ struct WorkspaceCreationPromptFeature { } repository.checkoutMode = checkoutMode state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .none case .repositorySourceKindChanged(let repositoryID, let sourceKind): @@ -261,7 +328,7 @@ struct WorkspaceCreationPromptFeature { repository.sourceLocation = "" } state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() guard sourceKind != .remote, repository.localSourceURL != nil else { return .none } @@ -269,18 +336,20 @@ struct WorkspaceCreationPromptFeature { case .repositoryNameChanged(let repositoryID, let name): state.repositories[id: repositoryID]?.name = name - state.validationMessage = nil + state.clearValidation() return .none case .repositoryPathChanged(let repositoryID, let path): state.repositories[id: repositoryID]?.path = path - state.validationMessage = nil + state.clearValidation() return .none case .repositorySourceChosen(let repositoryID, let sourceLocation): guard let rootPath = PathPolicy.normalizePath(sourceLocation) else { - state.validationMessage = - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, + target: .repository(repositoryID, .source) + ) return .none } guard var repository = state.repositories[id: repositoryID] else { @@ -293,7 +362,7 @@ struct WorkspaceCreationPromptFeature { repository.baseRef = nil repository.baseRefOptions = [] state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): @@ -307,15 +376,17 @@ struct WorkspaceCreationPromptFeature { repository.baseRefOptions = [] } state.repositories[id: repositoryID] = repository - state.validationMessage = nil - guard sourceLocationChanged, repository.sourceKind != .remote, repository.localSourceURL != nil else { + state.clearValidation() + guard sourceLocationChanged, repository.sourceKind != .remote, + repository.localSourceURL != nil + else { return .none } return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositoryBranchNameChanged(let repositoryID, let branchName): state.repositories[id: repositoryID]?.branchName = branchName - state.validationMessage = nil + state.clearValidation() return .none case .repositoryBaseRefChanged(let repositoryID, let baseRef): @@ -323,24 +394,26 @@ struct WorkspaceCreationPromptFeature { return .none } let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) else { + guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) + else { return .none } repository.baseRef = trimmed.isEmpty ? nil : trimmed // A new ref selection invalidates any previous keep/reset choice. repository.resetLocalBranchToRemote = false state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .none case .repositoryResetLocalBranchChanged(let repositoryID, let resetToRemote): state.repositories[id: repositoryID]?.resetLocalBranchToRemote = resetToRemote - state.validationMessage = nil + state.clearValidation() return .none case .rootPathChosen(let path): state.rootPath = path - state.validationMessage = nil + state.isRootPathDirty = true + state.clearValidation() return .none case .cancelButtonTapped: @@ -349,15 +422,25 @@ struct WorkspaceCreationPromptFeature { case .createButtonTapped: let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { - state.validationMessage = ProjectWorkspaceCreationError.missingTitle.localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingTitle.localizedDescription, + target: .title + ) return .none } - guard let rootPath = PathPolicy.normalizePath(state.rootPath, resolvingSymlinks: false) else { - state.validationMessage = ProjectWorkspaceCreationError.missingPath.localizedDescription + guard let rootPath = PathPolicy.normalizePath(state.rootPath, resolvingSymlinks: false) + else { + state.setValidation( + ProjectWorkspaceCreationError.missingPath.localizedDescription, + target: .rootPath + ) return .none } guard state.repositories.count >= 2 else { - state.validationMessage = ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription, + target: nil + ) return .none } var plans: [ProjectWorkspaceRepositoryPlan] = [] @@ -366,11 +449,14 @@ struct WorkspaceCreationPromptFeature { case .success(let plan): plans.append(plan) case .failure(let error): - state.validationMessage = error.localizedDescription + state.setValidation( + error.localizedDescription, + target: Self.validationTarget(for: error, repositoryID: repository.id) + ) return .none } } - state.validationMessage = nil + state.clearValidation() return .send( .delegate( .submit( @@ -397,6 +483,68 @@ struct WorkspaceCreationPromptFeature { return String(name.dropLast(4)) } + nonisolated private static func workspaceRootPath(folderName: String, suffix: Int?) -> String { + let component = suffix.map { "\(folderName)-\($0)" } ?? folderName + return SupacodePaths.workspacesDirectory + .appending(path: component, directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + } + + nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { + var suffix: Int? + while true { + let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) + if !FileManager.default.fileExists(atPath: candidate) { + return candidate + } + suffix = (suffix ?? 1) + 1 + } + } + + nonisolated private static func loadRemoteBranchRefs( + _ url: String, + gitClient: GitClientDependency + ) async throws -> GitRemoteBranchRefs { + try await withThrowingTaskGroup(of: GitRemoteBranchRefs.self) { group in + group.addTask { + try await gitClient.remoteBranchRefs(url) + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw RemoteBranchLoadTimeoutError() + } + guard let refs = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return refs + } + } + + nonisolated static func validationTarget( + for error: ProjectWorkspaceCreationError, + repositoryID: Repository.ID + ) -> ValidationTarget? { + switch error { + case .missingRepositoryName: + return .repository(repositoryID, .name) + case .missingRepositorySource: + return .repository(repositoryID, .source) + case .missingBranchName: + return .repository(repositoryID, .branchName) + case .missingExistingRef: + return .repository(repositoryID, .baseRef) + case .missingTitle: + return .title + case .missingPath: + return .rootPath + case .notEnoughRepositories, .linkCheckoutUnsupported, .destinationIsFile, + .workspaceAlreadyExists, .repositoryDoesNotExist, .linkAlreadyExists, .gitCommandFailed: + return nil + } + } + static func plan( for repository: ProjectWorkspaceCreationRepository ) -> Result { @@ -434,13 +582,14 @@ struct WorkspaceCreationPromptFeature { if kind == .local { checkout = .useExistingRef(baseRef) } else { - // Remote refs are named /; materialize them as a local - // tracking branch instead of a detached worktree. - let branchName = baseRef.split(separator: "/").dropFirst().joined(separator: "/") - guard !branchName.isEmpty else { + guard + let branchName = ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: baseRef) + else { return .failure(.missingExistingRef(displayName)) } - if let localBranchName = repository.resettableLocalBranchName, !repository.resetLocalBranchToRemote { + if let localBranchName = repository.resettableLocalBranchName, + !repository.resetLocalBranchToRemote + { // A same-named local branch already exists and the user chose to keep // it: check out the local branch directly rather than resetting it to // the remote ref with `-B`, which would discard local-only commits. @@ -462,3 +611,9 @@ struct WorkspaceCreationPromptFeature { ) } } + +private struct RemoteBranchLoadTimeoutError: LocalizedError { + var errorDescription: String? { + "Remote branch loading timed out." + } +} diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 7569678b..1208e8cd 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -4,16 +4,18 @@ import SwiftUI struct EmptyStateView: View { let store: StoreOf @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @State private var isAddChoicePresented = false var body: some View { - let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) + let shortcutDisplay = AppShortcuts.display( + for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) ContentUnavailableView { Label("Open a repository or folder", systemImage: "folder.badge.plus") } description: { Text(promptText(shortcutDisplay: shortcutDisplay)) } actions: { - Button("Add Repository...") { - store.send(.setOpenPanelPresented(true)) + Button("Add...") { + isAddChoicePresented = true } .modifier( KeyboardShortcutModifier( @@ -27,14 +29,33 @@ struct EmptyStateView: View { in: resolvedKeybindings )) } + .confirmationDialog( + "Add to Prowl", + isPresented: $isAddChoicePresented, + titleVisibility: .visible + ) { + Button("Add Local Repository/Folder") { + store.send(.setOpenPanelPresented(true)) + } + Button("Add Workspace") { + store.send(.workspaceCreation(.promptRequested)) + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "A local repository or folder opens one project root. " + + "A workspace creates one shared task folder " + + "containing multiple repositories for one agent to work across." + ) + } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) } private func promptText(shortcutDisplay: String?) -> String { if let shortcutDisplay { - return "Press \(shortcutDisplay) or click Add Repository to add one." + return "Press \(shortcutDisplay) or click Add to add one." } - return "Click Add Repository to add one." + return "Click Add to add one." } } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 935b36b9..ab806783 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -28,7 +28,7 @@ struct RepositorySectionView: View { // `supportsWorktrees` so a workspace never offers "New Worktree". let isExpandable = repository.capabilities.supportsWorktrees || repository.isWorkspace let isRemovingRepository = state.isRemovingRepository(repository) - let isSelected = state.selection == .repository(repository.id) + let isSelected = state.selection == .repository(repository.id) && state.selectedWorkspaceChildID == nil let openRepoSettings = { _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) } @@ -188,7 +188,9 @@ struct RepositorySectionView: View { .accessibilityLabel(Text("Repo color: \(color.displayName)")) } } - .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) + .frame( + maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center + ) .padding(.horizontal, 12) .padding(.top, hasTopSpacing ? 4 : 0) .padding(.bottom, hasTopSpacing && !isExpandable ? 4 : 0) @@ -242,7 +244,14 @@ struct RepositorySectionView: View { .tag(SidebarSelection.repository(repository.id)) if isExpanded { if repository.isWorkspace { - WorkspaceChildRowsView(rows: state.workspaceChildRows(in: repository)) + WorkspaceChildRowsView( + rows: state.workspaceChildRows(in: repository), + selectedID: state.selection == .repository(repository.id) + ? state.selectedWorkspaceChildID : nil, + onSelect: { childID in + store.send(.openWorkspaceChild(childID)) + } + ) } else { WorktreeRowsView( repository: repository, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 3980d7ca..dd9f05c2 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -42,6 +42,7 @@ struct SidebarListView: View { @State private var sidebarHeight = 0.0 @State private var sidebarFooterHeight = 0.0 @State private var resizingPanelHeight: Double? + @State private var isAddChoicePresented = false @Namespace private var topSegmentNamespace @Environment(\.resolvedKeybindings) private var resolvedKeybindings @Environment(CommandKeyObserver.self) private var commandKeyObserver @@ -86,7 +87,8 @@ struct SidebarListView: View { let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 let activeAgentsPanelTopGap = 4.0 - let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap + let listBottomPadding = + state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap ScrollViewReader { scrollProxy in ScrollView { @@ -95,7 +97,7 @@ struct SidebarListView: View { VStack(spacing: 0) { // When there are no repositories the sidebar stays empty — the // detail pane's `EmptyStateView` ("Open a repository or folder") - // carries the prompt and the Add Repository button instead. + // carries the prompt and the Add button instead. if !repositoryItems.isEmpty { repositoryListHeader( action: repositoryListHeaderAction, @@ -194,21 +196,34 @@ struct SidebarListView: View { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem(placement: .automatic) { Button { - store.send(.workspaceCreation(.promptRequested)) + isAddChoicePresented = true } label: { - Label("New Workspace", systemImage: "folder.badge.person.crop") + Label("Add...", systemImage: "folder.badge.plus") } - .help("New Workspace") - Button { - store.send(.setOpenPanelPresented(true)) - } label: { - Label("Add Repository", systemImage: "folder.badge.plus") - } - .help("Add Repository") + .help("Add Repository or Workspace") } } + .confirmationDialog( + "Add to Prowl", + isPresented: $isAddChoicePresented, + titleVisibility: .visible + ) { + Button("Add Local Repository/Folder") { + store.send(.setOpenPanelPresented(true)) + } + Button("Add Workspace") { + store.send(.workspaceCreation(.promptRequested)) + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "A local repository or folder opens one project root. " + + "A workspace creates one shared task folder " + + "containing multiple repositories for one agent to work across." + ) + } } // ScrollViewReader } @@ -477,7 +492,8 @@ struct SidebarListView: View { await Task.yield() await Task.yield() withAnimation(.easeOut(duration: 0.2)) { - scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) + scrollProxy.scrollTo( + SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) } store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } @@ -510,7 +526,8 @@ struct SidebarListView: View { } static func repositoryHeaderOpensCanvasTarget(_ repository: Repository) -> Bool { - repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees + repository.capabilities.supportsRunnableFolderActions + && !repository.capabilities.supportsWorktrees } static func activeAgentWorktreeMetadata( @@ -525,7 +542,9 @@ struct SidebarListView: View { for repository in repositories { let repositoryName = customTitles[repository.id] ?? repository.name let repositoryColor = repositoryAppearances[repository.id]?.color - if repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees { + if repository.capabilities.supportsRunnableFolderActions + && !repository.capabilities.supportsWorktrees + { repositoryNamesByWorktreeID[repository.id] = repositoryName branchNamesByWorktreeID[repository.id] = repository.name if let repositoryColor { @@ -612,7 +631,9 @@ struct SidebarListView: View { best = (id, depth) } for repository in repositories { - if repository.capabilities.supportsRunnableFolderActions, !repository.capabilities.supportsWorktrees { + if repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + { consider(id: repository.id, directory: repository.rootURL) } for worktree in repository.worktrees { diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 53a46758..7e738a68 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -1,40 +1,71 @@ import SwiftUI -/// Display-only rows for the child repositories of an expanded workspace. -/// Reuses `WorktreeRow` for visual parity with git worktree rows (branch icon, -/// `+N/-M` diff badge, PR tag), but deliberately omits selection, drag, context -/// menu, and tap handling — a workspace has a single root terminal and its -/// children are not independently runnable targets. +/// Rows for the child repositories of an expanded workspace. +/// Reuses `WorktreeRow` for visual parity with git worktree rows (`+N/-M` diff +/// badge, PR tag), while clicking a child focuses its bound terminal tab, +/// creating one rooted at that repository's folder when needed. struct WorkspaceChildRowsView: View { let rows: [WorkspaceChildRowModel] + let selectedID: String? + let onSelect: (String) -> Void var body: some View { ForEach(rows) { row in - WorktreeRow( - name: row.branchName ?? row.repositoryName, - worktreeName: row.branchName == nil ? "" : row.repositoryName, - info: row.info, - showsPullRequestInfo: true, - isHovered: false, - isPinned: false, - isMainWorktree: false, - isLoading: false, - taskStatus: nil, - isRunScriptRunning: false, - showsNotificationIndicator: false, - notifications: [], - onFocusNotification: { _ in }, - shortcutHint: nil, - showsShortcutHint: false, - pinAction: nil, - isSelected: false, - archiveAction: nil, - onDiffTap: nil, - onStopRunScript: nil, - ) - .padding(.leading, 14) - .padding(.trailing, 8) - .id(row.id) + let isSelected = row.id == selectedID + WorkspaceChildRowButton(row: row, isSelected: isSelected, onSelect: onSelect) + .padding(.leading, 14) + .padding(.trailing, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.18)) + .padding(.horizontal, 6) + } + } + .padding(.vertical, 2) + .id(row.id) } } } + +private struct WorkspaceChildRowButton: View { + let row: WorkspaceChildRowModel + let isSelected: Bool + let onSelect: (String) -> Void + + var body: some View { + Button { + onSelect(row.id) + } label: { + content + } + .buttonStyle(.plain) + .help("Focus Terminal in \(row.repositoryName)") + } + + private var content: some View { + WorktreeRow( + name: row.branchName ?? row.repositoryName, + worktreeName: row.branchName == nil ? "" : row.repositoryName, + info: row.info, + iconSystemName: "folder", + showsPullRequestInfo: true, + isHovered: false, + isPinned: false, + isMainWorktree: false, + isLoading: false, + taskStatus: nil, + isRunScriptRunning: false, + showsNotificationIndicator: false, + notifications: [], + onFocusNotification: { _ in }, + shortcutHint: nil, + showsShortcutHint: false, + pinAction: nil, + isSelected: isSelected, + archiveAction: nil, + onDiffTap: nil, + onStopRunScript: nil, + ) + } +} diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 5145608d..b9a989ec 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -5,12 +5,6 @@ import SwiftUI struct WorkspaceCreationPromptView: View { @Bindable var store: StoreOf @FocusState private var isTitleFieldFocused: Bool - private let sourceKinds: [ProjectWorkspaceRepositorySourceKind] = [ - .existingPath, - .localRepository, - .remote, - .bareRepository, - ] var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -24,23 +18,43 @@ struct WorkspaceCreationPromptView: View { VStack(alignment: .leading, spacing: 8) { Text("Title") .foregroundStyle(.secondary) - TextField("Workspace title", text: $store.title) - .textFieldStyle(.roundedBorder) - .focused($isTitleFieldFocused) - .disabled(store.isCreating) - .onSubmit { - store.send(.createButtonTapped) - } + TextField( + "Workspace title", + text: Binding( + get: { store.title }, + set: { store.send(.titleChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($isTitleFieldFocused) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(store.validationTarget == .title) + } + .onSubmit { + store.send(.createButtonTapped) + } + helpText( + "A short name for the shared task folder shown in the sidebar and workspace metadata.") } VStack(alignment: .leading, spacing: 8) { Text("Folder") .foregroundStyle(.secondary) HStack(spacing: 8) { - TextField("Workspace folder", text: $store.rootPath) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isCreating) + TextField( + "Workspace folder", + text: Binding( + get: { store.rootPath }, + set: { store.send(.rootPathChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(store.validationTarget == .rootPath) + } Button { chooseFolder() } label: { @@ -55,6 +69,9 @@ struct WorkspaceCreationPromptView: View { .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) + helpText( + "Where Prowl creates the workspace root. Until you edit it, this path follows the workspace title." + ) } VStack(alignment: .leading, spacing: 8) { @@ -90,24 +107,30 @@ struct WorkspaceCreationPromptView: View { } .help("Add Local Repository") .disabled(store.isCreating) - - Button { - chooseRepositorySource(kind: .bareRepository) - } label: { - Label("Add Bare", systemImage: "externaldrive") - } - .help("Add Bare Repository") - .disabled(store.isCreating) } - ScrollView { - VStack(spacing: 0) { - ForEach(store.repositories) { repository in - repositoryEditor(repository) - if repository.id != store.repositories.last?.id { - Divider() + helpText( + "Add at least two repositories. Opened and local repositories can be linked or materialized as worktrees." + ) + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + ForEach(store.repositories) { repository in + repositoryEditor(repository) + .id(repository.id) + if repository.id != store.repositories.last?.id { + Divider() + } } } } + .onChange(of: store.validationRequestID) { _, _ in + guard let repositoryID = validationRepositoryID else { + return + } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(repositoryID, anchor: .center) + } + } } .frame(maxHeight: 340) .clipShape(.rect(cornerRadius: 8)) @@ -182,21 +205,7 @@ struct WorkspaceCreationPromptView: View { .fontWeight(.medium) .lineLimit(1) - Picker( - "Source", - selection: Binding( - get: { repository.sourceKind }, - set: { store.send(.repositorySourceKindChanged(repository.id, $0)) } - ) - ) { - ForEach(sourceKinds(for: repository), id: \.self) { kind in - Text(sourceKindTitle(kind)).tag(kind) - } - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 150) - .disabled(store.isCreating) + sourceKindBadge(repository.sourceKind) Spacer() @@ -212,104 +221,133 @@ struct WorkspaceCreationPromptView: View { } } - private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { + private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) + -> some View + { HStack(spacing: 8) { - TextField( - "Name", - text: Binding( - get: { repository.name }, - set: { store.send(.repositoryNameChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + "Repository name", + text: Binding( + get: { repository.name }, + set: { store.send(.repositoryNameChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .name)) + } + helpText("Display name for this repository in the workspace metadata.") + } - TextField( - "Workspace path", - text: Binding( - get: { repository.path ?? "" }, - set: { store.send(.repositoryPathChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + Text("Folder inside workspace") + .foregroundStyle(.secondary) + TextField( + "Folder name", + text: Binding( + get: { repository.path ?? "" }, + set: { store.send(.repositoryPathChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + helpText( + "Destination folder under the workspace root. It does not change the original source path." + ) + } } } private func repositorySourceField(_ repository: ProjectWorkspaceCreationRepository) -> some View { - HStack(spacing: 8) { - TextField( - sourceLocationPlaceholder(repository.sourceKind), - text: Binding( - get: { repository.sourceLocation }, - set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + TextField( + sourceLocationPlaceholder(repository.sourceKind), + text: Binding( + get: { repository.sourceLocation }, + set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .source)) + } - if repository.sourceKind != .remote { - Button { - chooseSource(for: repository) - } label: { - Image(systemName: "folder") - .accessibilityLabel("Choose Repository Source") + if repository.sourceKind != .remote { + Button { + chooseSource(for: repository) + } label: { + Image(systemName: "folder") + .accessibilityLabel("Choose Repository Source") + } + .help("Choose Repository Source") + .disabled(store.isCreating) } - .help("Choose Repository Source") - .disabled(store.isCreating) } + helpText(sourceLocationHelpText(repository.sourceKind)) } } private func repositoryBranchFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { - HStack(spacing: 8) { - Picker( - "Branch action", - selection: Binding( - get: { repository.checkoutMode }, - set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } - ) - ) { - if repository.sourceKind.supportsLinkCheckout { - Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Picker( + "Branch action", + selection: Binding( + get: { repository.checkoutMode }, + set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } + ) + ) { + if repository.sourceKind.supportsLinkCheckout { + Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + } + Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) + Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) } - Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) - Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 150) - .help("Choose Branch Action") - .disabled(store.isCreating) + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 150) + .help("Choose Branch Action") + .disabled(store.isCreating) - if repository.checkoutMode == .createBranch { - TextField( - "Branch", - text: Binding( - get: { repository.branchName ?? "" }, - set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + if repository.checkoutMode == .createBranch { + TextField( + "Branch", + text: Binding( + get: { repository.branchName ?? "" }, + set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) - } + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .branchName)) + } + } - if repository.checkoutMode == .link { - Text("Symlink to the repository as it is on disk") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - WorkspaceBranchRefPickerView( - title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", - selection: repository.baseRef, - options: repository.baseRefOptions, - isDisabled: store.isCreating || repository.baseRefOptions.isEmpty - ) { ref in - store.send(.repositoryBaseRefChanged(repository.id, ref)) + if repository.checkoutMode != .link { + WorkspaceBranchRefPickerView( + title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", + selection: repository.baseRef, + options: repository.baseRefOptions, + isDisabled: store.isCreating || repository.baseRefOptions.isEmpty, + isInvalid: repositoryFieldIsInvalid(repository, .baseRef) + ) { ref in + store.send(.repositoryBaseRefChanged(repository.id, ref)) + } + .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } - .disabled(store.isCreating || repository.baseRefOptions.isEmpty) + } + helpText(branchActionHelpText(repository)) + + if repository.checkoutMode != .link { if let localBranchName = repository.resettableLocalBranchName { VStack(alignment: .leading, spacing: 4) { Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") @@ -335,14 +373,6 @@ struct WorkspaceCreationPromptView: View { } } - private func sourceKinds( - for repository: ProjectWorkspaceCreationRepository - ) -> [ProjectWorkspaceRepositorySourceKind] { - repository.sourceKind == .remote - ? sourceKinds - : sourceKinds.filter { $0 != .remote } - } - @ViewBuilder private func remoteRepositoryPromptView() -> some View { if let prompt = store.remoteRepositoryPrompt { @@ -363,6 +393,7 @@ struct WorkspaceCreationPromptView: View { .textFieldStyle(.roundedBorder) .font(.body.monospaced()) .disabled(prompt.isLoading) + helpText("Remote git URL to clone into the workspace, such as SSH or HTTPS.") } VStack(alignment: .leading, spacing: 8) { @@ -377,12 +408,15 @@ struct WorkspaceCreationPromptView: View { ) .textFieldStyle(.roundedBorder) .disabled(prompt.isLoading) + helpText("Display name and default folder name for this remote repository.") } if !prompt.branchOptions.isEmpty { Text("\(prompt.branchOptions.count) remote branches loaded") .font(.footnote) .foregroundStyle(.secondary) + } else { + helpText("Load branches before adding so Prowl can choose an existing branch safely.") } if let message = prompt.validationMessage, !message.isEmpty { @@ -402,13 +436,13 @@ struct WorkspaceCreationPromptView: View { } .keyboardShortcut(.cancelAction) .help("Cancel (Esc)") - .disabled(prompt.isLoading) Button("Load") { store.send(.remoteRepositoryPromptLoadButtonTapped) } .help("Load Remote Branches") - .disabled(prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Button("Add") { store.send(.remoteRepositoryPromptAddButtonTapped) @@ -450,7 +484,8 @@ struct WorkspaceCreationPromptView: View { } private func chooseSource(for repository: ProjectWorkspaceCreationRepository) { - let panel = repositorySourcePanel(kind: repository.sourceKind, currentPath: repository.sourceLocation) + let panel = repositorySourcePanel( + kind: repository.sourceKind, currentPath: repository.sourceLocation) panel.begin { response in guard response == .OK, let url = panel.url else { return @@ -472,16 +507,17 @@ struct WorkspaceCreationPromptView: View { if let currentPath, !currentPath.isEmpty { panel.directoryURL = URL(filePath: currentPath).deletingLastPathComponent() } - panel.message = kind == .bareRepository ? "Choose a bare repository folder" : "Choose a repository folder" + panel.message = + kind == .bareRepository ? "Choose a bare repository folder" : "Choose a repository folder" return panel } private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath: - return "Opened Path" + return "Opened in Prowl" case .localRepository: - return "Local Repo" + return "Picked from Disk" case .remote: return "Remote Clone" case .bareRepository: @@ -489,6 +525,41 @@ struct WorkspaceCreationPromptView: View { } } + private func sourceKindIcon(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "folder.badge.plus" + case .localRepository: + return "folder" + case .remote: + return "network" + case .bareRepository: + return "externaldrive" + } + } + + private func sourceKindBadgeHelp(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "Added from repositories already opened in Prowl." + case .localRepository: + return "Added by choosing a repository folder from disk." + case .remote: + return "Added from a remote URL and cloned into the workspace." + case .bareRepository: + return "Added from a local bare repository." + } + } + + private func sourceKindBadge(_ kind: ProjectWorkspaceRepositorySourceKind) -> some View { + Label(sourceKindTitle(kind), systemImage: sourceKindIcon(kind)) + .font(.caption) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .help(sourceKindBadgeHelp(kind)) + } + private func sourceLocationPlaceholder(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath, .localRepository: @@ -499,6 +570,68 @@ struct WorkspaceCreationPromptView: View { return "Bare repository folder" } } + + private func sourceLocationHelpText(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return + "Existing opened repository path. Link keeps using this checkout; " + + "branch actions create workspace worktrees from it." + case .localRepository: + return + "Local repository folder on disk. It can be linked as-is or used as the source for a workspace worktree." + case .remote: + return "Remote URL cloned into the workspace folder after branches are loaded." + case .bareRepository: + return "Advanced source: a local bare repository used only for git worktree materialization." + } + } + + private func branchActionHelpText(_ repository: ProjectWorkspaceCreationRepository) -> String { + switch repository.checkoutMode { + case .link: + return + "Link adds a symlink to the source checkout, so workspace edits affect the original folder directly." + case .createBranch: + return + "Create Branch materializes an isolated checkout on a new branch from the selected base ref." + case .useExistingRef: + if repository.resettableLocalBranchName != nil { + return + "Use Existing checks out the selected branch. " + + "If a matching local branch already exists, choose whether to keep or reset it." + } + return + "Use Existing checks out the selected local branch or creates a local tracking branch from a remote ref." + } + } + + private func helpText(_ text: String) -> some View { + Text(text) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private var validationRepositoryID: Repository.ID? { + guard case .repository(let repositoryID, _) = store.validationTarget else { + return nil + } + return repositoryID + } + + private func repositoryFieldIsInvalid( + _ repository: ProjectWorkspaceCreationRepository, + _ field: WorkspaceCreationPromptFeature.RepositoryField + ) -> Bool { + store.validationTarget == .repository(repository.id, field) + } + + private func invalidFieldBorder(_ isInvalid: Bool) -> some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } } private struct WorkspaceBranchRefPickerView: View { @@ -506,6 +639,7 @@ private struct WorkspaceBranchRefPickerView: View { let selection: String? let options: [GitBranchRefOption] let isDisabled: Bool + let isInvalid: Bool let onSelect: (String) -> Void @State private var isPresented = false @@ -527,6 +661,9 @@ private struct WorkspaceBranchRefPickerView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.bordered) + .overlay { + invalidFieldBorder + } .help("Choose \(title)") .disabled(isDisabled) .popover(isPresented: $isPresented, arrowEdge: .bottom) { @@ -587,6 +724,12 @@ private struct WorkspaceBranchRefPickerView: View { return selection } + private var invalidFieldBorder: some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } + private var groupedOptions: [(kind: GitBranchRefKind, options: [GitBranchRefOption])] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let filtered = diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift index a827c189..9bab292d 100644 --- a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -38,13 +38,18 @@ struct WorkspaceDetailView: View { .font(.subheadline.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) - Text("\(workspace.repositories.count) repositories") + Text(repositoryCountText) .font(.subheadline) .foregroundStyle(.tertiary) } } } + private var repositoryCountText: String { + workspace.repositories.count == 1 + ? "1 repository" : "\(workspace.repositories.count) repositories" + } + private var taskLinks: some View { VStack(alignment: .leading, spacing: 6) { Text("Task Links") diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index df0dcb7d..f3f3be28 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -5,6 +5,7 @@ struct WorktreeRow: View { let name: String let worktreeName: String let info: WorktreeInfoEntry? + let iconSystemName: String? let showsPullRequestInfo: Bool let isHovered: Bool let isPinned: Bool @@ -25,9 +26,57 @@ struct WorktreeRow: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings + init( + name: String, + worktreeName: String, + info: WorktreeInfoEntry?, + iconSystemName: String? = nil, + showsPullRequestInfo: Bool, + isHovered: Bool, + isPinned: Bool, + isMainWorktree: Bool, + isLoading: Bool, + taskStatus: WorktreeTaskStatus?, + isRunScriptRunning: Bool, + showsNotificationIndicator: Bool, + notifications: [WorktreeTerminalNotification], + onFocusNotification: @escaping (WorktreeTerminalNotification) -> Void, + shortcutHint: String?, + showsShortcutHint: Bool, + pinAction: (() -> Void)?, + isSelected: Bool, + archiveAction: (() -> Void)?, + onDiffTap: (() -> Void)?, + onStopRunScript: (() -> Void)? + ) { + self.name = name + self.worktreeName = worktreeName + self.info = info + self.iconSystemName = iconSystemName + self.showsPullRequestInfo = showsPullRequestInfo + self.isHovered = isHovered + self.isPinned = isPinned + self.isMainWorktree = isMainWorktree + self.isLoading = isLoading + self.taskStatus = taskStatus + self.isRunScriptRunning = isRunScriptRunning + self.showsNotificationIndicator = showsNotificationIndicator + self.notifications = notifications + self.onFocusNotification = onFocusNotification + self.shortcutHint = shortcutHint + self.showsShortcutHint = showsShortcutHint + self.pinAction = pinAction + self.isSelected = isSelected + self.archiveAction = archiveAction + self.onDiffTap = onDiffTap + self.onStopRunScript = onStopRunScript + } + var body: some View { let showsSpinner = isLoading || taskStatus == .running - let branchIconName = isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch") + let branchIconName = + iconSystemName + ?? (isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch")) let display = WorktreePullRequestDisplay( worktreeName: name, pullRequest: showsPullRequestInfo ? info?.pullRequest : nil @@ -35,7 +84,8 @@ struct WorktreeRow: View { let displayAddedLines = info?.addedLines let displayRemovedLines = info?.removedLines let mergeReadiness = pullRequestMergeReadiness(for: display.pullRequest) - let isQueued = display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil + let isQueued = + display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil let hasChangeCounts = displayAddedLines != nil && displayRemovedLines != nil let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil let nameColor = colorScheme == .dark ? Color.white : Color.primary @@ -265,11 +315,16 @@ private struct WorktreeRowPreview: View { addedLines: 632, removedLines: 344 ) - row(id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) + row( + id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) row(id: "running", name: "feature/auth-flow", worktreeName: "auth-flow", taskStatus: .running) row(id: "loading", name: "creating-worktree...", worktreeName: "Setting up", isLoading: true) - row(id: "notif", name: "feature/notifications", worktreeName: "notifications", showsNotificationIndicator: true) - row(id: "script", name: "feature/run-script", worktreeName: "run-script", isRunScriptRunning: true) + row( + id: "notif", name: "feature/notifications", worktreeName: "notifications", + showsNotificationIndicator: true) + row( + id: "script", name: "feature/run-script", worktreeName: "run-script", + isRunScriptRunning: true) row(id: "hint", name: "feature/shortcuts", worktreeName: "shortcuts", shortcutHint: "⌘1") row(id: "selected", name: "feature/selected", worktreeName: "selected", isSelected: true) } @@ -356,7 +411,9 @@ private struct WorktreeRowChangeCountView: View { .fixedSize(horizontal: true, vertical: false) .overlay { Capsule() - .stroke(isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) + .stroke( + isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), + lineWidth: 1) } .monospacedDigit() } diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index d780b6d0..adad553a 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -140,8 +140,10 @@ struct RepositorySettingsView: View { isBranchPickerPresented = true } label: { HStack { - Text(store.settings.worktreeBaseRef ?? "Automatic (\(store.defaultWorktreeBaseRef))") - .foregroundStyle(.primary) + Text( + store.settings.worktreeBaseRef ?? "Automatic (\(store.defaultWorktreeBaseRef))" + ) + .foregroundStyle(.primary) Spacer() Image(systemName: "chevron.up.chevron.down") .foregroundStyle(.secondary) @@ -169,7 +171,7 @@ struct RepositorySettingsView: View { } } header: { VStack(alignment: .leading, spacing: 4) { - Text("Branch new workspaces from") + Text("Branch new worktrees from") Text("Each workspace is an isolated copy of your codebase.") .foregroundStyle(.secondary) } @@ -183,8 +185,10 @@ struct RepositorySettingsView: View { ) .textFieldStyle(.roundedBorder) - Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") - .foregroundStyle(.secondary) + Text( + "Set a repository-specific worktree base directory. Leave empty to inherit the global setting." + ) + .foregroundStyle(.secondary) Text("Example new worktree path: \(exampleWorktreePath)") .foregroundStyle(.secondary) .monospaced() @@ -370,8 +374,10 @@ struct RepositorySettingsView: View { } header: { VStack(alignment: .leading, spacing: 4) { Text("Custom Commands") - Text("Repository-local terminal actions. Custom command shortcuts take precedence in this repository.") - .foregroundStyle(.secondary) + Text( + "Repository-local terminal actions. Custom command shortcuts take precedence in this repository." + ) + .foregroundStyle(.secondary) } } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4dd83d17..1f9b55cb 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -87,6 +87,8 @@ final class WorktreeTerminalManager { Task { createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) } + case .focusOrCreateTabInDirectory(let worktree, let directory, let title): + state(for: worktree).focusOrCreateTab(boundToDirectory: directory, title: title) case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index e0ea530b..3da162b5 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -320,6 +320,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeAll() cleanupAllAgentDetectionState() tabIsRunningById.removeAll() + boundDirectoryTabIDs.removeAll() autoCloseSurfaceIds.removeAll() pendingCustomCommands.removeAll() setRunScriptTabId(nil) @@ -805,6 +806,7 @@ extension WorktreeTerminalState { if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) + removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if tabId == runScriptTabId { setRunScriptTabId(nil) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8faf1861..dbd7bebc 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -76,6 +76,7 @@ final class WorktreeTerminalState { var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] + var boundDirectoryTabIDs: [String: TerminalTabID] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var runScriptTabId: TerminalTabID? var pendingSetupScript: Bool @@ -291,6 +292,7 @@ final class WorktreeTerminalState { @discardableResult func createTab( focusing: Bool = true, + title: String? = nil, setupScript: String? = nil, initialInput: String? = nil, inheritingFromSurfaceId: UUID? = nil, @@ -298,7 +300,7 @@ final class WorktreeTerminalState { ) -> TerminalTabID? { let context = GHOSTTY_SURFACE_CONTEXT_TAB let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() - let title = "\(worktree.name) \(nextTabIndex())" + let title = title ?? "\(worktree.name) \(nextTabIndex())" let setupInput = setupScriptInput(setupScript: setupScript) let commandInput = initialInput.flatMap { runScriptInput($0) } let resolvedInput: String? @@ -334,6 +336,36 @@ final class WorktreeTerminalState { return tabId } + @discardableResult + func focusOrCreateTab( + boundToDirectory directory: URL, + title: String? + ) -> TerminalTabID? { + let directoryKey = boundDirectoryKey(for: directory) + if let tabId = boundDirectoryTabIDs[directoryKey], + tabManager.tabs.contains(where: { $0.id == tabId }) + { + selectTab(tabId) + return tabId + } + boundDirectoryTabIDs.removeValue(forKey: directoryKey) + + if let tabId = tabID(withWorkingDirectoryKey: directoryKey) { + boundDirectoryTabIDs[directoryKey] = tabId + selectTab(tabId) + return tabId + } + + let tabId = createTab( + title: title, + workingDirectoryOverride: directory + ) + if let tabId { + boundDirectoryTabIDs[directoryKey] = tabId + } + return tabId + } + @discardableResult func runScript(_ script: String) -> TerminalTabID? { guard let input = runScriptInput(script) else { return nil } @@ -564,6 +596,7 @@ final class WorktreeTerminalState { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) + removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if let selected = tabManager.selectedTabId { focusSurface(in: selected) @@ -621,6 +654,35 @@ final class WorktreeTerminalState { return formatCommandInput(script) } + func removeBoundDirectoryTab(_ tabId: TerminalTabID) { + boundDirectoryTabIDs = boundDirectoryTabIDs.filter { $0.value != tabId } + } + + private func tabID(withWorkingDirectoryKey directoryKey: String) -> TerminalTabID? { + for tab in tabManager.tabs { + let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] + let hasMatchingPane = paneIDs.contains { paneID in + guard + let workingDirectory = inheritedSurfaceConfig( + fromSurfaceId: paneID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory + else { + return false + } + return boundDirectoryKey(for: workingDirectory) == directoryKey + } + if hasMatchingPane { + return tab.id + } + } + return nil + } + + private func boundDirectoryKey(for url: URL) -> String { + url.standardizedFileURL.path(percentEncoded: false) + } + // Env vars are injected into the surface's shell process via // `GhosttySurfaceView(environment:)`, so scripts no longer need a shell // export prefix. diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 67876e07..d1ac4988 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -36,9 +36,13 @@ struct ProjectWorkspaceTests { } @Test func remoteNamingStripsOnlyTrailingGitSuffix() { - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") == "x.github.io") - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") + == "x.github.io") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") } @Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { @@ -78,6 +82,7 @@ struct ProjectWorkspaceTests { let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false) #expect(workspace.id == rootPath) + #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.title == "Multi Repo Task") #expect(workspace.description == "") #expect(workspace.taskLinks == []) @@ -208,13 +213,16 @@ struct ProjectWorkspaceTests { createdAt: createdAt ), gitRunner: ProjectWorkspaceGitRunner { command in - throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "unexpected") + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: command.displayCommand, message: "unexpected") } ) #expect(workspace.title == "Checkout Flow") + #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.createdAt == createdAt) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(loaded.repositories.map(\.path) == ["App-Repo", "App-Repo-2"]) #expect(loaded.repositories.map(\.sourceKind) == [.existingPath, .existingPath]) let appPath = normalizedTestPath(appURL) @@ -228,8 +236,12 @@ struct ProjectWorkspaceTests { let appLinkPath = rootURL.appending(path: "App-Repo").path(percentEncoded: false) let apiLinkPath = rootURL.appending(path: "App-Repo-2").path(percentEncoded: false) - #expect(URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == appPath) - #expect(URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == apiPath) + #expect( + URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) + == appPath) + #expect( + URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) + == apiPath) } @Test func createWorkspaceMaterializesRemoteCloneAndBareWorktree() async throws { @@ -280,12 +292,18 @@ struct ProjectWorkspaceTests { #expect( commands.value.map(\.arguments) == [ ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], - ["-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main"], - ["-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "--end-of-options", "main"], + [ + "-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main", + ], + [ + "-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", + "--end-of-options", "main", + ], ]) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) - #expect(loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) + #expect( + loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) #expect(loaded.repositories.map(\.branchName) == ["codex/app", "codex/api"]) #expect(loaded.repositories.map(\.baseRef) == ["origin/main", "main"]) } @@ -367,7 +385,8 @@ struct ProjectWorkspaceTests { path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", - checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + checkout: .trackRemoteRef( + remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ProjectWorkspaceRepositoryPlan( id: "maker", @@ -375,7 +394,8 @@ struct ProjectWorkspaceTests { path: nil, sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), - checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + checkout: .trackRemoteRef( + remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ] ), @@ -398,7 +418,8 @@ struct ProjectWorkspaceTests { ], ]) #expect(workspace.repositories.map(\.path) == ["app", "maker"]) - #expect(workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) + #expect( + workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) #expect( workspace.repositories.map(\.baseRef) == ["origin/chore/disable-spine", "origin/chore/disable-spine"] @@ -445,6 +466,15 @@ struct ProjectWorkspaceTests { ) } + @Test func localBranchNameForRemoteRefKeepsSlashedBranchName() { + #expect( + ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "origin/chore/x") + == "chore/x") + #expect( + ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "upstream/main") == "main") + #expect(ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "main") == nil) + } + @Test func planResetsLocalBranchToRemoteWhenChosen() { var repository = ProjectWorkspaceCreationRepository( id: "maker", @@ -468,7 +498,8 @@ struct ProjectWorkspaceTests { @Test func defaultRepositoryNameStripsGitSuffix() { #expect( - WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker.git")) + WorkspaceCreationPromptFeature.defaultRepositoryName( + for: URL(fileURLWithPath: "/tmp/maker.git")) == "maker" ) #expect( @@ -525,7 +556,10 @@ struct ProjectWorkspaceTests { let apiPath = normalizedTestPath(apiURL) #expect( commands.value.map(\.arguments) == [ - ["-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", "--end-of-options", "main"], + [ + "-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", + "--end-of-options", "main", + ], ["-C", apiPath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "origin/main"], ]) #expect(workspace.repositories.map(\.branchName) == ["codex/app", nil]) @@ -571,7 +605,8 @@ struct ProjectWorkspaceTests { gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } if command.arguments.first == "clone" { - try FileManager.default.createDirectory(at: cloneDestination, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: cloneDestination, withIntermediateDirectories: true) } if command.arguments.contains("checkout") { throw ProjectWorkspaceCreationError.gitCommandFailed( @@ -851,7 +886,8 @@ struct ProjectWorkspaceTests { workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { _ in - throw ProjectWorkspaceCreationError.gitCommandFailed(command: "git worktree remove", message: "broken") + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: "git worktree remove", message: "broken") } ) @@ -904,7 +940,8 @@ struct ProjectWorkspaceTests { private func writeWorkspaceJSON(_ json: String, to rootURL: URL) throws { let metadataDirectoryURL = rootURL.appending(path: ProjectWorkspace.metadataDirectoryName) - try FileManager.default.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: metadataDirectoryURL, withIntermediateDirectories: true) try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 6ebb1a9b..d3e446de 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -333,7 +333,8 @@ struct RepositoriesFeatureTests { $0.gitClient.worktrees = { _ in [existingWorktree, discoveredWorktree] } } - await store.send(.worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) + await store.send( + .worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories[id: repository.id] = makeRepository( @@ -545,7 +546,8 @@ struct RepositoriesFeatureTests { workspace: workspace ) - let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path(percentEncoded: false) + let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path( + percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { @@ -554,7 +556,8 @@ struct RepositoriesFeatureTests { } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in - Issue.record("workspace should load as plain without git probing: \(url.path(percentEncoded: false))") + Issue.record( + "workspace should load as plain without git probing: \(url.path(percentEncoded: false))") return url } $0.gitClient.worktrees = { url in @@ -905,7 +908,8 @@ struct RepositoriesFeatureTests { } await store.receive( .remoteRepositoryPromptLoaded( - "git@github.com:onevcat/app.git", GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) + "git@github.com:onevcat/app.git", + GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) ) { $0.remoteRepositoryPrompt?.isLoading = false $0.remoteRepositoryPrompt?.branchOptions = options @@ -927,6 +931,43 @@ struct RepositoriesFeatureTests { } } + @Test func workspaceCreationPromptFolderFollowsTitleUntilEdited() async { + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + ) { + WorkspaceCreationPromptFeature() + } + + let requestedRootPath = defaultWorkspaceBaseRootPath(for: "Client App") + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: "Client App") + await store.send(.titleChanged("Client App")) { + $0.title = "Client App" + $0.rootPath = requestedRootPath + } + if resolvedRootPath == requestedRootPath { + await store.receive( + .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) + ) + } else { + await store.receive( + .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) + ) { + $0.rootPath = resolvedRootPath + } + } + await store.send(.rootPathChanged("/tmp/manual-workspace")) { + $0.rootPath = "/tmp/manual-workspace" + $0.isRootPathDirty = true + } + await store.send(.titleChanged("Other Title")) { + $0.title = "Other Title" + } + } + @Test func workspaceCreationPromptRemoteLoadUsesDetectedDefaultFromOptions() async { let store = TestStore( initialState: WorkspaceCreationPromptFeature.State( @@ -1038,7 +1079,9 @@ struct RepositoriesFeatureTests { WorkspaceCreationPromptFeature() } - await store.send(.repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git")) { + await store.send( + .repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git") + ) { $0.repositories[id: repositoryID]?.sourceLocation = "git@github.com:onevcat/other.git" $0.repositories[id: repositoryID]?.baseRef = nil $0.repositories[id: repositoryID]?.baseRefOptions = [] @@ -1105,10 +1148,13 @@ struct RepositoriesFeatureTests { await store.send(.createButtonTapped) { $0.validationMessage = "Choose an existing branch for Repo A." + $0.validationTarget = .repository(repoRootA, .baseRef) + $0.validationRequestID = 1 } await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { $0.repositories[id: repoRootA]?.baseRef = "main" $0.validationMessage = nil + $0.validationTarget = nil } } @@ -1141,10 +1187,13 @@ struct RepositoriesFeatureTests { await store.send(.createButtonTapped) { $0.validationMessage = "Branch name required for Remote." + $0.validationTarget = .repository("remote", .branchName) + $0.validationRequestID = 1 } await store.send(.repositoryBranchNameChanged("remote", "codex/app")) { $0.repositories[id: "remote"]?.branchName = "codex/app" $0.validationMessage = nil + $0.validationTarget = nil } } @@ -1327,7 +1376,8 @@ struct RepositoriesFeatureTests { $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.gitClient.deleteLocalBranch = { name, url, force in deleteCalls.withValue { - $0.append(DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) + $0.append( + DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) } return .deleted } @@ -1488,7 +1538,8 @@ struct RepositoriesFeatureTests { } store.exhaustivity = .off - await store.send(.repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) + await store.send( + .repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) await store.finish() #expect(savedEntries.value.last == []) @@ -1513,6 +1564,66 @@ struct RepositoriesFeatureTests { await store.receive(\.showToast, .warning("Workspace creation canceled")) } + @Test func workspaceCreationSuccessOpensWorkspaceAndShowsToast() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-feature-success-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + defer { try? FileManager.default.removeItem(at: rootURL) } + var initialState = RepositoriesFeature.State() + initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Feature", + rootPath: rootURL.path(percentEncoded: false) + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_700_000_000) + $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + let draft = ProjectWorkspaceCreationDraft( + title: "Feature", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkout: .useExistingRef("origin/main") + ), + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/api.git", + checkout: .useExistingRef("origin/main") + ), + ] + ) + + await store.send(.workspaceCreation(.createWorkspace(draft))) { + $0.workspaceCreationPrompt?.isCreating = true + } + await store.receive(\.workspaceCreation.workspaceCreated) { + $0.workspaceCreationPrompt = nil + } + await store.receive(\.showToast, .success("Workspace created")) { + $0.statusToast = .success("Workspace created") + } + await store.receive(\.repositoryManagement.openRepositories) + await store.finish() + + #expect(ProjectWorkspace.load(from: rootURL)?.title == "Feature") + } + @Test func workspaceBaseRefsLoadErrorSurfacesValidationMessage() async { let repositoryID = "/tmp/repo" var initialState = RepositoriesFeature.State() @@ -1664,7 +1775,8 @@ struct RepositoriesFeatureTests { return URL(fileURLWithPath: ancestorRoot) } $0.gitClient.worktrees = { url in - Issue.record("downgraded git entry should not load worktrees: \(url.path(percentEncoded: false))") + Issue.record( + "downgraded git entry should not load worktrees: \(url.path(percentEncoded: false))") return [] } } @@ -2962,7 +3074,8 @@ struct RepositoriesFeatureTests { $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) - continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) + continuation.yield( + .outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) continuation.yield(.finished(createdWorktree)) continuation.finish() } @@ -3099,7 +3212,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.remoteNames = { _ in ["origin"] } $0.gitClient.fetchRemote = { _, _ in - throw NSError(domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) + throw NSError( + domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } @@ -3223,7 +3337,8 @@ struct RepositoriesFeatureTests { #expect(observedBaseDirectory.value == expectedBaseDirectory) } - @Test(.dependencies) func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { + @Test(.dependencies) + func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3275,7 +3390,9 @@ struct RepositoriesFeatureTests { #expect(observedBaseDirectory.value == expectedBaseDirectory) } - @Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() async { + @Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3306,7 +3423,9 @@ struct RepositoriesFeatureTests { $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in - observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } + observedCopyFlags.withValue { + $0 = (request.copyFiles.ignored, request.copyFiles.untracked) + } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -3355,7 +3474,9 @@ struct RepositoriesFeatureTests { $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in - observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } + observedCopyFlags.withValue { + $0 = (request.copyFiles.ignored, request.copyFiles.untracked) + } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -3373,7 +3494,9 @@ struct RepositoriesFeatureTests { #expect(observedCopyFlags.value?.1 == false) } - @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { + @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3394,7 +3517,8 @@ struct RepositoriesFeatureTests { $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) - continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) + continuation.finish( + throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) } } } @@ -3536,7 +3660,8 @@ struct RepositoriesFeatureTests { @Test func pendingProgressUpdateIsIgnoredAfterCreateFailureRemovesPendingWorktree() async { let repoRoot = "/tmp/repo" - let repository = makeRepository(id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) + let repository = makeRepository( + id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) let pendingID = "pending:test" var state = makeState(repositories: [repository]) state.selection = .worktree(pendingID) @@ -3603,7 +3728,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 1 @@ -3630,7 +3758,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: true ) $0.nextDeleteWorktreeConfirmationID = 1 @@ -3647,7 +3778,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete feature? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: true ) let forceDeleteAttempts = LockIsolated<[Bool]>([]) @@ -3663,7 +3797,8 @@ struct RepositoriesFeatureTests { if force { return .deleted } - throw GitClientError.commandFailed(command: "git branch -d feature", message: "not fully merged") + throw GitClientError.commandFailed( + command: "git branch -d feature", message: "not fully merged") } $0.gitClient.worktrees = { _ in [mainWorktree] } } @@ -3776,8 +3911,10 @@ struct RepositoriesFeatureTests { let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ - RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree1.id, repositoryID: repository.id), - RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree2.id, repositoryID: repository.id), + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree1.id, repositoryID: repository.id), + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() @@ -3813,7 +3950,9 @@ struct RepositoriesFeatureTests { TextState("Cancel") } } message: { - TextState("Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") + TextState( + "Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." + ) } await store.send(.worktreeLifecycle(.requestArchiveWorktree(worktree.id, repository.id))) { @@ -3826,8 +3965,10 @@ struct RepositoriesFeatureTests { let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ - RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree1.id, repositoryID: repository.id), - RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree2.id, repositoryID: repository.id), + RepositoriesFeature.ArchiveWorktreeTarget( + worktreeID: worktree1.id, repositoryID: repository.id), + RepositoriesFeature.ArchiveWorktreeTarget( + worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() @@ -3844,7 +3985,8 @@ struct RepositoriesFeatureTests { TextState("Cancel") } } message: { - TextState("Find them later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") + TextState( + "Find them later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") } await store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) { @@ -3915,13 +4057,16 @@ struct RepositoriesFeatureTests { AsyncThrowingStream { continuation in continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) - continuation.yield(.finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) + continuation.yield( + .finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) continuation.finish() } } } - await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { + await store.send( + .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) + ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", @@ -3995,7 +4140,9 @@ struct RepositoriesFeatureTests { TextState("Command failed: bash -lc exit 7\nstderr:\nfail") } - await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { + await store.send( + .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) + ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", @@ -4025,7 +4172,8 @@ struct RepositoriesFeatureTests { } await store.send( - .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + .worktreeLifecycle( + .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivedWorktrees.isEmpty) } @@ -4043,7 +4191,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) + await store.send( + .worktreeLifecycle( + .archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) #expect(store.state.alert == nil) #expect(store.state.archivedWorktrees.isEmpty) } @@ -4081,7 +4231,8 @@ struct RepositoriesFeatureTests { #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) await store.send( - .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + .worktreeLifecycle( + .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) @@ -4119,7 +4270,9 @@ struct RepositoriesFeatureTests { #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) - await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) + await store.send( + .worktreeLifecycle( + .archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) #expect(store.state.alert != nil) @@ -4247,7 +4400,8 @@ struct RepositoriesFeatureTests { let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) + let repository = makeRepository( + id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] @@ -4362,8 +4516,11 @@ struct RepositoriesFeatureTests { ) $0.repositories[id: repository.id] = repository } - #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") - #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) + #expect( + store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") + #expect( + store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt + == createdAt) } @Test func orderedWorktreeRowsAreGlobal() { @@ -4493,7 +4650,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3))) { + await store.send( + .worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3)) + ) { $0.worktreeOrderByRepository[repoRoot] = [worktree2.id, worktree3.id, worktree1.id] } } @@ -4512,7 +4671,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0))) { + await store.send( + .worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0)) + ) { $0.pinnedWorktreeIDs = [worktreeA2.id, worktreeB1.id, worktreeA1.id] } } @@ -4597,14 +4758,16 @@ struct RepositoriesFeatureTests { } await store.receive(\.delegate.repositoriesChanged) - #expect(store.state.archivedWorktrees == [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)]) + #expect( + store.state.archivedWorktrees == [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)]) } @Test func repositoriesLoadedSkipsSelectionChangeWhenOnlyDisplayDataChanges() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) - let updatedWorktree = makeWorktree(id: "/tmp/repo/main", name: "main-updated", repoRoot: repoRoot) + let updatedWorktree = makeWorktree( + id: "/tmp/repo/main", name: "main-updated", repoRoot: repoRoot) let updatedRepository = makeRepository(id: repoRoot, worktrees: [updatedWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id) @@ -4754,7 +4917,8 @@ struct RepositoriesFeatureTests { let existingWorktree = makeWorktree(id: "/tmp/repo/wt-main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [existingWorktree]) let newWorktree = makeWorktree(id: "/tmp/repo/wt-new", name: "new", repoRoot: repoRoot) - let updatedRepository = makeRepository(id: repoRoot, worktrees: [newWorktree, existingWorktree]) + let updatedRepository = makeRepository( + id: repoRoot, worktrees: [newWorktree, existingWorktree]) let pendingID = "pending:\(UUID().uuidString)" var initialState = makeState(repositories: [repository]) initialState.pendingWorktrees = [ @@ -4898,7 +5062,9 @@ struct RepositoriesFeatureTests { } } - @Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() async { + @Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let externalWorktree = makeWorktree( @@ -4968,7 +5134,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.mergedWorktreeAction = .archive @@ -4977,7 +5144,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let mergedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5013,7 +5181,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5021,7 +5190,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let mergedStrategies = LockIsolated<[PullRequestMergeStrategy]>([]) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { @@ -5063,8 +5233,10 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5114,7 +5286,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5169,7 +5342,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5177,7 +5351,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let closedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5409,10 +5584,11 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) } await store.receive(\.githubIntegration.refreshGithubIntegrationAvailability) { $0.githubIntegrationAvailability = .checking @@ -5455,10 +5631,11 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) } await store.finish() } @@ -5474,10 +5651,11 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable - initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { @@ -5514,20 +5692,22 @@ struct RepositoriesFeatureTests { var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available initialState.inFlightPullRequestRefreshRepositoryIDs = [repository.id] - initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) { $0.githubIntegrationAvailability = .unavailable - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -5543,10 +5723,11 @@ struct RepositoriesFeatureTests { @Test func githubIntegrationAvailabilityUpdatedWhileDisabledIsIgnored() async { var state = makeState(repositories: []) state.githubIntegrationAvailability = .disabled - state.pendingPullRequestRefreshByRepositoryID["repo"] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), - worktreeIDs: [] - ) + state.pendingPullRequestRefreshByRepositoryID["repo"] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + worktreeIDs: [] + ) let expectedState = state let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5727,7 +5908,9 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeLifecycle.worktreeDeleted) } - @Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() async { + @Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let expiredWorktree = makeWorktree(id: "/tmp/repo/expired", name: "expired", repoRoot: repoRoot) @@ -6318,8 +6501,10 @@ struct RepositoriesFeatureTests { let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature") applyWorkspaceChildrenInfo( [ - WorkspaceChildInfoUpdate(id: "/ws/app", branch: "feature", added: 7, removed: 2, pullRequest: pullRequest), - WorkspaceChildInfoUpdate(id: "/ws/api", branch: " ", added: 0, removed: 0, pullRequest: nil), + WorkspaceChildInfoUpdate( + id: "/ws/app", branch: "feature", added: 7, removed: 2, pullRequest: pullRequest), + WorkspaceChildInfoUpdate( + id: "/ws/api", branch: " ", added: 0, removed: 0, pullRequest: nil), ], state: &state ) @@ -6345,7 +6530,8 @@ struct RepositoriesFeatureTests { var state = makeState(repositories: [repository]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) state.workspaceChildBranchByID[childID] = "live-branch" - state.workspaceChildInfoByID[childID] = WorktreeInfoEntry(addedLines: 3, removedLines: 1, pullRequest: nil) + state.workspaceChildInfoByID[childID] = WorktreeInfoEntry( + addedLines: 3, removedLines: 1, pullRequest: nil) let rows = state.workspaceChildRows(in: repository) @@ -6372,6 +6558,50 @@ struct RepositoriesFeatureTests { #expect(rows.first?.info == nil) } + @Test func openWorkspaceChildFocusesOrCreatesBoundTerminalTabInChildDirectory() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-open-child", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.openWorkspaceChild(childID)) { + $0.selection = .repository(repository.id) + $0.selectedWorkspaceChildID = childID + $0.openedWorktreeIDs = [repository.id] + $0.pendingTerminalFocusWorktreeIDs = [repository.id] + } + await store.finish() + + let workspaceWorktree = Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + #expect( + sentCommands.value == [ + .focusOrCreateTabInDirectory( + workspaceWorktree, + directory: URL(fileURLWithPath: childID), + title: "App" + ) + ]) + } + @Test func repositoriesLoadedRefreshesAndPrunesWorkspaceChildren() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", @@ -6410,6 +6640,103 @@ struct RepositoriesFeatureTests { #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) } + @Test func openRepositoriesFinishedRefreshesWorkspaceChildren() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-open-refresh", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { _ in nil } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [repository], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [repository.rootURL] + )) + ) { + $0.repositories = [repository] + $0.repositoryRoots = [repository.rootURL] + $0.isInitialLoadComplete = true + $0.snapshotPersistencePhase = .active + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.workspaceChildrenInfoLoaded) { + $0.workspaceChildBranchByID = [childID: "feature/live"] + } + await store.finish() + } + + @Test func workspaceChildRefreshLoadsPullRequestWhenGithubIntegrationAvailable() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-pr", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature/live", number: 42) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + struct PullRequestBatchCall: Equatable, Sendable { + let host: String + let owner: String + let repo: String + let branches: [String] + } + let calls = LockIsolated<[PullRequestBatchCall]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { url in + #expect(url.path(percentEncoded: false) == childID) + return GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "app") + } + $0.githubCLI.batchPullRequests = { host, owner, repo, branches, accountOverride in + #expect(accountOverride == nil) + calls.withValue { + $0.append( + PullRequestBatchCall(host: host, owner: owner, repo: repo, branches: branches)) + } + return ["feature/live": pullRequest] + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send( + .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) + ) + await store.receive(\.workspaceChildrenInfoLoaded) + await store.finish() + + #expect(calls.value.count == 1) + #expect(calls.value.first?.host == "github.com") + #expect(calls.value.first?.owner == "onevcat") + #expect(calls.value.first?.repo == "app") + #expect(calls.value.first?.branches == ["feature/live"]) + #expect(store.state.workspaceChildInfoByID[childID]?.pullRequest == pullRequest) + } + private func makeWorktree( id: String, name: String, -- 2.51.2 From b1e0a39b4b1b93fd980257effc48188dac7e0388 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 16 Jun 2026 16:10:25 +0800 Subject: [PATCH 483/528] Use login shell for workspace git commands --- ...epositoriesFeature+WorkspaceCreation.swift | 2 +- supacodeTests/RepositoriesFeatureTests.swift | 53 +++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 7b0aa5a6..4127e795 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -200,7 +200,7 @@ extension RepositoriesFeature { static func workspaceGitRunner(shellClient: ShellClient) -> ProjectWorkspaceGitRunner { ProjectWorkspaceGitRunner { command in do { - _ = try await shellClient.run( + _ = try await shellClient.runLogin( URL(fileURLWithPath: "/usr/bin/env"), ["git"] + command.arguments, command.currentDirectoryURL diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index d3e446de..b670de54 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1373,7 +1373,7 @@ struct RepositoriesFeatureTests { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } - $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.gitClient.deleteLocalBranch = { name, url, force in deleteCalls.withValue { $0.append( @@ -1430,7 +1430,7 @@ struct RepositoriesFeatureTests { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } - $0.shellClient.run = { _, _, _ in + $0.shellClient.runLoginImpl = { _, _, _, _ in throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) } } @@ -1488,7 +1488,7 @@ struct RepositoriesFeatureTests { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } - $0.shellClient.run = { _, _, _ in + $0.shellClient.runLoginImpl = { _, _, _, _ in throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) } } @@ -1564,6 +1564,51 @@ struct RepositoriesFeatureTests { await store.receive(\.showToast, .warning("Workspace creation canceled")) } + @Test func workspaceGitRunnerUsesLoginShellForGitCommands() async throws { + struct LoginCall: Equatable, Sendable { + let executablePath: String + let arguments: [String] + let currentDirectoryPath: String? + } + let directRunCalls = LockIsolated(0) + let loginCalls = LockIsolated<[LoginCall]>([]) + let runner = RepositoriesFeature.workspaceGitRunner( + shellClient: ShellClient( + run: { _, _, _ in + directRunCalls.withValue { $0 += 1 } + throw ShellClientError(command: "git", stdout: "", stderr: "direct run used", exitCode: 1) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + loginCalls.withValue { + $0.append( + LoginCall( + executablePath: executableURL.path(percentEncoded: false), + arguments: arguments, + currentDirectoryPath: currentDirectoryURL?.path(percentEncoded: false) + )) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) + ) + + try await runner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", "/tmp/repo", "worktree", "add", "/tmp/workspace/repo"], + currentDirectoryURL: URL(fileURLWithPath: "/tmp/repo") + )) + + #expect(directRunCalls.value == 0) + #expect( + loginCalls.value == [ + LoginCall( + executablePath: "/usr/bin/env", + arguments: ["git", "-C", "/tmp/repo", "worktree", "add", "/tmp/workspace/repo"], + currentDirectoryPath: "/tmp/repo" + ) + ]) + } + @Test func workspaceCreationSuccessOpensWorkspaceAndShowsToast() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-feature-success-\(UUID().uuidString)", directoryHint: .isDirectory) @@ -1579,7 +1624,7 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_700_000_000) - $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } -- 2.51.2 From e0f51f27aebdeab58608034524c70a008a552fee Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:10:42 +0900 Subject: [PATCH 484/528] Fix workspace removal not auto-selecting next repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectionWasRemoved only checked selectedWorktreeID against the repository's worktrees array. Workspaces use .repository(id) selection and have an empty worktrees array, so the check was always false — leaving stale selection state and skipping the auto-select-first-after-reload logic. Include .repository(id) match in the selectionWasRemoved check. --- ...sitoriesFeature+RepositoryManagement.swift | 5 ++- supacodeTests/RepositoriesFeatureTests.swift | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 14c6ae34..4a019ea5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -220,9 +220,10 @@ extension RepositoriesFeature { } state.removingRepositoryIDs.insert(repository.id) let selectionWasRemoved = - state.selectedWorktreeID.map { id in + state.selection == .repository(repository.id) + || (state.selectedWorktreeID.map { id in repository.worktrees.contains(where: { $0.id == id }) - } ?? false + } ?? false) guard confirmation.deleteFiles, let workspace = repository.workspace else { return .send( .repositoryManagement( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index b670de54..dacc7cf6 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1337,6 +1337,49 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test func removeSelectedWorkspaceSelectsNextRepository() async { + let workspaceID = "/tmp/ws" + let otherWorktree = makeWorktree(id: "/tmp/other/main", name: "main", repoRoot: "/tmp/other") + let otherRepo = makeRepository(id: "/tmp/other", name: "other", worktrees: [otherWorktree]) + let wsRepo = makeRepository( + id: workspaceID, + name: "WS", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "WS") + ) + var state = makeState(repositories: [wsRepo, otherRepo]) + state.selection = .repository(workspaceID) + state.selectedWorkspaceChildID = "child-1" + let otherEntry = PersistedRepositoryEntry(path: "/tmp/other", kind: .git) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [otherEntry] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.repoRoot = { @Sendable url in url } + $0.gitClient.worktrees = { @Sendable _ in [otherWorktree] } + $0.gitClient.isBareRepository = { @Sendable _ in false } + } + store.exhaustivity = .off + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) + await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) + await store.receive(\.repositoryManagement.repositoryRemoved) + await store.receive(\.repositoriesLoaded) + await store.finish() + + #expect(store.state.selectedWorkspaceChildID == nil) + #expect(store.state.selectedWorkspaceChildID == nil) + // After removing the currently-selected workspace with another repo available, + // the sidebar should auto-select the next item — not leave an empty selection. + #expect( + store.state.selection == .worktree(otherWorktree.id), + "Expected auto-selection of next worktree, got \(String(describing: store.state.selection))" + ) + } + @Test func removeWorkspaceRoutesBranchDeletionThroughGuardedClient() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-ws-\(UUID().uuidString)") -- 2.51.2 From 41718f30533ab87e87e3dc7c83e5d45445870a99 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:14:41 +0900 Subject: [PATCH 485/528] Fix local branches named */HEAD being silently filtered branchRefOptions applied a hasSuffix("/HEAD") filter to local refs, but that filter's purpose is to drop remote symbolic HEAD pointers (e.g. origin/HEAD). A local branch like fix/HEAD was incorrectly excluded from the branch picker. Remove the filter from local refs (keep it on remote refs only) and simplify branchRefs to delegate entirely to branchRefOptions, which already handles sorting, filtering, and deduplication. --- supacode/Clients/Git/GitClient.swift | 7 +---- supacodeTests/GitClientBranchRefsTests.swift | 33 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 7feb7433..30d61b23 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -129,11 +129,7 @@ struct GitClient { } nonisolated func branchRefs(for repoRoot: URL) async throws -> [String] { - let options = try await branchRefOptions(for: repoRoot) - let refs = options.map(\.ref) - .filter { !$0.hasSuffix("/HEAD") } - .sorted { $0.localizedStandardCompare($1) == .orderedAscending } - return deduplicated(refs) + try await branchRefOptions(for: repoRoot).map(\.ref) } nonisolated func branchRefOptions(for repoRoot: URL) async throws -> [GitBranchRefOption] { @@ -159,7 +155,6 @@ struct GitClient { ] ) let localRefs = parseRefLines(localOutput, prefix: "refs/heads/") - .filter { !$0.hasSuffix("/HEAD") } .sorted { $0.localizedStandardCompare($1) == .orderedAscending } let remoteRefs = parseRefLines(remoteOutput, prefix: "refs/remotes/") // Drop `/HEAD` symbolic pointers: they resolve to a branch that is diff --git a/supacodeTests/GitClientBranchRefsTests.swift b/supacodeTests/GitClientBranchRefsTests.swift index b2f1bafb..96587a62 100644 --- a/supacodeTests/GitClientBranchRefsTests.swift +++ b/supacodeTests/GitClientBranchRefsTests.swift @@ -107,6 +107,39 @@ struct GitClientBranchRefsTests { #expect(options.contains(GitBranchRefOption(ref: "origin/main", kind: .remoteTracking))) } + @Test func branchRefOptionsPreservesLocalBranchNamedHEAD() async throws { + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("refs/heads") { + return ShellOutput( + stdout: "refs/heads/main\nrefs/heads/fix/HEAD\n", + stderr: "", + exitCode: 0 + ) + } + return ShellOutput( + stdout: "refs/remotes/origin/HEAD\nrefs/remotes/origin/main\n", + stderr: "", + exitCode: 0 + ) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + + let options = try await client.branchRefOptions(for: repoRoot) + + #expect( + options.contains(GitBranchRefOption(ref: "fix/HEAD", kind: .local)), + "Local branch fix/HEAD must not be filtered out" + ) + #expect( + !options.contains(GitBranchRefOption(ref: "origin/HEAD", kind: .remoteTracking)), + "Remote HEAD pointer should still be filtered" + ) + } + @Test func remoteBranchRefsParsesDefaultHeadAndBranches() async throws { let output = """ ref: refs/heads/main\tHEAD -- 2.51.2 From e0ff8a56b58d61dc70d008d0df57978ec37ad391 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:16:15 +0900 Subject: [PATCH 486/528] Avoid full workspace JSON decode in persistence normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RepositoryEntryNormalizer.normalize called ProjectWorkspace.load for every persisted entry to check if it should force kind to .plain. This performed synchronous Data(contentsOf:) + JSONDecoder.decode per entry on every save — wasteful when most entries are not workspaces. Replace with ProjectWorkspace.hasMetadata, a lightweight FileManager.fileExists check that only does a stat syscall. --- supacode/Domain/ProjectWorkspace.swift | 4 ++++ .../Settings/BusinessLogic/RepositoryPersistenceKeys.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index c33b3bd1..eda211fc 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -434,6 +434,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { .appending(path: metadataFileName) } + static func hasMetadata(at rootURL: URL) -> Bool { + FileManager.default.fileExists(atPath: metadataURL(for: rootURL).path(percentEncoded: false)) + } + static func load(from rootURL: URL) -> ProjectWorkspace? { let metadataURL = metadataURL(for: rootURL) guard let data = try? Data(contentsOf: metadataURL) else { diff --git a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift index 75bb222d..a90f67ee 100644 --- a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift @@ -205,7 +205,7 @@ nonisolated enum RepositoryEntryNormalizer { return order.compactMap { path in guard let kind = kindByPath[path] else { return nil } - if ProjectWorkspace.load(from: URL(fileURLWithPath: path)) != nil { + if ProjectWorkspace.hasMetadata(at: URL(fileURLWithPath: path)) { return PersistedRepositoryEntry(path: path, kind: .plain) } return PersistedRepositoryEntry(path: path, kind: kind) -- 2.51.2 From baca08eedd87773354dbeeb7ed977668707b7240 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:17:04 +0900 Subject: [PATCH 487/528] Parallelize independent git lookups in workspace base ref fetch automaticWorktreeBaseRef and branchRefOptions are independent git operations on the same repository. Running them with async let overlaps their latency instead of summing it, halving per-repository wait time during workspace creation. --- .../Reducer/RepositoriesFeature+WorkspaceCreation.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 4127e795..81e463eb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -260,11 +260,13 @@ extension RepositoriesFeature { repositoryURL = (try? await gitClient.repoRoot(sourceURL)) ?? sourceURL } - let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(repositoryURL) + async let automaticBaseRefTask = gitClient.automaticWorktreeBaseRef(repositoryURL) + async let refsTask = gitClient.branchRefOptions(repositoryURL) + let automaticBaseRef = await automaticBaseRefTask let refs: [GitBranchRefOption] var errorMessage: String? do { - refs = try await gitClient.branchRefOptions(repositoryURL) + refs = try await refsTask } catch { refs = [] let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) -- 2.51.2 From 1d1cb8a423c9356f1c57a81892a3a23e33507cf4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:19:31 +0900 Subject: [PATCH 488/528] Deduplicate workspace root path logic into ProjectWorkspace workspaceRootPath and uniqueWorkspaceRootPath were copy-pasted across WorkspaceCreationPromptFeature and RepositoriesFeature. Move both to ProjectWorkspace (which already owns defaultWorkspaceFolderName) so a future change only needs to touch one place. --- supacode/Domain/ProjectWorkspace.swift | 19 ++++++++++++++ ...epositoriesFeature+WorkspaceCreation.swift | 26 ++----------------- .../WorkspaceCreationPromptFeature.swift | 23 ++-------------- supacodeTests/RepositoriesFeatureTests.swift | 11 +------- 4 files changed, 24 insertions(+), 55 deletions(-) diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index eda211fc..881e337b 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -693,6 +693,25 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return sanitized.isEmpty ? "workspace" : sanitized } + nonisolated static func workspaceRootPath(folderName: String, suffix: Int?) -> String { + let component = suffix.map { "\(folderName)-\($0)" } ?? folderName + return SupacodePaths.workspacesDirectory + .appending(path: component, directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + } + + nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { + var suffix: Int? + while true { + let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) + if !FileManager.default.fileExists(atPath: candidate) { + return candidate + } + suffix = (suffix ?? 1) + 1 + } + } + private static func materialize( _ repository: ProjectWorkspaceRepositoryPlan, workspaceRootURL: URL, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 81e463eb..dc9a6e07 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -38,7 +38,7 @@ extension RepositoriesFeature { // then resolve a unique, non-colliding folder name off the main actor so // the reducer body performs no filesystem I/O. let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) - let requestedRootPath = Self.workspaceRootPath(folderName: folderName, suffix: nil) + let requestedRootPath = ProjectWorkspace.workspaceRootPath(folderName: folderName, suffix: nil) state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( repositories: [], title: title, @@ -46,7 +46,7 @@ extension RepositoriesFeature { openedRepositoryCandidates: candidates ) return .run { send in - let resolved = Self.uniqueWorkspaceRootPath(folderName: folderName) + let resolved = ProjectWorkspace.uniqueWorkspaceRootPath(folderName: folderName) await send(.workspaceCreation(.defaultRootPathResolved(path: resolved, requestedRootPath: requestedRootPath))) } @@ -140,28 +140,6 @@ extension RepositoriesFeature { } } - nonisolated private static func workspaceRootPath(folderName: String, suffix: Int?) -> String { - let component = suffix.map { "\(folderName)-\($0)" } ?? folderName - return SupacodePaths.workspacesDirectory - .appending(path: component, directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) - } - - // Resolves a workspace folder path that does not collide with an existing - // directory. Touches the filesystem, so it must run inside an effect rather - // than the reducer body. - nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { - var suffix: Int? - while true { - let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) - if !FileManager.default.fileExists(atPath: candidate) { - return candidate - } - suffix = (suffix ?? 1) + 1 - } - } - private static func trimmedNonEmpty(_ value: String?) -> String? { guard let value else { return nil diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 782d31d2..39c9094a 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -140,10 +140,10 @@ struct WorkspaceCreationPromptFeature { return .none } let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) - let requestedRootPath = Self.workspaceRootPath(folderName: folderName, suffix: nil) + let requestedRootPath = ProjectWorkspace.workspaceRootPath(folderName: folderName, suffix: nil) state.rootPath = requestedRootPath return .run { send in - let resolved = Self.uniqueWorkspaceRootPath(folderName: folderName) + let resolved = ProjectWorkspace.uniqueWorkspaceRootPath(folderName: folderName) await send( .automaticRootPathResolved(path: resolved, requestedRootPath: requestedRootPath)) } @@ -483,25 +483,6 @@ struct WorkspaceCreationPromptFeature { return String(name.dropLast(4)) } - nonisolated private static func workspaceRootPath(folderName: String, suffix: Int?) -> String { - let component = suffix.map { "\(folderName)-\($0)" } ?? folderName - return SupacodePaths.workspacesDirectory - .appending(path: component, directoryHint: .isDirectory) - .standardizedFileURL - .path(percentEncoded: false) - } - - nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { - var suffix: Int? - while true { - let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) - if !FileManager.default.fileExists(atPath: candidate) { - return candidate - } - suffix = (suffix ?? 1) + 1 - } - } - nonisolated private static func loadRemoteBranchRefs( _ url: String, gitClient: GitClientDependency diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index dacc7cf6..5b732951 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -7104,16 +7104,7 @@ func defaultWorkspaceBaseRootPath(for title: String) -> String { .path(percentEncoded: false) } -// Mirrors RepositoriesFeature.uniqueWorkspaceRootPath: the default folder name -// is uniqued against directories that already exist on the test machine. func expectedDefaultWorkspaceRootPath(for title: String) -> String { let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) - let baseURL = SupacodePaths.workspacesDirectory - var candidateURL = baseURL.appending(path: folderName, directoryHint: .isDirectory) - var suffix = 2 - while FileManager.default.fileExists(atPath: candidateURL.path(percentEncoded: false)) { - candidateURL = baseURL.appending(path: "\(folderName)-\(suffix)", directoryHint: .isDirectory) - suffix += 1 - } - return candidateURL.standardizedFileURL.path(percentEncoded: false) + return ProjectWorkspace.uniqueWorkspaceRootPath(folderName: folderName) } -- 2.51.2 From e41306cad865ee475d7fb7157a833d7f16f9571e Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:23:10 +0900 Subject: [PATCH 489/528] Document Swift Testing -only-testing format in AGENTS.md xcodebuild -only-testing silently matches nothing when a Swift Testing @Test function is referenced without trailing (). Add a note with both XCTest and Swift Testing identifier formats. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4741ab8c..002a2a32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,14 @@ xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platf CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation ``` +**Swift Testing vs XCTest `-only-testing` format**: Swift Testing (`@Test`) requires trailing `()` in the test identifier. Without it, `xcodebuild` silently matches nothing and reports `TEST SUCCEEDED` with zero tests run. +```bash +# XCTest (func testFoo) +-only-testing:supacodeTests/FooTests/testBar +# Swift Testing (@Test func bar) +-only-testing:"supacodeTests/FooTests/bar()" +``` + Requires [mise](https://mise.jdx.dev/) for zig, swiftlint, and xcsift tooling. ## Architecture -- 2.51.2 From 853d4a09e472b69baf81cd8236abe7bd4bad21da Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:29:14 +0900 Subject: [PATCH 490/528] Use branch icon for workspace child rows instead of folder The folder icon should only appear on the workspace parent row. Child repository rows now fall through to the default WorktreeRow icon (arrow.triangle.branch), matching regular worktree rows. --- .../Features/Repositories/Views/WorkspaceChildRowsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 7e738a68..a37f9457 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -48,7 +48,7 @@ private struct WorkspaceChildRowButton: View { name: row.branchName ?? row.repositoryName, worktreeName: row.branchName == nil ? "" : row.repositoryName, info: row.info, - iconSystemName: "folder", + iconSystemName: nil, showsPullRequestInfo: true, isHovered: false, isPinned: false, -- 2.51.2 From 3f36d340115256e2a4abf64dc655b7427549328a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 18 Jun 2026 23:33:36 +0900 Subject: [PATCH 491/528] Default workspace sidebar row to folder icon when no custom icon set Workspaces are visually distinct from git repos and plain folders. Show a folder SF Symbol by default so users can identify them at a glance without needing to set a custom icon first. --- .../Features/Repositories/Views/RepositorySectionView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index ab806783..a43265c5 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -54,7 +54,7 @@ struct RepositorySectionView: View { name: repository.name, customTitle: store.repositoryCustomTitles[repository.id], isRemoving: isRemovingRepository, - icon: appearance.icon, + icon: appearance.icon ?? (repository.isWorkspace ? .sfSymbol("folder") : nil), iconTint: appearance.color?.color ?? .accentColor, repositoryRootURL: repository.rootURL, nameTooltip: repository.capabilities.supportsWorktrees -- 2.51.2 From 18ca4dc7b8fca49f78203778cea302aac9c5d57d Mon Sep 17 00:00:00 2001 From: mlamp Date: Fri, 19 Jun 2026 04:08:26 +0300 Subject: [PATCH 492/528] Show worktree running while an agent works/blocks or a workflow runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar spinner and `prowl list` task status were driven only by OSC 9;4 command progress, which Claude Code never emits — so a pane mid-work, and especially one running a background workflow, read as idle. Fold agent detection into `taskStatus`: a per-tab `tabAgentBusyById` aggregate (any surface whose detected agent is Working or Blocked) is OR-ed into `taskStatus`, recomputed on each detection tick, on agent release, and on surface/tab teardown. For the turn-ended-but-busy case, extend Claude screen detection to read the persistent below-prompt workflow footer ("N/M agents done") as Working, anchored to the footer so conversation text can't trip it. Docs: document the merged running indicator plus an optional Claude Code OSC 9;4 hook (`terminalSequence`) for a lower-latency, restyle-proof signal. Tests: workflow-footer detection (incl. a false-positive guard), `PaneAgentState.isBusy`, and an object-level `taskStatus` fold + teardown. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/components/agent-detection.md | 37 ++++++++++++++ docs/components/cli.md | 9 ++-- docs/components/terminal.md | 6 ++- .../AgentDetection/PaneAgentState.swift | 10 ++++ ...WorktreeTerminalState+AgentDetection.swift | 18 +++++++ .../WorktreeTerminalState+Surfaces.swift | 5 ++ .../Models/WorktreeTerminalState.swift | 10 +++- .../AgentDetection/ScreenHeuristics.swift | 28 +++++++++++ supacodeTests/PaneAgentStateTests.swift | 10 ++++ supacodeTests/ScreenHeuristicsTests.swift | 42 ++++++++++++++++ .../WorktreeTerminalManagerTests.swift | 49 +++++++++++++++++++ 11 files changed, 218 insertions(+), 6 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 26fff9b5..3729d6cf 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -32,6 +32,10 @@ icon where Prowl shows detected command icons, including terminal tabs. loader or braille spinner status line (working), confirmation/permission prompts (blocked), idle prompts. Each agent family has its own patterns (including spinner glyphs: braille frames, symbol cycles, Cursor's hexagons, Kimi's moon phases, etc.). + For Claude, a running **background workflow** keeps a status line *below* the + input box (e.g. `3/5 agents done · 7m 29s · ↓ 288.5k tokens`) after the turn has + ended; Prowl reads that footer as **Working**, so a churning workflow isn't + mistaken for idle. To avoid flicker, detection **stabilizes**: it tolerates several consecutive misses before declaring an agent gone, and a working agent gets a short (~3s) @@ -75,6 +79,39 @@ attention color; **Idle/Done** are static. The "working" animation style is also configurable in spirit — Prowl uses a bagua/trigram-style spinner in the agents list. +## Worktree running indicator + +The sidebar worktree row spinner and `prowl list`'s `task.status` report +**running** whenever any pane in the worktree is busy. A pane is busy when: + +- a terminal command reports progress (OSC 9;4 / ConEmu-style, e.g. a long shell + command), **or** +- a detected agent is **Working** or **Blocked** — including Claude running a + background **workflow**, detected from its below-prompt `… agents done …` status + line even while the input box looks idle. + +It's a single coarse running/idle bit (it can't distinguish a background workflow +from a long command). For the agent's finer state use the +[Active Agents panel](active-agents.md) or [`prowl agents`](cli.md). Expect up to +~2 s before it lights on a warm pane, and the ~3 s working-hold before it clears. + +### Optional: a lower-latency signal via a Claude Code hook + +Footer detection is zero-config but Claude-specific and depends on that footer's +wording. For a faster, restyle-proof signal, have Claude Code emit an **OSC 9;4** +progress sequence while background work runs — Prowl already treats OSC 9;4 +progress as *running*, with no extra setup on its side. Add a hook to +`~/.claude/settings.json` that returns the bytes via the `terminalSequence` output +field (Claude Code ≥ 2.1.141; hooks have no controlling terminal, so +`terminalSequence` is the only path to the terminal): + +- **start** (e.g. `PreToolUse` on a backgrounded `Bash`, or `SubagentStart`): + emit `printf '\e]9;4;3\e\\'` (indeterminate progress) +- **stop** (e.g. `SubagentStop`): emit `printf '\e]9;4;0\e\\'` (clear) + +A plain backgrounded `Bash` has no completion hook, so the clear is best-effort; +Prowl auto-clears a stale progress bar after ~15 s. + ## Settings Agent detection is on by default. Related toggles live in the Active Agents and diff --git a/docs/components/cli.md b/docs/components/cli.md index e35b11db..fecfefce 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -71,9 +71,12 @@ Each item contains: - `pane`: `id`, `title`, `cwd`, `focused` - `task`: `status` (`running` | `idle` | null) -`task.status` is the same agent activity signal described in -[agent-detection](agent-detection.md). It's good for coordination but can flip to -idle **before** a TUI finishes painting — confirm with `read --wait-stable`. +`task.status` is **running** when any pane in the worktree is busy — a terminal +command reporting progress, or a detected agent that is Working/Blocked (including +Claude running a background **workflow**); otherwise **idle**. See the +[worktree running indicator](agent-detection.md#worktree-running-indicator). It's +good for coordination but lags a screen by ~2–3 s and can flip to idle **before** a +TUI finishes painting — confirm with `read --wait-stable`. Find your own pane (to avoid operating on yourself): ```bash diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 86a9f3a2..f609f8ef 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -92,8 +92,10 @@ sequences: finishes** and its **exit code**. This powers command-finished notifications, auto-close-on-success, and the `prowl send --capture` output capture. - **OSC 2** (title) — feeds tab titles and agent/icon detection. -- **Progress reports** — agents/commands report busy/idle, feeding the tab's - activity indicator and task status. +- **Progress reports (OSC 9;4)** — long commands report busy/idle, feeding the + tab's activity indicator and task status. Task status *also* folds in detected + agent activity (Working/Blocked, incl. background workflows) — see + [agent-detection](agent-detection.md#worktree-running-indicator). - **Bell / desktop notification** — increments unread indicators. `--capture` in the CLI **requires OSC 133** on the target pane; without it you get diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 68440989..480025a8 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -36,6 +36,16 @@ struct PaneAgentState: Equatable, Sendable { return .idle } } + + /// Whether this pane should count toward the worktree running indicator: a + /// detected agent that is working or blocked (awaiting permission). Folded + /// into `taskStatus` so the sidebar spinner and `prowl list` light up on agent + /// activity even when no OSC 9;4 command progress is reported (Claude Code + /// does not emit OSC 9;4 while it works). + var isBusy: Bool { + guard detectedAgent != nil else { return false } + return displayState == .working || displayState == .blocked + } } struct AgentDetectionPresence: Equatable, Sendable { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index b8ee672d..48022a75 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -150,6 +150,7 @@ extension WorktreeTerminalState { } guard next != previous else { return true } surfaceAgentStates[surfaceID] = next + updateTabAgentBusyState(for: tabId) emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: next) return true } @@ -168,6 +169,23 @@ extension WorktreeTerminalState { surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) lastWorkingAtBySurface.removeValue(forKey: surfaceID) onAgentEntryRemoved?(surfaceID) + if let tabId = tabId(containing: surfaceID) { + updateTabAgentBusyState(for: tabId) + } + } + + /// Recompute `tabAgentBusyById[tabId]` from the stabilized state of every + /// surface in the tab, emitting a task-status change only when the aggregate + /// flips. Driven by `detectAgentState` (state change), agent release, and + /// surface teardown so the sidebar/`prowl list` running indicator tracks agent + /// activity. Uses `displayState` (post-stabilization) so the 3 s working-hold + /// absorbs raw oscillation; `emitTaskStatusIfChanged` dedupes emissions. + func updateTabAgentBusyState(for tabId: TerminalTabID) { + let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] + let isBusy = surfaceIDs.contains { surfaceAgentStates[$0]?.isBusy == true } + guard (tabAgentBusyById[tabId] ?? false) != isBusy else { return } + tabAgentBusyById[tabId] = isBusy + emitTaskStatusIfChanged() } /// Re-emit Active Agents entries for every pane in `tabId` so the panel picks diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 3da162b5..3d477311 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -320,6 +320,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeAll() cleanupAllAgentDetectionState() tabIsRunningById.removeAll() + tabAgentBusyById.removeAll() boundDirectoryTabIDs.removeAll() autoCloseSurfaceIds.removeAll() pendingCustomCommands.removeAll() @@ -611,6 +612,9 @@ extension WorktreeTerminalState { pendingCustomCommands.removeValue(forKey: surfaceID) cleanupCommandDetectorState(forSurfaceId: surfaceID) cleanupAgentDetectionState(forSurfaceId: surfaceID) + if let tabId = tabId(containing: surfaceID) { + updateTabAgentBusyState(for: tabId) + } let previousHasUnseen = hasUnseenNotification notifications = Self.prunedNotifications(from: notifications, removingSurfaceID: surfaceID) emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) @@ -624,6 +628,7 @@ extension WorktreeTerminalState { } focusedSurfaceIdByTab.removeValue(forKey: tabId) tabIsRunningById.removeValue(forKey: tabId) + tabAgentBusyById.removeValue(forKey: tabId) } func tabID(containing surfaceId: UUID) -> TerminalTabID? { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index dbd7bebc..e6c6e5c1 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -76,6 +76,12 @@ final class WorktreeTerminalState { var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] + /// Per-tab aggregate of agent busy-state: `true` when at least one surface in + /// the tab has a detected agent whose stabilized `displayState` is `.working` + /// or `.blocked`. OR-ed into `taskStatus` alongside `tabIsRunningById` so the + /// sidebar spinner and `prowl list` reflect agent activity, not just OSC 9;4 + /// command progress (which Claude Code does not emit while it works). + var tabAgentBusyById: [TerminalTabID: Bool] = [:] var boundDirectoryTabIDs: [String: TerminalTabID] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var runScriptTabId: TerminalTabID? @@ -253,7 +259,9 @@ final class WorktreeTerminalState { } var taskStatus: WorktreeTaskStatus { - tabIsRunningById.values.contains(true) ? .running : .idle + let hasRunningCommand = tabIsRunningById.values.contains(true) + let hasBusyAgent = tabAgentBusyById.values.contains(true) + return (hasRunningCommand || hasBusyAgent) ? .running : .idle } var isRunScriptRunning: Bool { diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 580c7fe3..2094ee35 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -101,6 +101,9 @@ nonisolated private func detectClaude(_ content: String) -> AgentRawState { if hasSpinnerActivity(above) { return .working } + if hasClaudeBackgroundWork(content) { + return .working + } return .idle } @@ -294,6 +297,31 @@ nonisolated private func isBoxBorderLine(_ line: String) -> Bool { return trimmed.allSatisfy { $0 == "─" || $0 == "-" } } +// Everything rendered AFTER the input prompt line — i.e. the footer area where +// Claude shows its persistent background-work / workflow status. Mirrors +// `contentAbovePromptBox` so the background-work check can stay anchored to the +// footer and never trip on transcript text that merely mentions the marker. +nonisolated private func contentBelowPromptBox(_ content: String) -> String { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { + return "" + } + let startIndex = lines.index(after: promptIndex) + guard startIndex < lines.endIndex else { return "" } + return lines[startIndex...].joined(separator: "\n") +} + +// While a background workflow runs, Claude's turn has ended (so the spinner and +// "esc to interrupt" hint above the prompt are gone) but it keeps a persistent +// status line BELOW the input box, e.g. +// "◯ my-workflow 3/5 agents done · 7m 29s · ↓ 288.5k tokens" +// The "/ agents done" segment is Claude's stable statusText +// template, so "agents done" is a distinctive marker. Anchored to the +// below-prompt footer so it can't be tripped by conversation text. +nonisolated private func hasClaudeBackgroundWork(_ content: String) -> Bool { + contentBelowPromptBox(content).lowercased().contains("agents done") +} + nonisolated private func claudeCurrentInteractionRegion(_ content: String) -> String { let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let promptIndex = lines.lastIndex(where: { $0.contains("❯") }) else { diff --git a/supacodeTests/PaneAgentStateTests.swift b/supacodeTests/PaneAgentStateTests.swift index 62987f51..376c9c3f 100644 --- a/supacodeTests/PaneAgentStateTests.swift +++ b/supacodeTests/PaneAgentStateTests.swift @@ -126,4 +126,14 @@ struct PaneAgentStateTests { #expect(presence.update(detectedAgent: nil) == nil) #expect(presence.currentAgent == nil) } + + @Test func isBusyReflectsWorkingAndBlockedDetectedAgents() { + #expect(PaneAgentState(detectedAgent: .claude, state: .working).isBusy) + #expect(PaneAgentState(detectedAgent: .claude, state: .blocked).isBusy) + #expect(!PaneAgentState(detectedAgent: .claude, state: .idle).isBusy) + // Unseen idle surfaces display as `.done`, which must not count as busy. + #expect(!PaneAgentState(detectedAgent: .claude, state: .idle, seen: false).isBusy) + // A plain shell (no detected agent) is never busy, even mid-output. + #expect(!PaneAgentState(detectedAgent: nil, state: .working).isBusy) + } } diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 633344f1..3674ab71 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -277,6 +277,48 @@ struct ScreenHeuristicsTests { ) } + @Test func claudeDetectsRunningWorkflowFooterBelowPrompt() { + // A running background workflow: the turn has ended (no spinner / "esc to + // interrupt" above the prompt), but Claude keeps a status line BELOW the + // input box. The "/ agents done" segment marks active work. + #expect( + DetectedAgent.claude.detectState( + in: """ + ⏺ Kicked off the scout workflow in the background. + ───────── + ❯ + ───────── + ◯ scout-prowl-idle Map idle detection 3/5 agents done · 7m 29s · ↓ 288.5k tokens + """ + ) == .working + ) + // Idle with an ordinary footer (no workflow line) stays idle. + #expect( + DetectedAgent.claude.detectState( + in: """ + Task complete. + ───────── + ❯ + ───────── + ? for shortcuts + """ + ) == .idle + ) + // The marker quoted in conversation (above the prompt) must NOT force + // working — the check is anchored to the below-prompt footer. + #expect( + DetectedAgent.claude.detectState( + in: """ + ⏺ The run showed 3/5 agents done before it wrapped up. + ───────── + ❯ + ───────── + ? for shortcuts + """ + ) == .idle + ) + } + @Test func codexDetection() { #expect(DetectedAgent.codex.detectState(in: "press enter to confirm or esc to cancel") == .blocked) #expect(DetectedAgent.codex.detectState(in: "• Working (12s)\nesc to interrupt") == .working) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4226cd0a..4d6adab0 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -588,6 +588,55 @@ struct WorktreeTerminalManagerTests { ) } + @Test func busyAgentFoldsIntoTaskStatusAndEmits() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + #expect(state.taskStatus == .idle) + + var emissions: [WorktreeTaskStatus] = [] + state.onTaskStatusChanged = { emissions.append($0) } + + // A working agent on the tab makes the worktree run, with one emission. + state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) + state.updateTabAgentBusyState(for: tabId) + #expect(state.taskStatus == .running) + #expect(emissions == [.running]) + + // Idempotent while it stays busy — no duplicate emission. + state.updateTabAgentBusyState(for: tabId) + #expect(emissions == [.running]) + + // Returning to idle clears the indicator and emits once more. + state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .idle) + state.updateTabAgentBusyState(for: tabId) + #expect(state.taskStatus == .idle) + #expect(emissions == [.running, .idle]) + + state.cleanupAllAgentDetectionState() + } + + @Test func tabTeardownClearsAgentBusyTaskStatus() throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + + let tabId = try #require(state.createTab()) + let surfaceId = try #require(state.focusedSurfaceId(in: tabId)) + + state.surfaceAgentStates[surfaceId] = PaneAgentState(detectedAgent: .claude, state: .working) + state.updateTabAgentBusyState(for: tabId) + #expect(state.taskStatus == .running) + + state.closeAllSurfaces() + #expect(state.tabAgentBusyById.isEmpty) + #expect(state.taskStatus == .idle) + } + private func nextEvent( _ stream: AsyncStream, matching predicate: (TerminalClient.Event) -> Bool -- 2.51.2 From 190aec9890bc547db013967d28ac20278cadf0d5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 19 Jun 2026 16:56:00 +0800 Subject: [PATCH 493/528] Accelerate make run-app with caching, parallelism, and incremental compilation Four optimizations to reduce `make run-app` latency for daily development: 1. Cache xcodebuild -showBuildSettings output (~5-10s saved) - Store result in .build_settings_cache.json, keyed by pbxproj mtime - Applied to both run-app and install-dev-build targets 2. Parallelize build-app dependencies (~10-15s saved) - ensure-ghostty, embed-cli-debug, embed-docs run via $(MAKE) -j3 - These targets write to isolated directories, safe to parallelize 3. Resolve mise tool paths at Makefile load time (~2s saved per invocation) - Replace `mise exec -- xcsift` with $(shell mise which xcsift) - Same for swiftlint; avoids mise runtime initialization overhead 4. Use incremental Swift compilation for Debug builds (~15-25s saved) - Pass SWIFT_COMPILATION_MODE=incremental to xcodebuild - Overrides wholemodule setting; single-file changes rebuild in ~5s Release/archive builds are unaffected (still use wholemodule). Signed-off-by: Alex --- .gitignore | 1 + Makefile | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 449e18de..45e77007 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ Resources/terminfo Resources/docs .ghostty_hash .ghostty_build_stamp +.build_settings_cache.json *.profraw .env build/ diff --git a/Makefile b/Makefile index a8103b38..b4dcf121 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= FORMAT_BASE_REF ?= origin/main +BUILD_SETTINGS_CACHE := $(CURRENT_MAKEFILE_DIR)/.build_settings_cache.json +PBXPROJ_PATH := $(CURRENT_MAKEFILE_DIR)/supacode.xcodeproj/project.pbxproj +XCSIFT := $(shell mise which xcsift 2>/dev/null) +SWIFTLINT := $(shell mise which swiftlint 2>/dev/null) # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. @@ -37,7 +41,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash build-app _build-app-all-deps build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -106,8 +110,12 @@ embed-docs: # Stage docs/ into Resources for bundling into the app (.app/Content rsync -a --delete --exclude '.sync-meta.json' "$$src/" "$$dst/"; \ echo "embedded docs at $$dst" -build-app: ensure-ghostty embed-cli-debug embed-docs # Build the macOS app (Debug) - bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -w --format toon' +build-app: _build-app-all-deps # Build the macOS app (Debug) + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | $(XCSIFT) -w --format toon' + +.PHONY: _build-app-all-deps +_build-app-all-deps: + @$(MAKE) -j3 ensure-ghostty embed-cli-debug embed-docs sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion.swift @version="$$(/usr/bin/awk -F' = ' '/MARKETING_VERSION = [0-9.]*;/{gsub(/;/,"",$$2);print $$2; exit}' \ @@ -154,7 +162,14 @@ embed-cli: build-cli-release # Build release CLI and copy into Resources for dis run-app: build-app # Build then launch (Debug) with log streaming @set -euo pipefail; \ - settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + cache="$(BUILD_SETTINGS_CACHE)"; \ + pbxproj="$(PBXPROJ_PATH)"; \ + if [ -f "$$cache" ] && [ "$$cache" -nt "$$pbxproj" ]; then \ + settings="$$(cat "$$cache")"; \ + else \ + settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + printf '%s' "$$settings" > "$$cache"; \ + fi; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ exec_name="$$(echo "$$settings" | jq -r '.[0].buildSettings.EXECUTABLE_NAME')"; \ @@ -167,7 +182,14 @@ run-app: build-app # Build then launch (Debug) with log streaming install-dev-build: build-app # install dev build to /Applications @set -euo pipefail; \ - settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + cache="$(BUILD_SETTINGS_CACHE)"; \ + pbxproj="$(PBXPROJ_PATH)"; \ + if [ -f "$$cache" ] && [ "$$cache" -nt "$$pbxproj" ]; then \ + settings="$$(cat "$$cache")"; \ + else \ + settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + printf '%s' "$$settings" > "$$cache"; \ + fi; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ if [ -z "$$build_dir" ] || [ -z "$$product" ] || [ "$$build_dir" = "null" ] || [ "$$product" = "null" ]; then \ @@ -300,10 +322,10 @@ install-release: build-ghostty-xcframework # Build Release, sign locally, instal echo "installed $$DST (Release build, locally signed)" archive: build-ghostty-xcframework embed-cli embed-docs # Archive Release build for distribution - 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" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' + 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" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | $(XCSIFT) -qw --format toon' export-archive: # Export xarchive - 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' + bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | $(XCSIFT) -qw --format toon' test: ensure-ghostty embed-cli-debug embed-docs test-app @@ -313,7 +335,7 @@ test-app: ensure-ghostty # Run app/unit tests via xcodebuild mkdir -p "$$(dirname "$$result_bundle")"; \ rm -rf "$$result_bundle"; \ set +e; \ - xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -resultBundlePath "$$result_bundle" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -w --format toon; \ + xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -resultBundlePath "$$result_bundle" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | $(XCSIFT) -w --format toon; \ xcodebuild_status=$${PIPESTATUS[0]}; \ set -e; \ if [ "$$xcodebuild_status" -ne 0 ]; then \ @@ -357,7 +379,7 @@ format-lint: # Check Swift formatting without rewriting files swift-format lint --strict --recursive --configuration ./.swift-format.json supacode supacodeTests lint: # Lint code with swiftlint - mise exec -- swiftlint lint --quiet --config .swiftlint.yml + $(SWIFTLINT) lint --quiet --config .swiftlint.yml check: format-changed format-lint lint # Format changed Swift files, then run swift-format lint and SwiftLint -- 2.51.2 From 8b196dd340c6c360f3f25c3a2c6f0a9771e56fc4 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 17 Jun 2026 11:39:10 +0800 Subject: [PATCH 494/528] Stabilize local debug app builds --- Makefile | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index a8103b38..b2aef475 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= FORMAT_BASE_REF ?= origin/main +DEBUG_PRODUCT_NAME ?= Prowl Debug +DEBUG_BUNDLE_IDENTIFIER ?= com.onevcat.prowl.debug +DEBUG_SIGNING_IDENTITY ?= +DEBUG_APP_NAME := $(DEBUG_PRODUCT_NAME).app # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. @@ -165,8 +169,22 @@ run-app: build-app # Build then launch (Debug) with log streaming app_path="$$build_dir/$$product/Contents/MacOS/$$exec_name"; \ "$$app_path" -install-dev-build: build-app # install dev build to /Applications +install-dev-build: build-app # Build Debug and install to /Applications/Prowl Debug.app @set -euo pipefail; \ + signing_identity="$(DEBUG_SIGNING_IDENTITY)"; \ + if [ -z "$$signing_identity" ]; then \ + signing_identity="$$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' ' \ + /Apple Development/ { print $$2; found=1; exit } \ + /Developer ID Application/ && fallback == "" { fallback=$$2 } \ + END { if (!found && fallback != "") print fallback } \ + ')"; \ + fi; \ + if [ -z "$$signing_identity" ]; then \ + echo "error: no Apple Development or Developer ID Application signing identity found"; \ + echo "Install a development signing certificate, or pass DEBUG_SIGNING_IDENTITY explicitly."; \ + exit 1; \ + fi; \ + echo "debug install signing identity: $$signing_identity"; \ settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ @@ -183,7 +201,7 @@ install-dev-build: build-app # install dev build to /Applications exit 1; \ fi; \ src="$$build_dir/$$product"; \ - dst="/Applications/$$product"; \ + dst="/Applications/$(DEBUG_APP_NAME)"; \ dst_parent="$$(cd "$$(dirname "$$dst")" && pwd -P)"; \ if [ "$$dst_parent" != "/Applications" ]; then \ echo "error: refusing to install outside /Applications: $$dst"; \ @@ -218,7 +236,13 @@ install-dev-build: build-app # install dev build to /Applications trash "$$dst"; \ fi; \ ditto "$$src" "$$dst"; \ - echo "installed $$dst" + /usr/libexec/PlistBuddy -c 'Set :CFBundleName $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist"; \ + /usr/libexec/PlistBuddy -c 'Set :CFBundleDisplayName $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c 'Add :CFBundleDisplayName string $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist"; \ + /usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier $(DEBUG_BUNDLE_IDENTIFIER)' "$$dst/Contents/Info.plist"; \ + codesign -f --deep -s "$$signing_identity" --timestamp=none "$$dst"; \ + echo "installed $$dst"; \ + codesign -dv --verbose=4 "$$dst" 2>&1 | sed -n -E '/Identifier=|Authority=|TeamIdentifier=|Signature=|Info.plist=/p' install-release: build-ghostty-xcframework # Build Release, sign locally, install to /Applications @set -euo pipefail; \ -- 2.51.2 From a8796130ee0eb9016de30caff26176a9120fa16b Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 19 Jun 2026 21:32:43 +0900 Subject: [PATCH 495/528] Separate Debug and Release app identity at the Xcode project level Move Debug bundle identifier and product name into the Xcode project (Debug configuration only) so builds natively produce "Prowl Debug" with com.onevcat.prowl.debug. This eliminates the need for post-build PlistBuddy patching and codesign re-signing, preserving entitlements and hardened runtime flags that the Makefile workaround would strip. Also update TEST_HOST for the Debug test configuration and simplify install-dev-build back to a plain copy. Co-authored-by: MikotoZero --- Makefile | 30 +++--------------------------- supacode.xcodeproj/project.pbxproj | 8 +++++--- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index b2aef475..627931d8 100644 --- a/Makefile +++ b/Makefile @@ -28,10 +28,6 @@ VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= FORMAT_BASE_REF ?= origin/main -DEBUG_PRODUCT_NAME ?= Prowl Debug -DEBUG_BUNDLE_IDENTIFIER ?= com.onevcat.prowl.debug -DEBUG_SIGNING_IDENTITY ?= -DEBUG_APP_NAME := $(DEBUG_PRODUCT_NAME).app # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. @@ -169,22 +165,8 @@ run-app: build-app # Build then launch (Debug) with log streaming app_path="$$build_dir/$$product/Contents/MacOS/$$exec_name"; \ "$$app_path" -install-dev-build: build-app # Build Debug and install to /Applications/Prowl Debug.app +install-dev-build: build-app # Build Debug and install to /Applications @set -euo pipefail; \ - signing_identity="$(DEBUG_SIGNING_IDENTITY)"; \ - if [ -z "$$signing_identity" ]; then \ - signing_identity="$$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' ' \ - /Apple Development/ { print $$2; found=1; exit } \ - /Developer ID Application/ && fallback == "" { fallback=$$2 } \ - END { if (!found && fallback != "") print fallback } \ - ')"; \ - fi; \ - if [ -z "$$signing_identity" ]; then \ - echo "error: no Apple Development or Developer ID Application signing identity found"; \ - echo "Install a development signing certificate, or pass DEBUG_SIGNING_IDENTITY explicitly."; \ - exit 1; \ - fi; \ - echo "debug install signing identity: $$signing_identity"; \ settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ @@ -201,7 +183,7 @@ install-dev-build: build-app # Build Debug and install to /Applications/Prowl De exit 1; \ fi; \ src="$$build_dir/$$product"; \ - dst="/Applications/$(DEBUG_APP_NAME)"; \ + dst="/Applications/$$product"; \ dst_parent="$$(cd "$$(dirname "$$dst")" && pwd -P)"; \ if [ "$$dst_parent" != "/Applications" ]; then \ echo "error: refusing to install outside /Applications: $$dst"; \ @@ -236,13 +218,7 @@ install-dev-build: build-app # Build Debug and install to /Applications/Prowl De trash "$$dst"; \ fi; \ ditto "$$src" "$$dst"; \ - /usr/libexec/PlistBuddy -c 'Set :CFBundleName $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist"; \ - /usr/libexec/PlistBuddy -c 'Set :CFBundleDisplayName $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c 'Add :CFBundleDisplayName string $(DEBUG_PRODUCT_NAME)' "$$dst/Contents/Info.plist"; \ - /usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier $(DEBUG_BUNDLE_IDENTIFIER)' "$$dst/Contents/Info.plist"; \ - codesign -f --deep -s "$$signing_identity" --timestamp=none "$$dst"; \ - echo "installed $$dst"; \ - codesign -dv --verbose=4 "$$dst" 2>&1 | sed -n -E '/Identifier=|Authority=|TeamIdentifier=|Signature=|Info.plist=/p' + echo "installed $$dst" install-release: build-ghostty-xcframework # Build Release, sign locally, install to /Applications @set -euo pipefail; \ diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index d6d60894..5d826d16 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -475,6 +475,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; @@ -487,6 +488,7 @@ EXECUTABLE_NAME = ProwlApp; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = supacode/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Prowl Debug"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -499,9 +501,9 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -Wno-incomplete-umbrella"; - PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl; + PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowl.debug; PRODUCT_MODULE_NAME = supacode; - PRODUCT_NAME = Prowl; + PRODUCT_NAME = "Prowl Debug"; REGISTER_APP_GROUPS = YES; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; @@ -594,7 +596,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ProwlApp"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Prowl Debug.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ProwlApp"; }; name = Debug; }; -- 2.51.2 From 7f8e38e617d4b78ceaa873171058f7029f68fb4a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 19 Jun 2026 21:54:30 +0900 Subject: [PATCH 496/528] Recognize Debug app bundle in CLI launcher --- ProwlCLI/AppLauncher.swift | 12 +++++++++++- ProwlCLITests/AppLauncherTests.swift | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 ProwlCLITests/AppLauncherTests.swift diff --git a/ProwlCLI/AppLauncher.swift b/ProwlCLI/AppLauncher.swift index b47ef705..b969cbf5 100644 --- a/ProwlCLI/AppLauncher.swift +++ b/ProwlCLI/AppLauncher.swift @@ -13,6 +13,11 @@ import ProwlCLIShared #endif enum AppLauncher { + private static let prowlAppBundleIdentifiers: Set = [ + "com.onevcat.prowl", + "com.onevcat.prowl.debug", + ] + /// Maximum time to wait for the socket after launching the app. private static let socketTimeoutSeconds: TimeInterval = 15 /// Interval between socket availability checks. @@ -59,10 +64,15 @@ enum AppLauncher { /// Check whether a Prowl app instance is currently running. private static func isAppProcessRunning() -> Bool { NSWorkspace.shared.runningApplications.contains { application in - application.bundleIdentifier == "com.onevcat.prowl" + isProwlAppBundleIdentifier(application.bundleIdentifier) } } + static func isProwlAppBundleIdentifier(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return prowlAppBundleIdentifiers.contains(bundleIdentifier) + } + // MARK: - App launch private static func launchApp() throws { diff --git a/ProwlCLITests/AppLauncherTests.swift b/ProwlCLITests/AppLauncherTests.swift new file mode 100644 index 00000000..79d3d29c --- /dev/null +++ b/ProwlCLITests/AppLauncherTests.swift @@ -0,0 +1,11 @@ +@testable import prowl +import XCTest + +final class AppLauncherTests: XCTestCase { + func testProwlBundleIdentifierMatchingIncludesDebugBuild() { + XCTAssertTrue(AppLauncher.isProwlAppBundleIdentifier("com.onevcat.prowl")) + XCTAssertTrue(AppLauncher.isProwlAppBundleIdentifier("com.onevcat.prowl.debug")) + XCTAssertFalse(AppLauncher.isProwlAppBundleIdentifier("com.example.Prowl")) + XCTAssertFalse(AppLauncher.isProwlAppBundleIdentifier(nil)) + } +} -- 2.51.2 From 798528bf84cce0ff95ed684f14a4e27bbfb40598 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 19 Jun 2026 22:39:29 +0900 Subject: [PATCH 497/528] Clarify agent detection docs --- docs/components/agent-detection.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 3729d6cf..56833727 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -95,23 +95,6 @@ from a long command). For the agent's finer state use the [Active Agents panel](active-agents.md) or [`prowl agents`](cli.md). Expect up to ~2 s before it lights on a warm pane, and the ~3 s working-hold before it clears. -### Optional: a lower-latency signal via a Claude Code hook - -Footer detection is zero-config but Claude-specific and depends on that footer's -wording. For a faster, restyle-proof signal, have Claude Code emit an **OSC 9;4** -progress sequence while background work runs — Prowl already treats OSC 9;4 -progress as *running*, with no extra setup on its side. Add a hook to -`~/.claude/settings.json` that returns the bytes via the `terminalSequence` output -field (Claude Code ≥ 2.1.141; hooks have no controlling terminal, so -`terminalSequence` is the only path to the terminal): - -- **start** (e.g. `PreToolUse` on a backgrounded `Bash`, or `SubagentStart`): - emit `printf '\e]9;4;3\e\\'` (indeterminate progress) -- **stop** (e.g. `SubagentStop`): emit `printf '\e]9;4;0\e\\'` (clear) - -A plain backgrounded `Bash` has no completion hook, so the clear is best-effort; -Prowl auto-clears a stale progress bar after ~15 s. - ## Settings Agent detection is on by default. Related toggles live in the Active Agents and -- 2.51.2 From 74d2f25654f622353ee2cb29423ec2e489d71486 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 20 Jun 2026 01:07:14 +0900 Subject: [PATCH 498/528] Fix toolbar title too narrow for folder and workspace names Wrap the folder/workspace title in a Button (matching the branch case) so it gets consistent toolbar button styling and padding. --- .../Views/WorktreeDetailTitleView.swift | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 998814e8..657309b2 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -12,47 +12,53 @@ struct WorktreeDetailTitleView: View { @State private var draftName = "" var body: some View { - Group { - if title.supportsRename { - Button { - openRenamePopover() - } label: { - labelContent - } - .help( - AppShortcuts.helpText( - title: "Rename branch", - commandID: AppShortcuts.CommandID.renameBranch, - in: resolvedKeybindings - ) + titleButton + .onHover { hovering in + isHovered = hovering + } + .popover(isPresented: $isPresented) { + RenameBranchPopover( + draftName: $draftName, + onCancel: { isPresented = false }, + onSubmit: { newName in + isPresented = false + if newName != title.text { + onSubmit?(newName) + } + } ) - .modifier( - KeyboardShortcutModifier( - shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.renameBranch) - )) - } else { + } + .task(id: externalRenamePrompt?.id) { + guard let prompt = externalRenamePrompt, title.supportsRename else { return } + openRenamePopover() + onConsumeExternalRenamePrompt(prompt.id) + } + } + + @ViewBuilder + private var titleButton: some View { + if title.supportsRename { + Button { + openRenamePopover() + } label: { labelContent } - } - .onHover { hovering in - isHovered = hovering - } - .popover(isPresented: $isPresented) { - RenameBranchPopover( - draftName: $draftName, - onCancel: { isPresented = false }, - onSubmit: { newName in - isPresented = false - if newName != title.text { - onSubmit?(newName) - } - } + .help( + AppShortcuts.helpText( + title: "Rename branch", + commandID: AppShortcuts.CommandID.renameBranch, + in: resolvedKeybindings + ) ) - } - .task(id: externalRenamePrompt?.id) { - guard let prompt = externalRenamePrompt, title.supportsRename else { return } - openRenamePopover() - onConsumeExternalRenamePrompt(prompt.id) + .modifier( + KeyboardShortcutModifier( + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.renameBranch) + )) + } else { + // Button wrapper gives folder/workspace the same toolbar padding as the branch button. + Button {} label: { + labelContent + } } } -- 2.51.2 From af8a969632d1cbf848dcaafd8af1f6c21531936e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 08:56:43 +0800 Subject: [PATCH 499/528] Simplify: remove mise path resolution and parallel deps, keep cache and incremental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove optimizations that had limited benefit or added complexity: - mise exec → direct path (silent failure risk, ~2s overhead) - Parallel deps via -j3 (only ~1.4s saved) Keep the two most valuable optimizations: - showBuildSettings cache (saves 8-63s) - SWIFT_COMPILATION_MODE=incremental (saves ~63s in no-change builds) Signed-off-by: Alex --- Makefile | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index b4dcf121..f9bd4aef 100644 --- a/Makefile +++ b/Makefile @@ -30,8 +30,6 @@ XCODEBUILD_FLAGS ?= FORMAT_BASE_REF ?= origin/main BUILD_SETTINGS_CACHE := $(CURRENT_MAKEFILE_DIR)/.build_settings_cache.json PBXPROJ_PATH := $(CURRENT_MAKEFILE_DIR)/supacode.xcodeproj/project.pbxproj -XCSIFT := $(shell mise which xcsift 2>/dev/null) -SWIFTLINT := $(shell mise which swiftlint 2>/dev/null) # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. @@ -41,7 +39,7 @@ PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash build-app _build-app-all-deps build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -110,12 +108,8 @@ embed-docs: # Stage docs/ into Resources for bundling into the app (.app/Content rsync -a --delete --exclude '.sync-meta.json' "$$src/" "$$dst/"; \ echo "embedded docs at $$dst" -build-app: _build-app-all-deps # Build the macOS app (Debug) - bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | $(XCSIFT) -w --format toon' - -.PHONY: _build-app-all-deps -_build-app-all-deps: - @$(MAKE) -j3 ensure-ghostty embed-cli-debug embed-docs +build-app: ensure-ghostty embed-cli-debug embed-docs # Build the macOS app (Debug) + bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | mise exec -- xcsift -w --format toon' sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion.swift @version="$$(/usr/bin/awk -F' = ' '/MARKETING_VERSION = [0-9.]*;/{gsub(/;/,"",$$2);print $$2; exit}' \ @@ -322,10 +316,10 @@ install-release: build-ghostty-xcframework # Build Release, sign locally, instal echo "installed $$DST (Release build, locally signed)" archive: build-ghostty-xcframework embed-cli embed-docs # Archive Release build for distribution - 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" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | $(XCSIFT) -qw --format toon' + 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" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' export-archive: # Export xarchive - bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | $(XCSIFT) -qw --format toon' + 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' test: ensure-ghostty embed-cli-debug embed-docs test-app @@ -335,7 +329,7 @@ test-app: ensure-ghostty # Run app/unit tests via xcodebuild mkdir -p "$$(dirname "$$result_bundle")"; \ rm -rf "$$result_bundle"; \ set +e; \ - xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -resultBundlePath "$$result_bundle" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | $(XCSIFT) -w --format toon; \ + xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -resultBundlePath "$$result_bundle" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental 2>&1 | mise exec -- xcsift -w --format toon; \ xcodebuild_status=$${PIPESTATUS[0]}; \ set -e; \ if [ "$$xcodebuild_status" -ne 0 ]; then \ @@ -379,7 +373,7 @@ format-lint: # Check Swift formatting without rewriting files swift-format lint --strict --recursive --configuration ./.swift-format.json supacode supacodeTests lint: # Lint code with swiftlint - $(SWIFTLINT) lint --quiet --config .swiftlint.yml + mise exec -- swiftlint lint --quiet --config .swiftlint.yml check: format-changed format-lint lint # Format changed Swift files, then run swift-format lint and SwiftLint -- 2.51.2 From 979deadf85eefb87f39c9e23d934c7b84c14bd36 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 09:31:46 +0800 Subject: [PATCH 500/528] Add Qwen Code agent detection support Recognize Qwen Code (`qwen`) as a detectable coding agent: - Process probe: match argv0 `qwen` in the classifier - Screen heuristics: detect working/blocked/idle states via `esc to cancel`, `ctrl+c to cancel`, and Braille spinner - Unit tests for process identification and screen state detection - Update agent-detection docs to list Qwen Code Signed-off-by: Alex --- docs/components/agent-detection.md | 3 +- .../Domain/AgentDetection/DetectedAgent.swift | 1 + .../AgentDetection/AgentClassifier.swift | 2 ++ .../AgentDetection/ScreenHeuristics.swift | 28 +++++++++++++++++ supacodeTests/AgentClassifierTests.swift | 1 + supacodeTests/ScreenHeuristicsTests.swift | 30 +++++++++++++++++++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 56833727..e115784e 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,7 +17,8 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, Pi (`pi`), and Oh My Pi (`omp`, `oh-my-pi`). Detection covers +Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), and Qwen Code (`qwen`). +Detection covers common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are still found. Oh My Pi reuses Pi-derived screen heuristics but uses its own command icon where Prowl shows detected command icons, including terminal tabs. diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index 1872ca58..e40c2845 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -13,6 +13,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { case kimi case droid case amp + case qwen var id: String { rawValue } diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 5540684b..76eb2ddd 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -24,6 +24,8 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .droid case "amp", "amp-local": return .amp + case "qwen": + return .qwen default: return nil } diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 2094ee35..8a3c9f25 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -28,6 +28,8 @@ extension DetectedAgent { return detectDroid(screen) case .amp: return detectAmp(screen) + case .qwen: + return detectQwen(screen) } } } @@ -496,3 +498,29 @@ nonisolated private func hasOpenCodeQuestionPrompt(_ content: String) -> Bool { return lower.contains("esc dismiss") && hasEnterAction && hasQuestionNavigation } + +nonisolated private func detectQwen(_ content: String) -> AgentRawState { + let lower = content.lowercased() + if lower.contains("waiting for user confirmation") + || lower.contains("do you want to proceed?") + || hasConfirmationPrompt(lower) + { + return .blocked + } + if lower.contains("esc to cancel") + || lower.contains("ctrl+c to cancel") + || hasBrailleSpinner(content) + { + return .working + } + return .idle +} + +nonisolated private func hasBrailleSpinner(_ content: String) -> Bool { + content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.unicodeScalars.first else { return false } + return (0x2800...0x28FF).contains(Int(first.value)) + && trimmed.contains(where: \.isLetter) + } +} diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index c92bc47a..a27ce770 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -23,6 +23,7 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "droid") == .droid) #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) + #expect(identifyAgent(processName: "qwen") == .qwen) } @Test func identifiesOhMyPiCommandNames() throws { diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 3674ab71..1e2c4e6a 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -510,4 +510,34 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.amp.detectState(in: "esc to cancel") == .working) #expect(DetectedAgent.amp.detectState(in: "done") == .idle) } + + @Test func qwenDetection() { + #expect(DetectedAgent.qwen.detectState(in: "Waiting for user confirmation...") == .blocked) + #expect( + DetectedAgent.qwen.detectState( + in: """ + Do you want to proceed? + > Yes, allow once + Always allow in this project + No (esc) + """ + ) == .blocked + ) + #expect( + DetectedAgent.qwen.detectState( + in: """ + ┌─ Shell Command Execution ──────────────────────┐ + │ Do you want to proceed? │ + │ > Yes, allow once │ + │ No (esc) │ + └────────────────────────────────────────────────┘ + """ + ) == .blocked + ) + #expect(DetectedAgent.qwen.detectState(in: "⠏ I'm Feeling Lucky (5s · esc to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "Thinking... (12s · ctrl+c to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "⠸ Writing file... (3s · ↓ 200 tokens · esc to cancel)") == .working) + #expect(DetectedAgent.qwen.detectState(in: "done") == .idle) + #expect(DetectedAgent.qwen.detectState(in: "> Type your message") == .idle) + } } -- 2.51.2 From 26c998ebf1d31efda4e2b8df9aa8b8d32a6f9574 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 09:46:24 +0800 Subject: [PATCH 501/528] Add Qwen brand icon for CommandIconMap - Add Qwen.svg imageset (sourced from qwen-code zed-extension) - Update CommandIconMap entry to use bundled Qwen asset - Add test assertion for qwen icon resolution Signed-off-by: Alex --- .../CommandIcons/Qwen.imageset/Contents.json | 16 ++++++++++++++++ .../CommandIcons/Qwen.imageset/Qwen.svg | 1 + .../Terminal/Models/CommandIconMap.swift | 1 + supacodeTests/CommandIconMapTests.swift | 1 + 4 files changed, 19 insertions(+) create mode 100644 supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Qwen.svg diff --git a/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Contents.json new file mode 100644 index 00000000..12c40eb1 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Qwen.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Qwen.svg b/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Qwen.svg new file mode 100644 index 00000000..6232ae3e --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Qwen.imageset/Qwen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index 665cb00c..f95472ee 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -44,6 +44,7 @@ enum CommandIconMap { "gemini": TabIconSource(systemSymbol: "sparkle", assetName: "Gemini"), "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), + "qwen": TabIconSource(systemSymbol: "sparkle", assetName: "Qwen"), "omp": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), "oh-my-pi": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), "pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index 1a9cd28c..26647558 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -77,6 +77,7 @@ struct CommandIconMapTests { #expect(CommandIconMap.iconForFirstToken("cursor-agent")?.assetName == "Cursor") #expect(CommandIconMap.iconForFirstToken("cline")?.assetName == "Cline") #expect(CommandIconMap.iconForFirstToken("droid")?.assetName == "Droid") + #expect(CommandIconMap.iconForFirstToken("qwen")?.assetName == "Qwen") // Aider has no bundled brand asset — sparkle fallback only. #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle") #expect(CommandIconMap.iconForFirstToken("aider")?.assetName == nil) -- 2.51.2 From 8e97e9439d9d2a3c76dee3cb72a48f178d234656 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 10:48:38 +0800 Subject: [PATCH 502/528] fix: detect non-OSC 9;4 commands via foreground process group fallback Two bugs fixed in taskStatus running detection: 1. hasRunningForegroundProcess compared processGroupID != shellPgid, but simple commands (sleep, ping, npm build) run in the SAME process group as the shell. Fix: check if the process group contains any processes OTHER than the shell itself. 2. updateRunningState was only triggered by onProgressReport (OSC 9;4). Non-OSC 9;4 commands never triggered it. Fix: also call from onTitleChange (shell changes title on command start/end) and handleCommandFinished (reliable end-of-command signal). Adds detectsForegroundCommandInSameProcessGroup unit test that spawns sleep 3 in the current process group, verifies detection while running, then confirms the group collapses back to just the test process. Signed-off-by: Alex --- .../WorktreeTerminalState+Notifications.swift | 6 +++ .../WorktreeTerminalState+Surfaces.swift | 31 ++++++++++++ .../ProcessDetectionSmokeTests.swift | 50 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift index d70dfd27..cd7401b1 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift @@ -86,6 +86,12 @@ extension WorktreeTerminalState { } } + // Re-evaluate running state: a command finished, so the tab may have + // transitioned from .running back to .idle. + if let tabId = tabId(containing: surfaceId) { + updateRunningState(for: tabId) + } + guard commandFinishedNotificationEnabled else { return } let durationSeconds = Int(durationNs / 1_000_000_000) guard durationSeconds >= commandFinishedNotificationThreshold else { return } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 3d477311..cb495b33 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -374,6 +374,10 @@ extension WorktreeTerminalState { } } self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) + // Title changes when a command starts (shell sets it to the command name) + // and when it ends (precmd resets to prompt). This is our trigger to + // re-evaluate the foreground process state for non-OSC 9;4 commands. + self.updateRunningState(for: tabId) } view.bridge.onSplitAction = { [weak self, weak view] action in guard let self, let view else { return false } @@ -659,6 +663,15 @@ extension WorktreeTerminalState { if surfaceRunningStartedAtById[surface.id] == nil { surfaceRunningStartedAtById[surface.id] = now } + } else if let pgid = surface.bridge.foregroundProcessGroupID(), + hasRunningForegroundProcess(processGroupID: pgid, shellPID: surface.bridge.childPID()) + { + // Fallback: detect active commands that don't emit OSC 9;4 + // (npm run build, git clone, sleep, etc.) + isRunningNow = true + if surfaceRunningStartedAtById[surface.id] == nil { + surfaceRunningStartedAtById[surface.id] = now + } } else { surfaceRunningStartedAtById.removeValue(forKey: surface.id) } @@ -668,6 +681,24 @@ extension WorktreeTerminalState { emitTaskStatusIfChanged() } + /// Check whether the foreground process group contains active processes + /// beyond the idle shell. Returns `true` when a command (e.g. npm run build, + /// git clone, sleep) is running, `false` when the shell is sitting at a + /// prompt. + /// + /// When a shell executes `sleep 60`, both shell and sleep are in the **same** + /// process group (the shell's, since the shell is the pgid leader). The + /// foreground pgid from `tcgetpgrp` does NOT change — it stays equal to the + /// shell's PID. So comparing pgids can't distinguish "shell at prompt" from + /// "shell running a command". Instead, we enumerate the foreground process + /// group and check whether any member is alive **besides the shell itself**: + /// - pgid contains only shell PID → idle prompt → not running + /// - pgid contains shell PID + sleep PID → command running → true + private func hasRunningForegroundProcess(processGroupID: pid_t, shellPID: pid_t?) -> Bool { + let pids = ProcessDetection.processGroupPIDs(processGroupID) + return pids.contains { $0 > 0 && $0 != shellPID && ProcessDetection.processBSDInfo(pid: $0) != nil } + } + func emitTaskStatusIfChanged() { let newStatus = taskStatus if newStatus != lastReportedTaskStatus { diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift index cf5b8b17..4ee4303d 100644 --- a/supacodeTests/ProcessDetectionSmokeTests.swift +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -1,4 +1,5 @@ import Darwin +import Foundation import Testing @testable import supacode @@ -22,4 +23,53 @@ struct ProcessDetectionSmokeTests { #expect(pids.contains(getpid())) } + + /// When the shell runs a foreground command (e.g. `sleep 60`), the command + /// joins the shell's process group — `tcgetpgrp` does NOT return a new pgid. + /// The only way to tell that work is happening is to check whether the + /// foreground process group contains any processes **besides the shell itself**. + /// + /// This test validates that signal: spawn `sleep 3` as a child in the same + /// process group, verify we see >1 process, then wait for it to exit and + /// verify the group collapses back to just us. + @Test func detectsForegroundCommandInSameProcessGroup() async throws { + let selfPID = getpid() + let myPgid = getpgrp() + + // Spawn `sleep 3` in our process group (the same pattern a shell uses for + // foreground commands). + let process = Process() + process.executableURL = URL(filePath: "/bin/sleep") + process.arguments = ["3"] + try process.run() + let sleepPID = process.processIdentifier + + // Ensure the child is in our process group (should be inherited). + let childPgid = getpgid(sleepPID) + #expect(childPgid == myPgid) + + // While sleep is running, the process group should contain at least 2 + // live processes: ourselves and sleep. + let pidsWhileRunning = ProcessDetection.processGroupPIDs(myPgid) + let hasOtherProcessWhileRunning = pidsWhileRunning.contains { + $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil + } + #expect(hasOtherProcessWhileRunning, "Expected to detect sleep in pgid \(myPgid) but found: \(pidsWhileRunning)") + + // Wait for sleep to exit. + process.waitUntilExit() + + // After sleep exits, the only live process in our pgid should be ourselves. + // Give the kernel a moment to reap. + try await Task.sleep(for: .milliseconds(100)) + + let pidsAfterExit = ProcessDetection.processGroupPIDs(myPgid) + let hasOtherProcessAfterExit = pidsAfterExit.contains { + $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil + } + #expect( + !hasOtherProcessAfterExit, + "Expected no other process in pgid \(myPgid) after sleep exited but found: \(pidsAfterExit)" + ) + } } -- 2.51.2 From 11c6f298fc1df8408e2ea7afd42daef18a9c5402 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 20 Jun 2026 11:00:20 +0800 Subject: [PATCH 503/528] fix(test): don't assume Process() child inherits parent process group On macOS, Process() may place the child in its own process group via posix_spawn. Look up the child's actual pgid with getpgid() rather than assuming it matches the test process's group. Signed-off-by: Alex --- .../ProcessDetectionSmokeTests.swift | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift index 4ee4303d..1282340c 100644 --- a/supacodeTests/ProcessDetectionSmokeTests.swift +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -24,52 +24,50 @@ struct ProcessDetectionSmokeTests { #expect(pids.contains(getpid())) } - /// When the shell runs a foreground command (e.g. `sleep 60`), the command - /// joins the shell's process group — `tcgetpgrp` does NOT return a new pgid. - /// The only way to tell that work is happening is to check whether the - /// foreground process group contains any processes **besides the shell itself**. + /// Validates that ProcessDetection can detect a foreground command running + /// in a process group and correctly reports the group as empty after exit. /// - /// This test validates that signal: spawn `sleep 3` as a child in the same - /// process group, verify we see >1 process, then wait for it to exit and - /// verify the group collapses back to just us. + /// On macOS, Process() may place the child in its own process group (via + /// posix_spawn), so we look up the child's pgid rather than assuming it + /// matches the test process's group. @Test func detectsForegroundCommandInSameProcessGroup() async throws { let selfPID = getpid() - let myPgid = getpgrp() - // Spawn `sleep 3` in our process group (the same pattern a shell uses for - // foreground commands). + // Spawn `sleep 3` — macOS Process() may place it in its own process group. let process = Process() process.executableURL = URL(filePath: "/bin/sleep") process.arguments = ["3"] try process.run() let sleepPID = process.processIdentifier - // Ensure the child is in our process group (should be inherited). + // Find the child's actual process group (may differ from our pgid on macOS). let childPgid = getpgid(sleepPID) - #expect(childPgid == myPgid) + #expect(childPgid > 0) - // While sleep is running, the process group should contain at least 2 - // live processes: ourselves and sleep. - let pidsWhileRunning = ProcessDetection.processGroupPIDs(myPgid) - let hasOtherProcessWhileRunning = pidsWhileRunning.contains { + // While sleep is running, its process group should contain at least one + // live process (the sleep process itself). + let pidsWhileRunning = ProcessDetection.processGroupPIDs(childPgid) + let hasLiveProcessWhileRunning = pidsWhileRunning.contains { $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil } - #expect(hasOtherProcessWhileRunning, "Expected to detect sleep in pgid \(myPgid) but found: \(pidsWhileRunning)") + #expect( + hasLiveProcessWhileRunning, + "Expected to detect live process in pgid \(childPgid) but found: \(pidsWhileRunning)" + ) // Wait for sleep to exit. process.waitUntilExit() - // After sleep exits, the only live process in our pgid should be ourselves. - // Give the kernel a moment to reap. + // After sleep exits, the process group should have no other live processes. try await Task.sleep(for: .milliseconds(100)) - let pidsAfterExit = ProcessDetection.processGroupPIDs(myPgid) - let hasOtherProcessAfterExit = pidsAfterExit.contains { + let pidsAfterExit = ProcessDetection.processGroupPIDs(childPgid) + let hasLiveProcessAfterExit = pidsAfterExit.contains { $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil } #expect( - !hasOtherProcessAfterExit, - "Expected no other process in pgid \(myPgid) after sleep exited but found: \(pidsAfterExit)" + !hasLiveProcessAfterExit, + "Expected no live process in pgid \(childPgid) after sleep exited but found: \(pidsAfterExit)" ) } } -- 2.51.2 From 4cf3582d1dd6ab529a8099f11076f563abdb544f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 20 Jun 2026 20:52:53 +0900 Subject: [PATCH 504/528] Fix workspace sidebar: full-row click area and collapse/expand all - Replace Button(.plain) with onTapGesture + contentShape in WorkspaceChildRowsView so the entire row is clickable, matching the pattern used by WorktreeRowsView. - Include workspaces in expandableRepositoryIDs so the collapse/expand all toggle applies to them. --- .../Repositories/Views/SidebarListView.swift | 2 +- .../Views/WorkspaceChildRowsView.swift | 93 ++++++++----------- 2 files changed, 41 insertions(+), 54 deletions(-) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index dd9f05c2..30c69c34 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -503,7 +503,7 @@ struct SidebarListView: View { ) -> Set where Repositories.Element == Repository { Set( repositories - .filter(\.capabilities.supportsWorktrees) + .filter { $0.capabilities.supportsWorktrees || $0.isWorkspace } .map(\.id) ) } diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index a37f9457..f815bb75 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -12,60 +12,47 @@ struct WorkspaceChildRowsView: View { var body: some View { ForEach(rows) { row in let isSelected = row.id == selectedID - WorkspaceChildRowButton(row: row, isSelected: isSelected, onSelect: onSelect) - .padding(.leading, 14) - .padding(.trailing, 8) - .background { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.18)) - .padding(.horizontal, 6) - } + WorktreeRow( + name: row.branchName ?? row.repositoryName, + worktreeName: row.branchName == nil ? "" : row.repositoryName, + info: row.info, + iconSystemName: nil, + showsPullRequestInfo: true, + isHovered: false, + isPinned: false, + isMainWorktree: false, + isLoading: false, + taskStatus: nil, + isRunScriptRunning: false, + showsNotificationIndicator: false, + notifications: [], + onFocusNotification: { _ in }, + shortcutHint: nil, + showsShortcutHint: false, + pinAction: nil, + isSelected: isSelected, + archiveAction: nil, + onDiffTap: nil, + onStopRunScript: nil, + ) + .padding(.leading, 14) + .padding(.trailing, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.18)) + .padding(.horizontal, 6) } - .padding(.vertical, 2) - .id(row.id) + } + .padding(.vertical, 2) + .contentShape(.interaction, .rect) + .contentShape(.rect) + .onTapGesture { + onSelect(row.id) + } + .accessibilityAddTraits(.isButton) + .help("Focus Terminal in \(row.repositoryName)") + .id(row.id) } } } - -private struct WorkspaceChildRowButton: View { - let row: WorkspaceChildRowModel - let isSelected: Bool - let onSelect: (String) -> Void - - var body: some View { - Button { - onSelect(row.id) - } label: { - content - } - .buttonStyle(.plain) - .help("Focus Terminal in \(row.repositoryName)") - } - - private var content: some View { - WorktreeRow( - name: row.branchName ?? row.repositoryName, - worktreeName: row.branchName == nil ? "" : row.repositoryName, - info: row.info, - iconSystemName: nil, - showsPullRequestInfo: true, - isHovered: false, - isPinned: false, - isMainWorktree: false, - isLoading: false, - taskStatus: nil, - isRunScriptRunning: false, - showsNotificationIndicator: false, - notifications: [], - onFocusNotification: { _ in }, - shortcutHint: nil, - showsShortcutHint: false, - pinAction: nil, - isSelected: isSelected, - archiveAction: nil, - onDiffTap: nil, - onStopRunScript: nil, - ) - } -} -- 2.51.2 From 0af6cc9fe1e129b4ea25a00acbecfc77b525a695 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 20 Jun 2026 21:22:20 +0900 Subject: [PATCH 505/528] Sync docs for 2026.6.20 --- docs/.sync-meta.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index f5926baf..bd372df3 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "40afc8c21b81833844b1667f3f4b101bb34aa183", - "last_synced_date": "2026-06-18", - "note": "Canvas card title bar now supports right-click context menu (Rename Tab, Change Icon, Close Tab, Close Other/All Tabs in This Worktree) — added to canvas.md. Shelf context menu now shows 'Close Tabs Below' instead of 'Close Tabs to the Right' (label-only, tolerated). PR refresh fixes (#456, #469) and layout restore race fix (#459) are internal behavior fixes, not new features. Toolbar icon fix (#467) cosmetic only." + "last_synced_commit": "0a49b5e56295ddf866fd6075a57de42a0a9847b5", + "last_synced_date": "2026-06-20", + "note": "Workspace creation/removal/child-rows, Qwen agent detection, worktree running indicator, sidebar full-row click and collapse/expand all, CLI debug identity — all already documented in prior syncs. Baseline bump only." } -- 2.51.2 From b92c04af98ee30a380e6082ab7c1660776ca5a29 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 20 Jun 2026 21:25:15 +0900 Subject: [PATCH 506/528] bump v2026.6.20 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 5d826d16..8a7df7d7 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260618; + CURRENT_PROJECT_VERSION = 20260620; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -495,7 +495,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.18; + MARKETING_VERSION = 2026.6.20; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -530,7 +530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260618; + CURRENT_PROJECT_VERSION = 20260620; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -553,7 +553,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.18; + MARKETING_VERSION = 2026.6.20; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -583,12 +583,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260618; + CURRENT_PROJECT_VERSION = 20260620; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.18; + MARKETING_VERSION = 2026.6.20; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -605,12 +605,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260618; + CURRENT_PROJECT_VERSION = 20260620; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.18; + MARKETING_VERSION = 2026.6.20; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 585e2db3..2a1436f7 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.18" + public static let current = "2026.6.20" } -- 2.51.2 From bf38e1571538f5509c1179a719e40581648d92e5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 20 Jun 2026 21:25:16 +0900 Subject: [PATCH 507/528] Update CHANGELOG for 2026.6.20 --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9d9cee..81f204ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2026.6.20](https://github.com/onevcat/Prowl/releases/tag/v2026.6.20) + +This release adds project workspace support, bringing multi-repository orchestration to Prowl's core workflow. + +### New + +- **Project workspaces**: Group multiple repositories into a single workspace. Open the "Add Repository" menu to create a workspace, choosing from three checkout modes — link existing worktrees, create a new branch, or use an existing one. Child repos appear as collapsible rows in the sidebar with their branch, diff, and PR status at a glance. Removing a workspace offers optional file cleanup and per-repo branch deletion. +- Qwen Code is now recognized as a detectable agent, with working, blocked, and idle state detection. + +### Improved + +- The sidebar running indicator and `prowl list` task status now reflect agent activity more accurately: a pane is shown as running whenever an agent is working or blocked, including during background Claude Code workflow runs where the input box is visible but subagents are still active. + +### Fixed + +- Workspace and folder names in the toolbar navigation area now have consistent padding and width, matching the appearance of branch names. +- Clicking anywhere on a workspace child row in the sidebar now selects it, not just the label text. +- "Collapse all" and "Expand all" in the sidebar header now correctly apply to workspace sections. + ## [2026.6.18](https://github.com/onevcat/Prowl/releases/tag/v2026.6.18) ### New -- 2.51.2 From a2cbfa82dc997ecf765d90a0b7642cf4ab60bdf3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 22 Jun 2026 23:24:27 +0900 Subject: [PATCH 508/528] Adaptive line-diff debounce by repo size and include untracked lines in badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed 5s/30s debounce intervals with per-repository timing tiers based on the git index entry count. Small repos (<5K files) now refresh in 1–2s, medium repos (5K–20K) in 2–5s, and large repos (>20K) keep a conservative 5–15s debounce. The file count is read from the git index binary header (12 bytes, zero subprocess overhead) and cached per repository root. Also fold untracked file line counts into the sidebar badge's +N number by running `git ls-files --others` concurrently with `git diff HEAD --shortstat` inside `lineChanges()`. Binary files (NUL byte in first 8KB) are skipped. Closes #488 --- .../2026-06-22-adaptive-line-diff-strategy.md | 228 ++++++++++++++++++ supacode/Clients/Git/GitClient.swift | 68 +++++- .../WorktreeInfoWatcherManager.swift | 56 ++++- supacodeTests/GitClientLineChangesTests.swift | 175 ++++++++++++-- .../WorktreeInfoWatcherManagerTests.swift | 103 +++++++- 5 files changed, 602 insertions(+), 28 deletions(-) create mode 100644 doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md diff --git a/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md b/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md new file mode 100644 index 00000000..6f6d3e15 --- /dev/null +++ b/doc-onevcat/plans/2026-06-22-adaptive-line-diff-strategy.md @@ -0,0 +1,228 @@ +# Adaptive Line-Diff Strategy & Untracked File Badge + +Ref: [#488](https://github.com/onevcat/Prowl/issues/488) + +## Background + +PR #365 (2026-05-28) replaced fixed-cadence line-diff polling with an +event-driven model. The design was motivated by a user report (#364) of high CPU +usage in a very large repository — `git diff HEAD --shortstat` was running on all +worktrees at a fixed cadence regardless of whether anything had changed. + +The solution introduced: + +| Parameter | Value | Purpose | +|---|---|---| +| `filesChangedDebounceInterval` | 5 s | Debounce after HEAD watcher fires (branch switch / commit) | +| `lineChangesEventDebounceInterval` | 30 s | Debounce after FSEvents fires (file edits in active worktrees) | +| `lineChangesSafetyRefreshInterval` | 300 s | Fallback for missed FSEvents / sleep-wake | +| `isLineChangesActive` gate | selected ∪ opened | Only active worktrees start FSEvents + safety refresh | +| `observeLineDiffsAutomatically` | per-repo toggle | Escape hatch to disable line-diff entirely | + +These values were chosen conservatively for worst-case large repos. + +## Problem + +For normal-sized repos the 30 s FSEvents debounce makes the sidebar badge feel +"stuck". Users edit a file and the badge takes 30+ seconds to update (if they +keep editing, the timer keeps resetting). + +Issue #488 correctly identifies this lag but overstates how cheap +`git diff HEAD --shortstat` is. Our benchmarks on this machine: + +| Repo size | Dirty files | Wall time | +|---|---|---| +| 5 000 tracked files | clean | ~14 ms | +| 5 000 tracked files | 5 000 dirty | ~550 ms | +| 20 000 tracked files | clean | ~31 ms | +| 20 000 tracked files | 5 000 dirty | ~484 ms | +| 20 000 tracked files | 20 000 dirty | ~2.0 s | +| 50 000 tracked files | clean | ~67 ms | +| 50 000 tracked files | 10 000 dirty | ~1.1 s | + +`--shortstat` still computes line-level diffs (not just metadata) because it +reports `+N/-M` line counts. Cost scales with the number of dirty files, not +repo size alone. For large repos with agents modifying many files concurrently, +sub-second git processes at aggressive intervals compound into sustained CPU +load. + +A one-size-fits-all debounce interval cannot serve both audiences. + +### Additional finding: untracked files ignored by badge + +`GitClient.lineChanges()` runs `git diff HEAD --shortstat`, which only reports +tracked file changes. Newly created (untracked) files are invisible to the badge +while the Diff window (`⌘⇧Y`) includes them via `git ls-files --others +--exclude-standard`. This creates a user-visible inconsistency: the badge shows ++0/-0 but the Diff window lists new files. + +## Plan + +### Part 1: Adaptive debounce based on repo size + +**Core idea**: read the repository's tracked file count once (cheap), classify +the repo into a size tier, and use that tier to select debounce intervals. + +#### Reading the file count + +The git index binary format stores the entry count as a big-endian `UInt32` at +byte offset 8. Reading 12 bytes from the index file gives an exact count with +zero subprocess overhead. + +``` +bytes 0–3: signature ("DIRC") +bytes 4–7: version (2/3/4) +bytes 8–11: entry count (big-endian UInt32) +``` + +For worktrees the index lives at the worktree's own git directory (resolved via +the `.git` file → `gitdir:` pointer). Since all worktrees of the same repository +track roughly the same set of files, the count can be cached per +**repository root** and refreshed lazily (e.g. on `setWorktrees` or once per +app-foreground cycle). + +Implementation: add a method on `GitClient`: + +```swift +nonisolated func indexEntryCount(at gitDir: URL) -> Int? { + let indexURL = gitDir.appending(path: "index") + guard let handle = try? FileHandle(forReadingFrom: indexURL) else { return nil } + defer { try? handle.close() } + guard let header = try? handle.read(upToCount: 12), header.count == 12 else { return nil } + return Int( + header[8...11].withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + ) +} +``` + +#### Size tiers and intervals + +| Tier | Tracked files | FSEvents debounce | HEAD debounce | Safety refresh | +|---|---|---|---|---| +| Small | < 5 000 | 2 s | 1 s | 300 s | +| Medium | 5 000 – 20 000 | 5 s | 2 s | 300 s | +| Large | > 20 000 | 15 s | 5 s | 300 s | + +The existing `observeLineDiffsAutomatically = false` toggle remains the hard +opt-out for truly massive repos where even 15 s is too aggressive. + +Thresholds are tentative — we can tune after real-world feedback. + +#### Where to apply + +`WorktreeInfoWatcherManager` currently takes `filesChangedDebounceInterval` and +`lineChangesEventDebounceInterval` as constructor parameters (single values for +all worktrees). Change these to be resolved **per worktree** by looking up the +cached repo file count and mapping to a tier. + +Specifically: +- `scheduleFilesChanged(worktreeID:)` — use the per-repo HEAD debounce. +- `scheduleLineChangesDebouncedRefresh(worktreeID:)` — use the per-repo FSEvents + debounce. + +The file count cache lives on `WorktreeInfoWatcherManager` as a +`[URL: Int]` dictionary keyed by repository root URL. It is populated when +worktrees are set / updated, and refreshed on app-foreground. No async work +needed — the index read is synchronous and takes <1 ms. + +#### Tier resolution + +Add a helper that maps a file count to debounce intervals: + +```swift +struct LineChangesTimingTier { + let filesChangedDebounce: Duration + let eventDebounce: Duration +} + +func lineChangesTimingTier(forFileCount count: Int) -> LineChangesTimingTier { + switch count { + case ..<5_000: + return LineChangesTimingTier(filesChangedDebounce: .seconds(1), eventDebounce: .seconds(2)) + case ..<20_000: + return LineChangesTimingTier(filesChangedDebounce: .seconds(2), eventDebounce: .seconds(5)) + default: + return LineChangesTimingTier(filesChangedDebounce: .seconds(5), eventDebounce: .seconds(15)) + } +} +``` + +### Part 2: Include untracked file lines in the badge + +#### Approach + +Count the **lines** in untracked files and fold them into the existing `+N` +number. No layout change to the badge — untracked lines are conceptually +"added lines" (they would show as `+` in a full diff). + +#### GitClient change + +`lineChanges(at:)` currently returns `(added: Int, removed: Int)?`. Keep the +same return type — the `added` count now includes untracked line counts. + +Inside `lineChanges(at:)`, run the existing `git diff HEAD --shortstat` and +`git ls-files --others --exclude-standard` concurrently via `async let`: + +```swift +async let diffOutput = runGit(operation: .lineChanges, arguments: [..., "diff", "HEAD", "--shortstat"]) +async let untrackedOutput = runGit(operation: .untrackedFilePaths, arguments: [..., "ls-files", "--others", "--exclude-standard"]) + +let tracked = parseShortstat(await diffOutput) +let untrackedPaths = parseUntrackedPaths(await untrackedOutput) +let untrackedLines = countLinesInFiles(untrackedPaths, relativeTo: worktreeURL) + +return (added: tracked.added + untrackedLines, removed: tracked.removed) +``` + +`countLinesInFiles` reads each file's `Data` and counts `0x0A` bytes. If a NUL +byte (`0x00`) appears in the first 8 KB, the file is treated as binary and +skipped (matches git's heuristic). This is pure in-process I/O with no +subprocess overhead. + +Performance: 1 000 untracked files × 100 lines = ~43 ms total (including the +`git ls-files` subprocess). The `async let` parallelism means it overlaps with +`git diff HEAD --shortstat` and adds minimal wall-clock time. + +#### Badge display + +No change to layout. The `+N` number now reflects tracked additions + +untracked file lines combined. + +Before: `+120 -45` (tracked only; creating a new 30-line file shows nothing) +After: `+150 -45` (30-line new file adds to the count) + +## Scope and non-goals + +- **Not changing `isLineChangesActive` gating**: inactive worktrees still don't + run FSEvents monitors. This is correct — a worktree you haven't opened doesn't + need sub-second freshness. The existing deferred refresh on open/select is + sufficient. +- **Not changing PR polling**: already batched via `PullRequestRefreshCoordinator` + into a single GraphQL call per host. Not a scaling concern. +- **Not exposing debounce intervals to users**: the adaptive tier handles it + automatically. `observeLineDiffsAutomatically = false` remains the manual + escape hatch. +- **Not replacing `git diff HEAD --shortstat`** with `git status --porcelain` or + similar. The current command gives exact line counts which the badge needs; + `--porcelain` would give file counts only. + +## Affected files + +| File | Change | +|---|---| +| `GitClient.swift` | Add `indexEntryCount(at:)`; add `countLinesInFiles` helper; extend `lineChanges()` to include untracked lines in `added` | +| `WorktreeInfoWatcherManager.swift` | Per-repo file count cache; per-worktree tier resolution for debounce intervals | +| `RepositoriesFeature+CoreReducer.swift` | No change needed — `added` already flows through | +| `WorktreeInfoWatcherManagerTests.swift` | Test tier selection; test debounce varies by repo size | + +## Testing + +- Unit test `indexEntryCount` with a hand-crafted 12-byte header. +- Unit test `lineChangesTimingTier` for boundary values. +- Unit test `countLinesInFiles`: text files counted, binary files (NUL in first + 8 KB) skipped, missing files skipped. +- Watcher manager tests: verify that worktrees in repos of different sizes get + different debounce intervals. +- Manual: open a small repo, edit a file, verify badge updates within ~2 s. + Create a new file, verify its lines appear in the `+N` count. + Toggle `observeLineDiffsAutomatically = false`, verify badge stops updating. diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 45fa5f52..d71d5112 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -428,17 +428,72 @@ struct GitClient { } let path = worktreeURL.path(percentEncoded: false) do { - let diff = try await runGit( + async let diffOutput = runGit( operation: .lineChanges, arguments: ["-C", path, "diff", "HEAD", "--shortstat"] ) - let changes = parseShortstat(diff) - return (added: changes.added, removed: changes.removed) + async let untrackedOutput = runGit( + operation: .untrackedFilePaths, + arguments: ["-C", path, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"] + ) + let tracked = parseShortstat(try await diffOutput) + let untrackedPaths = parseFileList(try await untrackedOutput) + let untrackedLines = Self.countLinesInFiles(untrackedPaths, relativeTo: worktreeURL) + return (added: tracked.added + untrackedLines, removed: tracked.removed) } catch { return nil } } + nonisolated static func indexEntryCount(at worktreeURL: URL) -> Int? { + let gitDir = resolveGitDirectory(for: worktreeURL) + guard let gitDir else { return nil } + let indexURL = gitDir.appending(path: "index") + guard let handle = try? FileHandle(forReadingFrom: indexURL) else { return nil } + defer { try? handle.close() } + guard let header = try? handle.read(upToCount: 12), header.count == 12 else { return nil } + return header[8...11].withUnsafeBytes { Int($0.load(as: UInt32.self).bigEndian) } + } + + nonisolated static func countLinesInFiles(_ relativePaths: [String], relativeTo base: URL) -> Int { + var total = 0 + for relativePath in relativePaths { + let fileURL = base.appending(path: relativePath) + guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { continue } + let prefixCount = min(data.count, 8192) + if data.prefix(prefixCount).contains(0x00) { continue } + total += data.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + } + return total + } + + nonisolated private static func resolveGitDirectory(for worktreeURL: URL) -> URL? { + let gitURL = worktreeURL.appending(path: ".git") + var isDirectory = ObjCBool(false) + guard + FileManager.default.fileExists( + atPath: gitURL.path(percentEncoded: false), + isDirectory: &isDirectory + ) + else { + return nil + } + if isDirectory.boolValue { + return gitURL + } + guard + let contents = try? String(contentsOf: gitURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("gitdir:") else { return nil } + let pathPart = trimmed.dropFirst("gitdir:".count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { return nil } + return URL(fileURLWithPath: String(pathPart), relativeTo: worktreeURL).standardizedFileURL + } + nonisolated private func isWorktreeIndexLocked(_ worktreeURL: URL) async -> Bool { let headURL = await MainActor.run { GitWorktreeHeadResolver.headURL( @@ -744,12 +799,15 @@ struct GitClient { return (added, removed) } - nonisolated private func parseFileListCount(_ output: String) -> Int { + nonisolated private func parseFileList(_ output: String) -> [String] { output .split(whereSeparator: \.isNewline) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - .count + } + + nonisolated private func parseFileListCount(_ output: String) -> Int { + parseFileList(output).count } nonisolated private func lastNonEmptyLine(in output: String) -> String? { diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index c8bda1f5..15aab400 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -51,11 +51,31 @@ final class WorktreeInfoWatcherManager { let unfocused: Duration } - private let filesChangedDebounceInterval: Duration + struct LineChangesTiming: Equatable, Sendable { + let filesChangedDebounce: Duration + let eventDebounce: Duration + + static let small = LineChangesTiming(filesChangedDebounce: .seconds(1), eventDebounce: .seconds(2)) + static let medium = LineChangesTiming(filesChangedDebounce: .seconds(2), eventDebounce: .seconds(5)) + static let large = LineChangesTiming(filesChangedDebounce: .seconds(5), eventDebounce: .seconds(15)) + + static func tier(forIndexEntryCount count: Int) -> LineChangesTiming { + switch count { + case ..<5_000: return .small + case ..<20_000: return .medium + default: return .large + } + } + } + + typealias IndexEntryCountProvider = @Sendable (URL) -> Int? + + private let defaultLineChangesTiming: LineChangesTiming private let repositoryWorktreesEventDebounceInterval: Duration private let remoteConfigEventDebounceInterval: Duration - private let lineChangesEventDebounceInterval: Duration private let lineChangesSafetyRefreshInterval: Duration + private let indexEntryCountProvider: IndexEntryCountProvider + private var repositoryLineChangesTimings: [URL: LineChangesTiming] = [:] private let pullRequestSelectionRefreshCooldown: Duration private let refreshTiming: RefreshTiming private let lineChangePhaseOffset: WorktreePhaseOffset @@ -88,10 +108,9 @@ final class WorktreeInfoWatcherManager { init>( focusedInterval: Duration = .seconds(30), unfocusedInterval: Duration = .seconds(60), - filesChangedDebounceInterval: Duration = .seconds(5), + defaultLineChangesTiming: LineChangesTiming = .small, repositoryWorktreesEventDebounceInterval: Duration = .seconds(2), remoteConfigEventDebounceInterval: Duration = .seconds(2), - lineChangesEventDebounceInterval: Duration = .seconds(30), lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), lineChangePhaseOffset: @escaping WorktreePhaseOffset = WorktreeInfoWatcherManager.defaultLineChangePhaseOffset, @@ -102,13 +121,13 @@ final class WorktreeInfoWatcherManager { WorktreeInfoWatcherManager.defaultWorktreeRegistryMonitorFactory, remoteConfigMonitorFactory: @escaping RemoteConfigMonitorFactory = WorktreeInfoWatcherManager.defaultRemoteConfigMonitorFactory, + indexEntryCountProvider: @escaping IndexEntryCountProvider = { GitClient.indexEntryCount(at: $0) }, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) - self.filesChangedDebounceInterval = filesChangedDebounceInterval + self.defaultLineChangesTiming = defaultLineChangesTiming self.repositoryWorktreesEventDebounceInterval = repositoryWorktreesEventDebounceInterval self.remoteConfigEventDebounceInterval = remoteConfigEventDebounceInterval - self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown self.lineChangePhaseOffset = lineChangePhaseOffset @@ -116,6 +135,7 @@ final class WorktreeInfoWatcherManager { self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory self.worktreeRegistryMonitorFactory = worktreeRegistryMonitorFactory self.remoteConfigMonitorFactory = remoteConfigMonitorFactory + self.indexEntryCountProvider = indexEntryCountProvider self.sleep = { duration in try await clock.sleep(for: duration) } @@ -176,6 +196,8 @@ final class WorktreeInfoWatcherManager { hasCompletedInitialWorktreeLoad = true } let repositoryRoots = Set(worktrees.map(\.repositoryRootURL)) + let normalizedRoots = Set(repositoryRoots.map { $0.standardizedFileURL }) + refreshRepositoryTimings(for: normalizedRoots) syncWorktreeRegistryMonitors(repositoryRoots: repositoryRoots) syncRemoteConfigMonitors(repositoryRoots: repositoryRoots) for repositoryRootURL in repositoryRoots { @@ -309,7 +331,7 @@ final class WorktreeInfoWatcherManager { private func scheduleFilesChanged(worktreeID: Worktree.ID) { filesDebounceTasks[worktreeID]?.cancel() - let debounceInterval = filesChangedDebounceInterval + let debounceInterval = lineChangesTiming(for: worktreeID).filesChangedDebounce let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(debounceInterval) @@ -528,7 +550,7 @@ final class WorktreeInfoWatcherManager { } private func scheduleLineChangesDebouncedRefresh(worktreeID: Worktree.ID) { - scheduleLineChangesRefresh(worktreeID: worktreeID, delay: lineChangesEventDebounceInterval) + scheduleLineChangesRefresh(worktreeID: worktreeID, delay: lineChangesTiming(for: worktreeID).eventDebounce) } private func updateLineChangesSafetySchedule(worktreeID: Worktree.ID) { @@ -588,6 +610,24 @@ final class WorktreeInfoWatcherManager { selectedWorktreeID == worktreeID || openedWorktreeIDs.contains(worktreeID) } + private func lineChangesTiming(for worktreeID: Worktree.ID) -> LineChangesTiming { + guard let worktree = worktrees[worktreeID] else { return defaultLineChangesTiming } + let repoRoot = worktree.repositoryRootURL.standardizedFileURL + return repositoryLineChangesTimings[repoRoot] ?? defaultLineChangesTiming + } + + private func refreshRepositoryTimings(for repositoryRoots: Set) { + let obsoleteRoots = repositoryLineChangesTimings.keys.filter { !repositoryRoots.contains($0) } + for root in obsoleteRoots { + repositoryLineChangesTimings.removeValue(forKey: root) + } + for root in repositoryRoots where repositoryLineChangesTimings[root] == nil { + if let count = indexEntryCountProvider(root) { + repositoryLineChangesTimings[root] = LineChangesTiming.tier(forIndexEntryCount: count) + } + } + } + private func startWorktreeFileEventMonitorIfNeeded(for worktree: Worktree) { guard worktreeFileEventMonitors[worktree.id] == nil else { return diff --git a/supacodeTests/GitClientLineChangesTests.swift b/supacodeTests/GitClientLineChangesTests.swift index e7556054..689be317 100644 --- a/supacodeTests/GitClientLineChangesTests.swift +++ b/supacodeTests/GitClientLineChangesTests.swift @@ -12,13 +12,16 @@ actor LineChangesShellCallStore { } struct GitClientLineChangesTests { - @Test func lineChangesUsesShortstatAndParsesOutput() async { + @Test func lineChangesUsesShortstatAndParsesOutput() async throws { let store = LineChangesShellCallStore() - let output = " 1 file changed, 12 insertions(+), 3 deletions(-)\n" let shell = ShellClient( run: { _, arguments, _ in await store.record(arguments) - return ShellOutput(stdout: output, stderr: "", exitCode: 0) + if arguments.contains("--shortstat") { + return ShellOutput( + stdout: " 1 file changed, 12 insertions(+), 3 deletions(-)\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) @@ -29,19 +32,25 @@ struct GitClientLineChangesTests { #expect(changes?.added == 12) #expect(changes?.removed == 3) let calls = await store.calls - #expect(calls.count == 1) - let args = calls[0] - #expect(args.first == "git") - #expect(args.contains("diff")) - #expect(args.contains("HEAD")) - #expect(args.contains("--shortstat")) - #expect(!args.contains("--numstat")) + #expect(calls.count == 2) + let diffArgs = try #require(calls.first { $0.contains("--shortstat") }) + #expect(diffArgs.first == "git") + #expect(diffArgs.contains("diff")) + #expect(diffArgs.contains("HEAD")) + #expect(!diffArgs.contains("--numstat")) + let untrackedArgs = try #require(calls.first { $0.contains("ls-files") }) + #expect(untrackedArgs.contains("--others")) + #expect(untrackedArgs.contains("--exclude-standard")) } @Test func lineChangesHandlesMissingDeletions() async { - let output = " 1 file changed, 5 insertions(+)\n" let shell = ShellClient( - run: { _, _, _ in ShellOutput(stdout: output, stderr: "", exitCode: 0) }, + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput(stdout: " 1 file changed, 5 insertions(+)\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell) @@ -53,9 +62,14 @@ struct GitClientLineChangesTests { } @Test func lineChangesParsesShortstatLine() async { - let output = "1 file changed, 10 insertions(+), 4 deletions(-)\n" let shell = ShellClient( - run: { _, _, _ in ShellOutput(stdout: output, stderr: "", exitCode: 0) }, + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput( + stdout: "1 file changed, 10 insertions(+), 4 deletions(-)\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell) @@ -79,6 +93,77 @@ struct GitClientLineChangesTests { #expect(changes?.removed == 0) } + @Test func lineChangesIncludesUntrackedFileLines() async throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + let headURL = gitDirectory.appending(path: "HEAD") + try "ref: refs/heads/main\n".write(to: headURL, atomically: true, encoding: .utf8) + + let untrackedFile = tempRoot.appending(path: "new_file.swift") + try "line1\nline2\nline3\n".write(to: untrackedFile, atomically: true, encoding: .utf8) + + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput( + stdout: " 1 file changed, 10 insertions(+), 2 deletions(-)\n", stderr: "", exitCode: 0) + } + if arguments.contains("ls-files") { + return ShellOutput(stdout: "new_file.swift\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let changes = await client.lineChanges(at: tempRoot) + + #expect(changes?.added == 13) + #expect(changes?.removed == 2) + } + + @Test func lineChangesSkipsBinaryUntrackedFiles() async throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + let headURL = gitDirectory.appending(path: "HEAD") + try "ref: refs/heads/main\n".write(to: headURL, atomically: true, encoding: .utf8) + + let binaryFile = tempRoot.appending(path: "image.png") + var binaryData = Data("PNG\n".utf8) + binaryData.append(0x00) + binaryData.append(contentsOf: Data(repeating: 0x0A, count: 100)) + try binaryData.write(to: binaryFile) + + let textFile = tempRoot.appending(path: "readme.txt") + try "hello\nworld\n".write(to: textFile, atomically: true, encoding: .utf8) + + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + if arguments.contains("ls-files") { + return ShellOutput(stdout: "image.png\nreadme.txt\n", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let changes = await client.lineChanges(at: tempRoot) + + #expect(changes?.added == 2) + #expect(changes?.removed == 0) + } + @Test func lineChangesSkipsWhenIndexLocked() async throws { let fileManager = FileManager.default let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) @@ -105,4 +190,66 @@ struct GitClientLineChangesTests { let calls = await store.calls #expect(calls.isEmpty) } + + @Test func indexEntryCountReadsGitIndexHeader() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + + var header = Data() + header.append(contentsOf: "DIRC".utf8) + var version = UInt32(2).bigEndian + header.append(Data(bytes: &version, count: 4)) + var entryCount = UInt32(42_000).bigEndian + header.append(Data(bytes: &entryCount, count: 4)) + try header.write(to: gitDirectory.appending(path: "index")) + + let count = GitClient.indexEntryCount(at: tempRoot) + #expect(count == 42_000) + } + + @Test func indexEntryCountReturnsNilForMissingIndex() { + let count = GitClient.indexEntryCount(at: URL(fileURLWithPath: "/nonexistent")) + #expect(count == nil) + } + + @Test func countLinesInFilesCountsNewlines() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try "a\nb\nc\n".write(to: tempRoot.appending(path: "a.txt"), atomically: true, encoding: .utf8) + try "x\ny\n".write(to: tempRoot.appending(path: "b.txt"), atomically: true, encoding: .utf8) + + let count = GitClient.countLinesInFiles(["a.txt", "b.txt"], relativeTo: tempRoot) + #expect(count == 5) + } + + @Test func countLinesInFilesSkipsBinaryFiles() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try "text\nfile\n".write(to: tempRoot.appending(path: "ok.txt"), atomically: true, encoding: .utf8) + var binary = Data("header\n".utf8) + binary.append(0x00) + binary.append(contentsOf: Data(repeating: 0x0A, count: 50)) + try binary.write(to: tempRoot.appending(path: "img.bin")) + + let count = GitClient.countLinesInFiles(["ok.txt", "img.bin"], relativeTo: tempRoot) + #expect(count == 2) + } + + @Test func countLinesInFilesSkipsMissingFiles() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try "a\nb\n".write(to: tempRoot.appending(path: "exists.txt"), atomically: true, encoding: .utf8) + + let count = GitClient.countLinesInFiles(["exists.txt", "gone.txt"], relativeTo: tempRoot) + #expect(count == 2) + } } diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 02efdd3a..a810ac80 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -136,7 +136,7 @@ struct WorktreeInfoWatcherManagerTests { let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), - lineChangesEventDebounceInterval: .milliseconds(80), + defaultLineChangesTiming: .init(filesChangedDebounce: .seconds(3_600), eventDebounce: .milliseconds(80)), lineChangesSafetyRefreshInterval: .seconds(3_600), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, @@ -398,6 +398,107 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func lineChangesTimingTierSelectsCorrectTier() { + let small = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 500) + #expect(small == .small) + + let smallBoundary = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 4_999) + #expect(smallBoundary == .small) + + let medium = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 5_000) + #expect(medium == .medium) + + let mediumBoundary = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 19_999) + #expect(mediumBoundary == .medium) + + let large = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 20_000) + #expect(large == .large) + + let veryLarge = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 100_000) + #expect(veryLarge == .large) + } + + @Test func largeRepoUsesLongerEventDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let worktree = try #require(tempRepository.worktrees.first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let largeTiming = WorktreeInfoWatcherManager.LineChangesTiming.large + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + defaultLineChangesTiming: largeTiming, + lineChangesSafetyRefreshInterval: .seconds(3_600), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + indexEntryCountProvider: { _ in nil }, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([worktree])) + manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + let monitor = try #require(monitorStore.monitor(for: worktree.id)) + monitor.emit() + + await clock.advance(by: largeTiming.eventDebounce - .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func adaptiveTimingResolvesFromIndexEntryCount() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let worktree = try #require(tempRepository.worktrees.first) + let monitorStore = TestWorktreeFileEventMonitorStore() + let mediumTiming = WorktreeInfoWatcherManager.LineChangesTiming.medium + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + lineChangesSafetyRefreshInterval: .seconds(3_600), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + worktreeFileEventMonitorFactory: monitorStore.makeMonitor, + indexEntryCountProvider: { _ in 10_000 }, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([worktree])) + manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + let monitor = try #require(monitorStore.monitor(for: worktree.id)) + monitor.emit() + + await clock.advance(by: mediumTiming.eventDebounce - .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func canceledSelectionCooldownDoesNotClearReplacementCooldown() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) -- 2.51.2 From c10f2d8f83ca7922181dcbf3a83e3b1f7d91ec3f Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:03:45 +0900 Subject: [PATCH 509/528] Harden untracked line counting --- supacode/Clients/Git/GitClient.swift | 62 ++++++++++-- .../Views/WorktreeDetailTitleView.swift | 3 +- supacodeTests/GitClientLineChangesTests.swift | 95 +++++++++++++++++-- 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index d71d5112..0ea37f03 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -434,10 +434,10 @@ struct GitClient { ) async let untrackedOutput = runGit( operation: .untrackedFilePaths, - arguments: ["-C", path, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"] + arguments: ["-C", path, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "-z"] ) let tracked = parseShortstat(try await diffOutput) - let untrackedPaths = parseFileList(try await untrackedOutput) + let untrackedPaths = parseNULFileList(try await untrackedOutput) let untrackedLines = Self.countLinesInFiles(untrackedPaths, relativeTo: worktreeURL) return (added: tracked.added + untrackedLines, removed: tracked.removed) } catch { @@ -452,21 +452,63 @@ struct GitClient { guard let handle = try? FileHandle(forReadingFrom: indexURL) else { return nil } defer { try? handle.close() } guard let header = try? handle.read(upToCount: 12), header.count == 12 else { return nil } - return header[8...11].withUnsafeBytes { Int($0.load(as: UInt32.self).bigEndian) } + guard header.prefix(4).elementsEqual("DIRC".utf8) else { return nil } + let version = Self.bigEndianUInt32(from: header, offset: 4) + guard (2...4).contains(version) else { return nil } + return Int(Self.bigEndianUInt32(from: header, offset: 8)) } nonisolated static func countLinesInFiles(_ relativePaths: [String], relativeTo base: URL) -> Int { var total = 0 for relativePath in relativePaths { let fileURL = base.appending(path: relativePath) - guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { continue } - let prefixCount = min(data.count, 8192) - if data.prefix(prefixCount).contains(0x00) { continue } - total += data.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + total += Self.countLines(in: fileURL) ?? 0 } return total } + nonisolated private static func bigEndianUInt32(from data: Data, offset: Int) -> UInt32 { + data[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + nonisolated private static func countLines(in fileURL: URL) -> Int? { + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + + let binaryProbeByteCount = 8_192 + let chunkByteCount = 64 * 1_024 + var probedByteCount = 0 + var lineCount = 0 + var isEmpty = true + var lastByte: UInt8? + + while true { + let chunk: Data + do { + guard let readChunk = try handle.read(upToCount: chunkByteCount) else { break } + chunk = readChunk + } catch { + return nil + } + guard !chunk.isEmpty else { break } + + isEmpty = false + if probedByteCount < binaryProbeByteCount { + let remainingProbeCount = binaryProbeByteCount - probedByteCount + let probe = chunk.prefix(remainingProbeCount) + if probe.contains(0x00) { return nil } + probedByteCount += probe.count + } + lineCount += chunk.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + lastByte = chunk.last + } + + if !isEmpty, lastByte != 0x0A { + lineCount += 1 + } + return lineCount + } + nonisolated private static func resolveGitDirectory(for worktreeURL: URL) -> URL? { let gitURL = worktreeURL.appending(path: ".git") var isDirectory = ObjCBool(false) @@ -806,6 +848,12 @@ struct GitClient { .filter { !$0.isEmpty } } + nonisolated private func parseNULFileList(_ output: String) -> [String] { + output + .split(separator: "\0", omittingEmptySubsequences: true) + .map(String.init) + } + nonisolated private func parseFileListCount(_ output: String) -> Int { parseFileList(output).count } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index 657309b2..8b46ec94 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -56,7 +56,8 @@ struct WorktreeDetailTitleView: View { )) } else { // Button wrapper gives folder/workspace the same toolbar padding as the branch button. - Button {} label: { + Button { + } label: { labelContent } } diff --git a/supacodeTests/GitClientLineChangesTests.swift b/supacodeTests/GitClientLineChangesTests.swift index 689be317..fa1a62e8 100644 --- a/supacodeTests/GitClientLineChangesTests.swift +++ b/supacodeTests/GitClientLineChangesTests.swift @@ -41,6 +41,7 @@ struct GitClientLineChangesTests { let untrackedArgs = try #require(calls.first { $0.contains("ls-files") }) #expect(untrackedArgs.contains("--others")) #expect(untrackedArgs.contains("--exclude-standard")) + #expect(untrackedArgs.contains("-z")) } @Test func lineChangesHandlesMissingDeletions() async { @@ -112,7 +113,7 @@ struct GitClientLineChangesTests { stdout: " 1 file changed, 10 insertions(+), 2 deletions(-)\n", stderr: "", exitCode: 0) } if arguments.contains("ls-files") { - return ShellOutput(stdout: "new_file.swift\n", stderr: "", exitCode: 0) + return ShellOutput(stdout: "new_file.swift\0", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, @@ -150,7 +151,39 @@ struct GitClientLineChangesTests { return ShellOutput(stdout: "", stderr: "", exitCode: 0) } if arguments.contains("ls-files") { - return ShellOutput(stdout: "image.png\nreadme.txt\n", stderr: "", exitCode: 0) + return ShellOutput(stdout: "image.png\0readme.txt\0", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + ) + let client = GitClient(shell: shell) + + let changes = await client.lineChanges(at: tempRoot) + + #expect(changes?.added == 2) + #expect(changes?.removed == 0) + } + + @Test func lineChangesParsesNULSeparatedUntrackedFilePaths() async throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + let headURL = gitDirectory.appending(path: "HEAD") + try "ref: refs/heads/main\n".write(to: headURL, atomically: true, encoding: .utf8) + + let relativePath = " leading space\nname.txt" + try "alpha\nbeta".write(to: tempRoot.appending(path: relativePath), atomically: true, encoding: .utf8) + + let shell = ShellClient( + run: { _, arguments, _ in + if arguments.contains("--shortstat") { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + if arguments.contains("ls-files") { + return ShellOutput(stdout: "\(relativePath)\0", stderr: "", exitCode: 0) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) }, @@ -198,18 +231,32 @@ struct GitClientLineChangesTests { let gitDirectory = tempRoot.appending(path: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) - var header = Data() - header.append(contentsOf: "DIRC".utf8) - var version = UInt32(2).bigEndian - header.append(Data(bytes: &version, count: 4)) - var entryCount = UInt32(42_000).bigEndian - header.append(Data(bytes: &entryCount, count: 4)) - try header.write(to: gitDirectory.appending(path: "index")) + try writeGitIndexHeader(entryCount: 42_000, to: gitDirectory.appending(path: "index")) let count = GitClient.indexEntryCount(at: tempRoot) #expect(count == 42_000) } + @Test func indexEntryCountRejectsInvalidHeader() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + let gitDirectory = tempRoot.appending(path: ".git") + try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) + + var invalidMagic = Data() + invalidMagic.append(contentsOf: "NOPE".utf8) + invalidMagic.append(contentsOf: [0, 0, 0, 2]) + invalidMagic.append(contentsOf: [0, 0, 0, 1]) + try invalidMagic.write(to: gitDirectory.appending(path: "index")) + + #expect(GitClient.indexEntryCount(at: tempRoot) == nil) + + try writeGitIndexHeader(version: 99, entryCount: 1, to: gitDirectory.appending(path: "index")) + + #expect(GitClient.indexEntryCount(at: tempRoot) == nil) + } + @Test func indexEntryCountReturnsNilForMissingIndex() { let count = GitClient.indexEntryCount(at: URL(fileURLWithPath: "/nonexistent")) #expect(count == nil) @@ -227,6 +274,18 @@ struct GitClientLineChangesTests { #expect(count == 5) } + @Test func countLinesInFilesCountsFinalLineWithoutTrailingNewline() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fileManager.removeItem(at: tempRoot) } + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try "hello".write(to: tempRoot.appending(path: "single.txt"), atomically: true, encoding: .utf8) + try "a\nb".write(to: tempRoot.appending(path: "multi.txt"), atomically: true, encoding: .utf8) + + let count = GitClient.countLinesInFiles(["single.txt", "multi.txt"], relativeTo: tempRoot) + #expect(count == 3) + } + @Test func countLinesInFilesSkipsBinaryFiles() throws { let fileManager = FileManager.default let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) @@ -252,4 +311,22 @@ struct GitClientLineChangesTests { let count = GitClient.countLinesInFiles(["exists.txt", "gone.txt"], relativeTo: tempRoot) #expect(count == 2) } + + private func writeGitIndexHeader( + version: UInt32 = 2, + entryCount: UInt32, + to url: URL + ) throws { + var header = Data() + header.append(contentsOf: "DIRC".utf8) + header.append(UInt8((version >> 24) & 0xff)) + header.append(UInt8((version >> 16) & 0xff)) + header.append(UInt8((version >> 8) & 0xff)) + header.append(UInt8(version & 0xff)) + header.append(UInt8((entryCount >> 24) & 0xff)) + header.append(UInt8((entryCount >> 16) & 0xff)) + header.append(UInt8((entryCount >> 8) & 0xff)) + header.append(UInt8(entryCount & 0xff)) + try header.write(to: url) + } } -- 2.51.2 From 786c30f3ef4e1fb1447dea62335f6f0a2af253e8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:30:14 +0900 Subject: [PATCH 510/528] Fix search field keyboard navigation when focused The field editor (not the NSTextField) is the first responder during editing, so SearchField.keyDown was never called for Enter/Escape. Add control(_:textView:doCommandBy:) to the Coordinator to handle insertNewline:/cancelOperation: through the proper delegate path. Also add performKeyEquivalent override on SearchField to intercept Cmd+G / Cmd+Shift+G for find next/previous while editing. Closes #480 --- .../Views/GhosttySurfaceSearchOverlay.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 8ab37e08..a08eaa6a 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -312,6 +312,25 @@ private struct GhosttySearchField: NSViewRepresentable { guard let field = obj.object as? NSTextField else { return } text = field.stringValue } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + guard let field = control as? SearchField else { return false } + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + let isShifted = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + field.onSubmit?(isShifted) + return true + case #selector(NSResponder.cancelOperation(_:)): + field.onEscape?() + return true + default: + return false + } + } } final class SearchField: NSTextField { @@ -322,6 +341,20 @@ private struct GhosttySearchField: NSViewRepresentable { onEscape?() } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard currentEditor() != nil, + event.type == .keyDown, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + || event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command, .shift], + event.charactersIgnoringModifiers?.lowercased() == "g" + else { + return super.performKeyEquivalent(with: event) + } + let isShifted = event.modifierFlags.contains(.shift) + onSubmit?(isShifted) + return true + } + override func keyDown(with event: NSEvent) { switch event.keyCode { case 36, 76: -- 2.51.2 From fe85343fec00ae42ff520870aa0179904d0ba44c Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:32:29 +0900 Subject: [PATCH 511/528] Clean up process detection test and trim verbose comment - Make detectsForegroundCommandInSameProcessGroup synchronous: removes Task.sleep and avoids blocking a cooperative thread with waitUntilExit. Use terminate() + waitUntilExit() for deterministic, instant cleanup. - Condense hasRunningForegroundProcess doc comment to a two-line rationale. --- .../WorktreeTerminalState+Surfaces.swift | 15 ++--------- .../ProcessDetectionSmokeTests.swift | 26 +++++-------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index cb495b33..51590c5d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -681,19 +681,8 @@ extension WorktreeTerminalState { emitTaskStatusIfChanged() } - /// Check whether the foreground process group contains active processes - /// beyond the idle shell. Returns `true` when a command (e.g. npm run build, - /// git clone, sleep) is running, `false` when the shell is sitting at a - /// prompt. - /// - /// When a shell executes `sleep 60`, both shell and sleep are in the **same** - /// process group (the shell's, since the shell is the pgid leader). The - /// foreground pgid from `tcgetpgrp` does NOT change — it stays equal to the - /// shell's PID. So comparing pgids can't distinguish "shell at prompt" from - /// "shell running a command". Instead, we enumerate the foreground process - /// group and check whether any member is alive **besides the shell itself**: - /// - pgid contains only shell PID → idle prompt → not running - /// - pgid contains shell PID + sleep PID → command running → true + // Simple commands share the shell's process group, so pgid comparison alone + // can't distinguish idle from running — enumerate and check for non-shell members. private func hasRunningForegroundProcess(processGroupID: pid_t, shellPID: pid_t?) -> Bool { let pids = ProcessDetection.processGroupPIDs(processGroupID) return pids.contains { $0 > 0 && $0 != shellPID && ProcessDetection.processBSDInfo(pid: $0) != nil } diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift index 1282340c..f0c4983f 100644 --- a/supacodeTests/ProcessDetectionSmokeTests.swift +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -24,50 +24,38 @@ struct ProcessDetectionSmokeTests { #expect(pids.contains(getpid())) } - /// Validates that ProcessDetection can detect a foreground command running - /// in a process group and correctly reports the group as empty after exit. - /// - /// On macOS, Process() may place the child in its own process group (via - /// posix_spawn), so we look up the child's pgid rather than assuming it - /// matches the test process's group. - @Test func detectsForegroundCommandInSameProcessGroup() async throws { + @Test func detectsForegroundCommandInSameProcessGroup() throws { let selfPID = getpid() - // Spawn `sleep 3` — macOS Process() may place it in its own process group. let process = Process() process.executableURL = URL(filePath: "/bin/sleep") - process.arguments = ["3"] + process.arguments = ["60"] try process.run() let sleepPID = process.processIdentifier - // Find the child's actual process group (may differ from our pgid on macOS). + // macOS Process() may place the child in its own process group via posix_spawn. let childPgid = getpgid(sleepPID) #expect(childPgid > 0) - // While sleep is running, its process group should contain at least one - // live process (the sleep process itself). let pidsWhileRunning = ProcessDetection.processGroupPIDs(childPgid) - let hasLiveProcessWhileRunning = pidsWhileRunning.contains { + let hasLiveProcess = pidsWhileRunning.contains { $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil } #expect( - hasLiveProcessWhileRunning, + hasLiveProcess, "Expected to detect live process in pgid \(childPgid) but found: \(pidsWhileRunning)" ) - // Wait for sleep to exit. + process.terminate() process.waitUntilExit() - // After sleep exits, the process group should have no other live processes. - try await Task.sleep(for: .milliseconds(100)) - let pidsAfterExit = ProcessDetection.processGroupPIDs(childPgid) let hasLiveProcessAfterExit = pidsAfterExit.contains { $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil } #expect( !hasLiveProcessAfterExit, - "Expected no live process in pgid \(childPgid) after sleep exited but found: \(pidsAfterExit)" + "Expected no live process in pgid \(childPgid) after exit but found: \(pidsAfterExit)" ) } } -- 2.51.2 From 1dfcca9e3be3418b19d873b8492716b89c111e40 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:38:49 +0900 Subject: [PATCH 512/528] Invert login-shell fallback logic for robust git detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of maintaining an allowlist of errors that should trigger the login-shell fallback (exit 127, "command not found", Xcode license, …), invert the check: fallback for ALL shell errors EXCEPT the one case where it's pointless — git ran successfully but confirmed this isn't a git repository. This handles Xcode shim failures (unaccepted license, broken developer path) and any future unknown environment errors without needing to track Apple's error messages. The worst case for an unnecessary fallback is one extra shell invocation that also fails. Closes #486 --- .../Clients/Git/GitClientShellHelpers.swift | 10 +-- .../GitClientShellFallbackTests.swift | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 supacodeTests/GitClientShellFallbackTests.swift diff --git a/supacode/Clients/Git/GitClientShellHelpers.swift b/supacode/Clients/Git/GitClientShellHelpers.swift index 8b7ae55e..5bd77939 100644 --- a/supacode/Clients/Git/GitClientShellHelpers.swift +++ b/supacode/Clients/Git/GitClientShellHelpers.swift @@ -7,11 +7,13 @@ nonisolated func shouldFallbackToLoginShell(_ error: Error) -> Bool { guard let shellError = error as? ShellClientError else { return false } - if shellError.exitCode == 127 { - return true - } let output = "\(shellError.stderr)\n\(shellError.stdout)".lowercased() - return output.contains("command not found") + // When git itself ran fine but confirmed this isn't a repo, retrying + // under a login shell won't change the answer. + if output.contains("not a git repository") { + return false + } + return true } nonisolated func wrapShellError( diff --git a/supacodeTests/GitClientShellFallbackTests.swift b/supacodeTests/GitClientShellFallbackTests.swift new file mode 100644 index 00000000..3e838e35 --- /dev/null +++ b/supacodeTests/GitClientShellFallbackTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +@testable import supacode + +struct GitClientShellFallbackTests { + private func shellError( + stderr: String = "", + stdout: String = "", + exitCode: Int32 = 1 + ) -> ShellClientError { + ShellClientError(command: "wt root", stdout: stdout, stderr: stderr, exitCode: exitCode) + } + + // MARK: - Should fallback + + @Test func fallsBackWhenExecutableNotFound() { + #expect(shouldFallbackToLoginShell(shellError(exitCode: 127))) + } + + @Test func fallsBackOnCommandNotFoundMessage() { + #expect(shouldFallbackToLoginShell(shellError(stderr: "env: git: command not found"))) + } + + @Test func fallsBackWhenXcodeLicenseUnaccepted() { + let stderr = """ + You have not agreed to the Xcode license agreements. Please run 'sudo xcodebuild -license' \ + from within a Terminal window to review and agree to the Xcode and Apple SDKs license. + """ + #expect(shouldFallbackToLoginShell(shellError(stderr: stderr, exitCode: 69))) + } + + @Test func fallsBackOnInvalidActiveDeveloperPath() { + let stderr = "xcode-select: error: invalid active developer path (/Library/Developer/CommandLineTools)" + #expect(shouldFallbackToLoginShell(shellError(stderr: stderr, exitCode: 1))) + } + + @Test func fallsBackOnUnknownShellError() { + #expect(shouldFallbackToLoginShell(shellError(stderr: "something unexpected", exitCode: 42))) + } + + @Test func fallsBackOnEmptyErrorOutput() { + #expect(shouldFallbackToLoginShell(shellError(exitCode: 1))) + } + + // MARK: - Should NOT fallback + + @Test func doesNotFallBackForGenuineNonGitDirectory() { + #expect( + shouldFallbackToLoginShell( + shellError(stderr: "fatal: not a git repository (or any of the parent directories)", exitCode: 128) + ) == false + ) + } + + @Test func doesNotFallBackWhenWtReportsNotGitRepo() { + #expect( + shouldFallbackToLoginShell( + shellError(stderr: "wt: not a git repository", exitCode: 1) + ) == false + ) + } + + @Test func doesNotFallBackForNonShellError() { + struct OtherError: Error {} + #expect(shouldFallbackToLoginShell(OtherError()) == false) + } +} -- 2.51.2 From bb871a6ef9f742f5aa05beaf1d9f10179eb3f9a0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:48:55 +0900 Subject: [PATCH 513/528] Update worktree discovery tests for inverted fallback logic Replace the "no fallback for regular failures" test with two tests that match the new behavior: non-git directories skip the fallback (pointless to retry), while environment errors (e.g. permission denied) do trigger it and recover via the login shell. --- .../GitClientWorktreeDiscoveryTests.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/supacodeTests/GitClientWorktreeDiscoveryTests.swift b/supacodeTests/GitClientWorktreeDiscoveryTests.swift index a7202de5..dffea2bc 100644 --- a/supacodeTests/GitClientWorktreeDiscoveryTests.swift +++ b/supacodeTests/GitClientWorktreeDiscoveryTests.swift @@ -209,7 +209,7 @@ struct GitClientWorktreeDiscoveryTests { } } - @Test func worktreesDoNotFallbackToLoginShellForRegularFailures() async { + @Test func worktreesDoNotFallbackToLoginShellForNonGitDirectory() async { let recorder = GitWorktreeDiscoveryRecorder() let shell = ShellClient( run: { executableURL, arguments, currentDirectoryURL in @@ -221,8 +221,8 @@ struct GitClientWorktreeDiscoveryTests { throw ShellClientError( command: "wt ls --json", stdout: "", - stderr: "permission denied", - exitCode: 1 + stderr: "fatal: not a git repository (or any of the parent directories): .git", + exitCode: 128 ) }, runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in @@ -231,17 +231,57 @@ struct GitClientWorktreeDiscoveryTests { arguments: arguments, currentDirectoryURL: currentDirectoryURL ) - Issue.record("worktrees should not fallback to runLogin for regular command failures") + Issue.record("worktrees should not fallback to runLogin for non-git directories") return ShellOutput(stdout: "", stderr: "", exitCode: 0) } ) let client = GitClient(shell: shell) await #expect(throws: GitClientError.self) { - _ = try await client.worktrees(for: URL(fileURLWithPath: "/tmp/repo")) + _ = try await client.worktrees(for: URL(fileURLWithPath: "/tmp/not-a-repo")) } #expect(recorder.runInvocations().count == 1) #expect(recorder.loginInvocations().isEmpty) } + + @Test func worktreesFallbackToLoginShellForEnvironmentErrors() async throws { + let recorder = GitWorktreeDiscoveryRecorder() + let shell = ShellClient( + run: { executableURL, arguments, currentDirectoryURL in + recorder.recordRun( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL + ) + throw ShellClientError( + command: "wt ls --json", + stdout: "", + stderr: "permission denied", + exitCode: 1 + ) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + recorder.recordLogin( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL + ) + return ShellOutput( + stdout: """ + [{"branch":"main","path":"/tmp/repo","head":"abc","is_bare":false}] + """, + stderr: "", + exitCode: 0 + ) + } + ) + let client = GitClient(shell: shell) + + let worktrees = try await client.worktrees(for: URL(fileURLWithPath: "/tmp/repo")) + + #expect(worktrees.count == 1) + #expect(recorder.runInvocations().count == 1) + #expect(recorder.loginInvocations().count == 1) + } } -- 2.51.2 From f27738485e983e58bd51321a7474ef378c85cb6f Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 00:55:55 +0900 Subject: [PATCH 514/528] Invert search direction to match standard Find Next/Previous semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty indexes matches from newest (0) to oldest, so its navigate_search:next goes upward (toward older content). This is the opposite of what users expect from Cmd+G / Find Next in other apps. - Swap GhosttySearchDirection.bindingAction so .next maps to Ghostty's "previous" (forward in document) and vice versa - Invert display index: show total-selected instead of selected+1 - Reorder buttons to [Previous ↑] [Next ↓] - Auto-select the newest match when search results first appear - Swap menu shortcut lookups to match the inverted direction - Update GhosttySearchNavigator wrap conditions and tests --- supacode/Commands/TerminalCommands.swift | 4 ++-- .../Views/GhosttySurfaceSearchOverlay.swift | 18 +++++++++++------- .../Ghostty/GhosttySearchNavigation.swift | 14 ++++++++------ .../GhosttySearchNavigatorTests.swift | 16 ++++++++-------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index d87b2fb5..8f994120 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -77,7 +77,7 @@ struct TerminalCommands: Commands { navigateSearchNextAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next")) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous")) ) .disabled(navigateSearchNextAction == nil) @@ -85,7 +85,7 @@ struct TerminalCommands: Commands { navigateSearchPreviousAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous")) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next")) ) .disabled(navigateSearchPreviousAction == nil) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index a08eaa6a..37c2daa8 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -47,10 +47,10 @@ struct GhosttySurfaceSearchOverlay: View { } Button { - navigateSearch(.next) + navigateSearch(.previous) } label: { SearchButtonLabel( - title: "Next", + title: "Previous", shortcut: ghosttyShortcuts.display(for: "search:next"), systemImage: "chevron.up" ) @@ -58,10 +58,10 @@ struct GhosttySurfaceSearchOverlay: View { .buttonStyle(GhosttySearchButtonStyle()) Button { - navigateSearch(.previous) + navigateSearch(.next) } label: { SearchButtonLabel( - title: "Previous", + title: "Next", shortcut: ghosttyShortcuts.display(for: "search:previous"), systemImage: "chevron.down" ) @@ -127,6 +127,11 @@ struct GhosttySurfaceSearchOverlay: View { searchText = newValue } } + .onChange(of: state.searchTotal) { _, newValue in + if let total = newValue, total > 0, state.searchSelected == nil { + surfaceView.performBindingAction("navigate_search:next") + } + } .onChange(of: state.searchFocusCount) { _, _ in focusSearchField() } @@ -139,9 +144,8 @@ struct GhosttySurfaceSearchOverlay: View { @ViewBuilder private var matchLabel: some View { - if let selected = state.searchSelected { - let totalLabel = state.searchTotal.map(String.init) ?? "?" - Text("\(selected + 1)/\(totalLabel)") + if let selected = state.searchSelected, let total = state.searchTotal { + Text("\(total - selected)/\(total)") .font(.caption) .foregroundStyle(.secondary) .padding(.trailing, 8) diff --git a/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift b/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift index e6d8f7ed..255383fb 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySearchNavigation.swift @@ -2,21 +2,23 @@ enum GhosttySearchDirection { case next case previous + // Ghostty indexes from newest (0) to oldest; its "next" goes toward older + // content. We invert so .next = forward in document = toward newer. var bindingAction: String { switch self { case .next: - return "navigate_search:next" - case .previous: return "navigate_search:previous" + case .previous: + return "navigate_search:next" } } var oppositeBindingAction: String { switch self { case .next: - return "navigate_search:previous" - case .previous: return "navigate_search:next" + case .previous: + return "navigate_search:previous" } } } @@ -33,9 +35,9 @@ enum GhosttySearchNavigator { } switch direction { - case .next where selected == total - 1: + case .next where selected == 0: return Array(repeating: direction.oppositeBindingAction, count: total - 1) - case .previous where selected == 0: + case .previous where selected == total - 1: return Array(repeating: direction.oppositeBindingAction, count: total - 1) default: return [directAction] diff --git a/supacodeTests/GhosttySearchNavigatorTests.swift b/supacodeTests/GhosttySearchNavigatorTests.swift index db15fd2c..a1d5c073 100644 --- a/supacodeTests/GhosttySearchNavigatorTests.swift +++ b/supacodeTests/GhosttySearchNavigatorTests.swift @@ -3,24 +3,24 @@ import Testing @testable import supacode struct GhosttySearchNavigatorTests { - @Test func nextWrapsFromLastToFirst() { + @Test func nextWrapsAtNewestToOldest() { let actions = GhosttySearchNavigator.bindingActions( direction: .next, - selected: 4, + selected: 0, total: 5 ) - #expect(actions == Array(repeating: "navigate_search:previous", count: 4)) + #expect(actions == Array(repeating: "navigate_search:next", count: 4)) } - @Test func previousWrapsFromFirstToLast() { + @Test func previousWrapsAtOldestToNewest() { let actions = GhosttySearchNavigator.bindingActions( direction: .previous, - selected: 0, + selected: 4, total: 5 ) - #expect(actions == Array(repeating: "navigate_search:next", count: 4)) + #expect(actions == Array(repeating: "navigate_search:previous", count: 4)) } @Test func nonBoundaryUsesDirectAction() { @@ -30,7 +30,7 @@ struct GhosttySearchNavigatorTests { total: 5 ) - #expect(actions == ["navigate_search:next"]) + #expect(actions == ["navigate_search:previous"]) } @Test func unknownSelectionUsesDirectAction() { @@ -40,6 +40,6 @@ struct GhosttySearchNavigatorTests { total: 5 ) - #expect(actions == ["navigate_search:next"]) + #expect(actions == ["navigate_search:previous"]) } } -- 2.51.2 From 67cb8542fd4f41430438bccc5cb4be8e885c02b4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 01:10:07 +0900 Subject: [PATCH 515/528] Add tooltips with hotkeys to search navigation buttons --- .../Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 37c2daa8..1c8ae217 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -56,6 +56,7 @@ struct GhosttySurfaceSearchOverlay: View { ) } .buttonStyle(GhosttySearchButtonStyle()) + .help("Find Previous (⇧⌘G)") Button { navigateSearch(.next) @@ -67,6 +68,7 @@ struct GhosttySurfaceSearchOverlay: View { ) } .buttonStyle(GhosttySearchButtonStyle()) + .help("Find Next (⌘G)") Button { closeSearch() @@ -78,6 +80,7 @@ struct GhosttySurfaceSearchOverlay: View { ) } .buttonStyle(GhosttySearchButtonStyle()) + .help("Close Find Bar (Esc)") } .padding(8) .background(.background) -- 2.51.2 From df6cf4ad91421d39c0a7c57e5cebc37d27fed8ed Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 01:28:34 +0900 Subject: [PATCH 516/528] Close search overlay on ESC and add key binding diagnostic log ESC in the search field now calls closeSearch() + requestFocus() instead of just unfocusing, matching standard macOS Find behavior (Safari, VS Code). This eliminates the double-ESC requirement and prevents ESC from leaking to the terminal process. Also restores focus to terminal when the close button is clicked, and adds a debug log in performKeyEquivalent to diagnose whether Cmd+F's start_search binding has PERFORMABLE flag (suspected cause of "F" leaking to TUI apps via the keyDown path). --- .../Terminal/Views/GhosttySurfaceSearchOverlay.swift | 3 ++- .../Ghostty/GhosttySurfaceView+Keyboard.swift | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 1c8ae217..b7af128f 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -32,7 +32,7 @@ struct GhosttySurfaceSearchOverlay: View { navigateSearch(isShifted ? .previous : .next) }, onEscape: { - isSearchFieldFocused = false + closeSearch() surfaceView.requestFocus() } ) @@ -72,6 +72,7 @@ struct GhosttySurfaceSearchOverlay: View { Button { closeSearch() + surfaceView.requestFocus() } label: { SearchButtonLabel( title: "Close", diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift index a457e420..00b68a30 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift @@ -112,6 +112,15 @@ extension GhosttySurfaceView { } if let bindingFlags = bindingFlags(for: event, surface: surface) { + let raw = bindingFlags.rawValue + let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 + let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 + let chars = event.charactersIgnoringModifiers ?? "" + SupaLogger("KeyEquiv").debug( + "binding flags=0x\(String(raw, radix: 16)) performable=\(isPerformable)" + + " consumed=\(isConsumed) key=\(chars) mods=\(event.modifierFlags.rawValue)" + ) + if shouldAttemptMenu(for: bindingFlags), let menu = NSApp.mainMenu, Self.mainMenuHasMatchingItem(for: event, in: menu), -- 2.51.2 From fdb472f67c32b677a9c95e8eeb8a9227a26c0d90 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 09:06:38 +0900 Subject: [PATCH 517/528] Route performable bindings through menu to prevent key leaking to PTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty's start_search binding has both PERFORMABLE and CONSUMED flags. shouldAttemptMenu previously rejected performable bindings, forcing them through keyDown which sends the key event to the PTY — causing characters like "F" from Cmd+F to leak into TUI apps. Remove the !isPerformable guard so consumed bindings always try the menu path first. The menu consumes the key event and triggers the action via performBindingAction, which never touches the PTY. Bindings without a matching menu item still fall through to keyDown. --- .../Ghostty/GhosttySurfaceView+Keyboard.swift | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift index 00b68a30..5503c1ec 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift @@ -112,15 +112,6 @@ extension GhosttySurfaceView { } if let bindingFlags = bindingFlags(for: event, surface: surface) { - let raw = bindingFlags.rawValue - let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 - let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 - let chars = event.charactersIgnoringModifiers ?? "" - SupaLogger("KeyEquiv").debug( - "binding flags=0x\(String(raw, radix: 16)) performable=\(isPerformable)" - + " consumed=\(isConsumed) key=\(chars) mods=\(event.modifierFlags.rawValue)" - ) - if shouldAttemptMenu(for: bindingFlags), let menu = NSApp.mainMenu, Self.mainMenuHasMatchingItem(for: event, in: menu), @@ -438,9 +429,8 @@ extension GhosttySurfaceView { if bridge.state.keyTableDepth > 0 { return false } let raw = flags.rawValue let isAll = (raw & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 - let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 - return !isAll && !isPerformable && isConsumed + return !isAll && isConsumed } @IBAction func copy(_ sender: Any?) { -- 2.51.2 From ccda10b4c5a2d334d716a292023d63e76979992d Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 09:15:37 +0900 Subject: [PATCH 518/528] Add fallback macOS shortcuts for search menu items ghostty_config_trigger cannot look up performable keybindings, so search menu items had no keyboard shortcuts. This prevented the menu path from intercepting Cmd+F, causing the key to leak to the PTY via keyDown. Add standard macOS fallbacks (Cmd+F, Cmd+G, Cmd+Shift+G, Esc, Cmd+E) that activate when Ghostty config lookup returns nil. Combined with the shouldAttemptMenu change, performable bindings now route through the menu system and the key is consumed before reaching the PTY. --- supacode/Commands/TerminalCommands.swift | 25 +++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index 8f994120..c9375231 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -69,7 +69,10 @@ struct TerminalCommands: Commands { startSearchAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "start_search")) + KeyboardShortcutModifier( + shortcut: ghosttyShortcuts.keyboardShortcut(for: "start_search") + ?? KeyboardShortcut("f", modifiers: .command) + ) ) .disabled(startSearchAction == nil) @@ -77,7 +80,10 @@ struct TerminalCommands: Commands { navigateSearchNextAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous")) + KeyboardShortcutModifier( + shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous") + ?? KeyboardShortcut("g", modifiers: .command) + ) ) .disabled(navigateSearchNextAction == nil) @@ -85,7 +91,10 @@ struct TerminalCommands: Commands { navigateSearchPreviousAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next")) + KeyboardShortcutModifier( + shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next") + ?? KeyboardShortcut("g", modifiers: [.command, .shift]) + ) ) .disabled(navigateSearchPreviousAction == nil) @@ -95,7 +104,10 @@ struct TerminalCommands: Commands { endSearchAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search")) + KeyboardShortcutModifier( + shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search") + ?? KeyboardShortcut(.escape, modifiers: []) + ) ) .disabled(endSearchAction == nil) @@ -105,7 +117,10 @@ struct TerminalCommands: Commands { searchSelectionAction?() } .modifier( - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection")) + KeyboardShortcutModifier( + shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection") + ?? KeyboardShortcut("e", modifiers: .command) + ) ) .disabled(searchSelectionAction == nil) } -- 2.51.2 From 905c86d37e17030ecfd2a010de40bcd4b867814d Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 09:35:37 +0900 Subject: [PATCH 519/528] Register search shortcuts as configurable app keybindings Move Find, Find Next, Find Previous from Ghostty binding lookups to the app's own keybinding system (AppShortcuts + ResolvedKeybindingMap). This makes them configurable in Settings > Shortcuts, provides proper menu keyboard shortcuts regardless of ghostty_config_trigger limitations, and prevents key leaking to the PTY via the menu consumption path. - Add startSearch, findNext, findPrevious command IDs with Cmd+F, Cmd+G, Cmd+Shift+G defaults to AppShortcuts - Switch TerminalCommands to use resolvedKeybindings for search items - Update search overlay tooltips to reflect configured shortcuts - Remove hardcoded fallback shortcuts (now handled by AppShortcuts defaults) --- supacode/App/AppShortcuts.swift | 24 +++++++++++++++++++ supacode/App/supacodeApp.swift | 5 +++- supacode/Commands/TerminalCommands.swift | 20 +++++----------- .../Views/GhosttySurfaceSearchOverlay.swift | 18 ++++++++++---- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 8dd149fd..2d799b1a 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -152,6 +152,9 @@ enum AppShortcuts { static let selectTerminalPaneLeft = "select_terminal_pane_left" static let selectTerminalPaneRight = "select_terminal_pane_right" static let toggleSplitZoom = "toggle_split_zoom" + static let startSearch = "start_search" + static let findNext = "find_next" + static let findPrevious = "find_previous" } enum Scope: String { @@ -200,6 +203,9 @@ enum AppShortcuts { static let stopRunScript = AppShortcut(key: ".", modifiers: .command) static let checkForUpdates = AppShortcut(key: "u", modifiers: [.command, .shift]) static let showDiff = AppShortcut(key: "y", modifiers: [.command, .shift]) + static let startSearch = AppShortcut(key: "f", modifiers: .command) + static let findNext = AppShortcut(key: "g", modifiers: .command) + static let findPrevious = AppShortcut(key: "g", modifiers: [.command, .shift]) static let toggleCanvas = AppShortcut( keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .option] ) @@ -786,6 +792,24 @@ enum AppShortcuts { scope: .localInteraction, shortcut: expandCanvasCard ), + .init( + id: CommandID.startSearch, + title: "Find", + scope: .configurableAppAction, + shortcut: startSearch + ), + .init( + id: CommandID.findNext, + title: "Find Next", + scope: .configurableAppAction, + shortcut: findNext + ), + .init( + id: CommandID.findPrevious, + title: "Find Previous", + scope: .configurableAppAction, + shortcut: findPrevious + ), ] static func userOverrideConflicts( diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index e86ad408..1c37663b 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -803,7 +803,10 @@ struct SupacodeApp: App { Group { WorktreeCommands(store: store) SidebarCommands(store: store) - TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) + TerminalCommands( + ghosttyShortcuts: ghosttyShortcuts, + resolvedKeybindings: store.resolvedKeybindings + ) WindowCommands( store: store, terminalManager: terminalManager, diff --git a/supacode/Commands/TerminalCommands.swift b/supacode/Commands/TerminalCommands.swift index c9375231..f3669c2f 100644 --- a/supacode/Commands/TerminalCommands.swift +++ b/supacode/Commands/TerminalCommands.swift @@ -2,6 +2,7 @@ import SwiftUI struct TerminalCommands: Commands { let ghosttyShortcuts: GhosttyShortcutManager + let resolvedKeybindings: ResolvedKeybindingMap @FocusedValue(\.newTerminalAction) private var newTerminalAction @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.closeTabAction) private var closeTabAction @@ -70,8 +71,7 @@ struct TerminalCommands: Commands { } .modifier( KeyboardShortcutModifier( - shortcut: ghosttyShortcuts.keyboardShortcut(for: "start_search") - ?? KeyboardShortcut("f", modifiers: .command) + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.startSearch) ) ) .disabled(startSearchAction == nil) @@ -81,8 +81,7 @@ struct TerminalCommands: Commands { } .modifier( KeyboardShortcutModifier( - shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:previous") - ?? KeyboardShortcut("g", modifiers: .command) + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.findNext) ) ) .disabled(navigateSearchNextAction == nil) @@ -92,8 +91,7 @@ struct TerminalCommands: Commands { } .modifier( KeyboardShortcutModifier( - shortcut: ghosttyShortcuts.keyboardShortcut(for: "navigate_search:next") - ?? KeyboardShortcut("g", modifiers: [.command, .shift]) + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.findPrevious) ) ) .disabled(navigateSearchPreviousAction == nil) @@ -104,10 +102,7 @@ struct TerminalCommands: Commands { endSearchAction?() } .modifier( - KeyboardShortcutModifier( - shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search") - ?? KeyboardShortcut(.escape, modifiers: []) - ) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search")) ) .disabled(endSearchAction == nil) @@ -117,10 +112,7 @@ struct TerminalCommands: Commands { searchSelectionAction?() } .modifier( - KeyboardShortcutModifier( - shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection") - ?? KeyboardShortcut("e", modifiers: .command) - ) + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection")) ) .disabled(searchSelectionAction == nil) } diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index b7af128f..95668f8a 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -5,6 +5,7 @@ struct GhosttySurfaceSearchOverlay: View { let surfaceView: GhosttySurfaceView @Bindable var state: GhosttySurfaceState @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts + @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var searchText: String @State private var corner: GhosttySearchCorner = .topRight @@ -51,24 +52,24 @@ struct GhosttySurfaceSearchOverlay: View { } label: { SearchButtonLabel( title: "Previous", - shortcut: ghosttyShortcuts.display(for: "search:next"), + shortcut: resolvedKeybindings.display(for: AppShortcuts.CommandID.findPrevious), systemImage: "chevron.up" ) } .buttonStyle(GhosttySearchButtonStyle()) - .help("Find Previous (⇧⌘G)") + .help(searchButtonHelp("Find Previous", commandID: AppShortcuts.CommandID.findPrevious)) Button { navigateSearch(.next) } label: { SearchButtonLabel( title: "Next", - shortcut: ghosttyShortcuts.display(for: "search:previous"), + shortcut: resolvedKeybindings.display(for: AppShortcuts.CommandID.findNext), systemImage: "chevron.down" ) } .buttonStyle(GhosttySearchButtonStyle()) - .help("Find Next (⌘G)") + .help(searchButtonHelp("Find Next", commandID: AppShortcuts.CommandID.findNext)) Button { closeSearch() @@ -76,7 +77,7 @@ struct GhosttySurfaceSearchOverlay: View { } label: { SearchButtonLabel( title: "Close", - shortcut: ghosttyShortcuts.display(for: "end_search"), + shortcut: nil, systemImage: "xmark" ) } @@ -200,6 +201,13 @@ struct GhosttySurfaceSearchOverlay: View { emitSearch(searchText) } + private func searchButtonHelp(_ title: String, commandID: String) -> String { + if let shortcut = resolvedKeybindings.display(for: commandID) { + return "\(title) (\(shortcut))" + } + return title + } + private func focusSearchField() { isSearchFieldFocused = false Task { @MainActor in -- 2.51.2 From 1f3408e00167dea0347413546df60ee33f811a69 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 23 Jun 2026 13:00:45 +0800 Subject: [PATCH 520/528] fix: auxiliary windows invisible in fullscreen mode Add collectionBehavior = [.moveToActiveSpace] to Diff, Settings, and Debug window managers so these windows appear on the current Space when the main app window is in fullscreen. Previously these windows were created but remained on a background Space because macOS fullscreen creates an exclusive Space that new windows don't automatically join. Co-authored-by: Qwen-Coder Signed-off-by: Alex --- supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift | 1 + supacode/Features/DiffView/DiffWindowManager.swift | 1 + .../Features/Settings/BusinessLogic/SettingsWindowManager.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift b/supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift index 0a242e32..1a90942f 100644 --- a/supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift +++ b/supacode/Features/Debug/BusinessLogic/DebugWindowManager.swift @@ -38,6 +38,7 @@ import SwiftUI new.identifier = NSUserInterfaceItemIdentifier("debug") new.styleMask = [.titled, .closable, .miniaturizable, .resizable] new.tabbingMode = .disallowed + new.collectionBehavior = [.moveToActiveSpace] new.toolbarStyle = .unified new.toolbar = NSToolbar(identifier: "DebugToolbar") if #unavailable(macOS 15.0) { diff --git a/supacode/Features/DiffView/DiffWindowManager.swift b/supacode/Features/DiffView/DiffWindowManager.swift index 6676ab91..24efa385 100644 --- a/supacode/Features/DiffView/DiffWindowManager.swift +++ b/supacode/Features/DiffView/DiffWindowManager.swift @@ -43,6 +43,7 @@ final class DiffWindowManager { newWindow.identifier = NSUserInterfaceItemIdentifier("diff") newWindow.styleMask = [.titled, .closable, .miniaturizable, .resizable] newWindow.tabbingMode = .disallowed + newWindow.collectionBehavior = [.moveToActiveSpace] newWindow.toolbarStyle = .unified newWindow.toolbar = NSToolbar(identifier: "DiffToolbar") newWindow.isReleasedWhenClosed = false diff --git a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift index a0c2c6c9..95b2b16b 100644 --- a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift +++ b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift @@ -52,6 +52,7 @@ final class SettingsWindowManager { window.identifier = NSUserInterfaceItemIdentifier(WindowID.settings) window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] window.tabbingMode = .disallowed + window.collectionBehavior = [.moveToActiveSpace] window.titlebarAppearsTransparent = true window.toolbarStyle = .unified window.toolbar = NSToolbar(identifier: "SettingsToolbar") -- 2.51.2 From e24bea62060b401ba31b2506fa0c56dcabbcc4a0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 21:49:55 +0900 Subject: [PATCH 521/528] fix: worktree delete/archive shortcuts unresponsive when terminal has focus Use focusedSceneValue instead of focusedValue so the menu bar commands can read the actions regardless of which view holds keyboard focus. --- .../Repositories/Views/ArchivedWorktreesDetailView.swift | 2 +- supacode/Features/Repositories/Views/SidebarView.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index 8d4eec62..59f7c475 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -38,7 +38,7 @@ struct ArchivedWorktreesDetailView: View { selectedArchivedWorktreeIDs = selectedArchivedWorktreeIDs.intersection(newValue) } .animation(.easeOut(duration: 0.2), value: snapshot.archivedRowIDs) - .focusedValue(\.deleteWorktreeAction, snapshot.deleteWorktreeAction) + .focusedSceneValue(\.deleteWorktreeAction, snapshot.deleteWorktreeAction) .focusedSceneValue(\.confirmWorktreeAction, snapshot.confirmWorktreeAction) .toolbar { toolbarContent(deleteWorktreeAction: snapshot.deleteWorktreeAction) } } diff --git a/supacode/Features/Repositories/Views/SidebarView.swift b/supacode/Features/Repositories/Views/SidebarView.swift index c2b73fc2..7be01aff 100644 --- a/supacode/Features/Repositories/Views/SidebarView.swift +++ b/supacode/Features/Repositories/Views/SidebarView.swift @@ -30,8 +30,8 @@ struct SidebarView: View { terminalManager: terminalManager ) .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) - .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) - .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) + .focusedSceneValue(\.archiveWorktreeAction, archiveWorktreeAction) + .focusedSceneValue(\.deleteWorktreeAction, deleteWorktreeAction) .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) // `visibleHotkeyWorktreeRows` stays a plain value: `WorktreeRowModel` is // Equatable, so SwiftUI already skips republishing it on no-op body runs. -- 2.51.2 From 347734ff55e859176c704bf73a83c10d63fb1289 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 21:52:30 +0900 Subject: [PATCH 522/528] Sync docs for 2026.6.23 --- docs/.sync-meta.json | 6 +++--- docs/reference/keyboard-shortcuts.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index bd372df3..6401f05a 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "0a49b5e56295ddf866fd6075a57de42a0a9847b5", - "last_synced_date": "2026-06-20", - "note": "Workspace creation/removal/child-rows, Qwen agent detection, worktree running indicator, sidebar full-row click and collapse/expand all, CLI debug identity — all already documented in prior syncs. Baseline bump only." + "last_synced_commit": "e24bea62060b401ba31b2506fa0c56dcabbcc4a0", + "last_synced_date": "2026-06-23", + "note": "Search shortcuts (Find/Find Next/Find Previous) promoted from Ghostty-managed to configurable app keybindings. Other changes (focusedValue fix, fullscreen window fix, git fallback inversion, adaptive line-diff debounce) are internal and don't affect docs." } diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 82fb6091..185bd266 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -87,6 +87,9 @@ pane has focus. The Ghostty action each maps to is shown for reference. | Select Pane Left | ⌘⌥← | `select_terminal_pane_left` | `goto_split:left` | | Select Pane Right | ⌘⌥→ | `select_terminal_pane_right` | `goto_split:right` | | Toggle Split Zoom | ⌘⌥⇧F | `toggle_split_zoom` | `toggle_split_zoom` | +| Find | ⌘F | `start_search` | — | +| Find Next | ⌘G | `find_next` | — | +| Find Previous | ⌘⇧G | `find_previous` | — | ## Terminal engine (Ghostty-managed) @@ -103,8 +106,6 @@ your Ghostty config (`~/.config/ghostty/config`). Typical defaults in parenthese | Reset Font Size | `reset_font_size` | ⌘0 | | Increase Font Size | `increase_font_size:1` | ⌘+ | | Decrease Font Size | `decrease_font_size:1` | ⌘- | -| Find… | `start_search` | ⌘F | -| Find Next / Previous | `search:next` / `search:previous` | ⌘G / ⌘⇧G | | Hide Find Bar | `end_search` | Esc | | Use Selection for Find | `search_selection` | ⌘E | -- 2.51.2 From 186f2863bd1e48880fc673c3ab0b7bb1d47034c5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 21:54:46 +0900 Subject: [PATCH 523/528] bump v2026.6.23 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- supacode/CLIService/Shared/ProwlVersion.swift | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 8a7df7d7..25dacc98 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260620; + CURRENT_PROJECT_VERSION = 20260623; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -495,7 +495,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.20; + MARKETING_VERSION = 2026.6.23; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -530,7 +530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260620; + CURRENT_PROJECT_VERSION = 20260623; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -553,7 +553,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 2026.6.20; + MARKETING_VERSION = 2026.6.23; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -583,12 +583,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260620; + CURRENT_PROJECT_VERSION = 20260623; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.20; + MARKETING_VERSION = 2026.6.23; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -605,12 +605,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260620; + CURRENT_PROJECT_VERSION = 20260623; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 2026.6.20; + MARKETING_VERSION = 2026.6.23; PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.prowlTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/supacode/CLIService/Shared/ProwlVersion.swift b/supacode/CLIService/Shared/ProwlVersion.swift index 2a1436f7..f63a9987 100644 --- a/supacode/CLIService/Shared/ProwlVersion.swift +++ b/supacode/CLIService/Shared/ProwlVersion.swift @@ -1,5 +1,5 @@ // Auto-generated by Makefile (sync-cli-version). Do not edit. public enum ProwlVersion { - public static let current = "2026.6.20" + public static let current = "2026.6.23" } -- 2.51.2 From c9b07646e2a36016e52b15e1785174fd65ffd52e Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 21:54:47 +0900 Subject: [PATCH 524/528] Update CHANGELOG for 2026.6.23 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f204ff..d6d67d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2026.6.23](https://github.com/onevcat/Prowl/releases/tag/v2026.6.23) + +This release focuses on search usability, terminal activity detection, and window management reliability. + +### Improved + +- The sidebar spinner now activates for any foreground command (such as `npm run build`, `git clone`, or `sleep`), not just agent-driven tasks that emit progress signals. +- The sidebar `+N` diff badge now includes lines from untracked files and uses adaptive timing: small repos update within about 2 seconds, while large repos use a longer debounce to stay responsive. + +### Fixed + +- Worktree delete and archive keyboard shortcuts no longer require clicking away from the terminal first — they now work while the terminal has focus. +- Diff, Settings, and Debug windows are now visible when the main window is in fullscreen mode; they open in the active Space instead of a background one. +- Search field keyboard handling is now reliable: Enter, Shift+Enter, Escape, Cmd+G, and Cmd+Shift+G all work while the field is being edited. Search now auto-selects the newest match on first result, and Find Next (Cmd+G) moves toward newer content to match standard macOS behavior. +- Git repository detection is more robust in non-standard environments such as an unaccepted Xcode license or a broken developer path. + ## [2026.6.20](https://github.com/onevcat/Prowl/releases/tag/v2026.6.20) This release adds project workspace support, bringing multi-repository orchestration to Prowl's core workflow. -- 2.51.2 From 5b2197912d9194010441d9a93aaa8f683398d2af Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 23:01:55 +0900 Subject: [PATCH 525/528] Revert "Merge pull request #484 from Alex-ai-future/fix/state-update-2" This reverts commit 27bc8b209e99ffa3e9fb82e3971977aedff60103, reversing changes made to 31e793bac15a1162e119ee860a02acc854d68604. --- .../WorktreeTerminalState+Notifications.swift | 6 ---- .../WorktreeTerminalState+Surfaces.swift | 20 ----------- .../ProcessDetectionSmokeTests.swift | 36 ------------------- 3 files changed, 62 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift index cd7401b1..d70dfd27 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift @@ -86,12 +86,6 @@ extension WorktreeTerminalState { } } - // Re-evaluate running state: a command finished, so the tab may have - // transitioned from .running back to .idle. - if let tabId = tabId(containing: surfaceId) { - updateRunningState(for: tabId) - } - guard commandFinishedNotificationEnabled else { return } let durationSeconds = Int(durationNs / 1_000_000_000) guard durationSeconds >= commandFinishedNotificationThreshold else { return } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 51590c5d..3d477311 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -374,10 +374,6 @@ extension WorktreeTerminalState { } } self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) - // Title changes when a command starts (shell sets it to the command name) - // and when it ends (precmd resets to prompt). This is our trigger to - // re-evaluate the foreground process state for non-OSC 9;4 commands. - self.updateRunningState(for: tabId) } view.bridge.onSplitAction = { [weak self, weak view] action in guard let self, let view else { return false } @@ -663,15 +659,6 @@ extension WorktreeTerminalState { if surfaceRunningStartedAtById[surface.id] == nil { surfaceRunningStartedAtById[surface.id] = now } - } else if let pgid = surface.bridge.foregroundProcessGroupID(), - hasRunningForegroundProcess(processGroupID: pgid, shellPID: surface.bridge.childPID()) - { - // Fallback: detect active commands that don't emit OSC 9;4 - // (npm run build, git clone, sleep, etc.) - isRunningNow = true - if surfaceRunningStartedAtById[surface.id] == nil { - surfaceRunningStartedAtById[surface.id] = now - } } else { surfaceRunningStartedAtById.removeValue(forKey: surface.id) } @@ -681,13 +668,6 @@ extension WorktreeTerminalState { emitTaskStatusIfChanged() } - // Simple commands share the shell's process group, so pgid comparison alone - // can't distinguish idle from running — enumerate and check for non-shell members. - private func hasRunningForegroundProcess(processGroupID: pid_t, shellPID: pid_t?) -> Bool { - let pids = ProcessDetection.processGroupPIDs(processGroupID) - return pids.contains { $0 > 0 && $0 != shellPID && ProcessDetection.processBSDInfo(pid: $0) != nil } - } - func emitTaskStatusIfChanged() { let newStatus = taskStatus if newStatus != lastReportedTaskStatus { diff --git a/supacodeTests/ProcessDetectionSmokeTests.swift b/supacodeTests/ProcessDetectionSmokeTests.swift index f0c4983f..cf5b8b17 100644 --- a/supacodeTests/ProcessDetectionSmokeTests.swift +++ b/supacodeTests/ProcessDetectionSmokeTests.swift @@ -1,5 +1,4 @@ import Darwin -import Foundation import Testing @testable import supacode @@ -23,39 +22,4 @@ struct ProcessDetectionSmokeTests { #expect(pids.contains(getpid())) } - - @Test func detectsForegroundCommandInSameProcessGroup() throws { - let selfPID = getpid() - - let process = Process() - process.executableURL = URL(filePath: "/bin/sleep") - process.arguments = ["60"] - try process.run() - let sleepPID = process.processIdentifier - - // macOS Process() may place the child in its own process group via posix_spawn. - let childPgid = getpgid(sleepPID) - #expect(childPgid > 0) - - let pidsWhileRunning = ProcessDetection.processGroupPIDs(childPgid) - let hasLiveProcess = pidsWhileRunning.contains { - $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil - } - #expect( - hasLiveProcess, - "Expected to detect live process in pgid \(childPgid) but found: \(pidsWhileRunning)" - ) - - process.terminate() - process.waitUntilExit() - - let pidsAfterExit = ProcessDetection.processGroupPIDs(childPgid) - let hasLiveProcessAfterExit = pidsAfterExit.contains { - $0 > 0 && $0 != selfPID && ProcessDetection.processBSDInfo(pid: $0) != nil - } - #expect( - !hasLiveProcessAfterExit, - "Expected no live process in pgid \(childPgid) after exit but found: \(pidsAfterExit)" - ) - } } -- 2.51.2 From cd5f37833b222d8c7809e37c0acf50951c5a3f89 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 23:24:19 +0900 Subject: [PATCH 526/528] Sync docs for 2026.6.23 after revert --- docs/.sync-meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index 6401f05a..da7a8fdb 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "e24bea62060b401ba31b2506fa0c56dcabbcc4a0", + "last_synced_commit": "5b2197912d9194010441d9a93aaa8f683398d2af", "last_synced_date": "2026-06-23", - "note": "Search shortcuts (Find/Find Next/Find Previous) promoted from Ghostty-managed to configurable app keybindings. Other changes (focusedValue fix, fullscreen window fix, git fallback inversion, adaptive line-diff debounce) are internal and don't affect docs." + "note": "Release prep after reverting terminal running-state fallback. Version bump and reverted implementation do not require user-facing doc changes." } -- 2.51.2 From 829a1d3c6a7b7f21ce9856fe0c26ba38e4340333 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 23:27:41 +0900 Subject: [PATCH 527/528] bump v2026.6.23 --- supacode.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 25dacc98..009cb877 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 20260623; + CURRENT_PROJECT_VERSION = 20260624; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -530,7 +530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 20260623; + CURRENT_PROJECT_VERSION = 20260624; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -583,7 +583,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260623; + CURRENT_PROJECT_VERSION = 20260624; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -605,7 +605,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20260623; + CURRENT_PROJECT_VERSION = 20260624; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; -- 2.51.2 From ee1183f2405489003b8eb9a738db9870c6fca801 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 23 Jun 2026 23:57:31 +0900 Subject: [PATCH 528/528] Pin Node version for release tooling --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index f0bf36a0..77c65ed0 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] +node = "22.21.1" zig = "0.15.2" swiftlint = "latest" "github:ldomaradzki/xcsift" = "latest"