From da9102160d246510502c3b12c5c4a3d8d016c15f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sun, 31 May 2026 23:08:45 +0800 Subject: [PATCH] feat: add new project banner --- Packages/Sources/RxCodeCore/Hooks/Hook.swift | 2 + .../RxCodeCore/Hooks/HookController.swift | 84 ++++++++- .../RxCodeCore/Hooks/HookPayloads.swift | 11 ++ .../RxCodeCore/Secrets/SecretsModels.swift | 27 +++ RxCode/App/AppState+Hooks.swift | 15 ++ RxCode/App/AppState+Stream.swift | 22 ++- RxCode/App/AppState+Worktree.swift | 4 + RxCode/App/AppState.swift | 8 +- RxCode/App/RxCodeApp.swift | 17 +- RxCode/Resources/Localizable.xcstrings | 12 ++ .../Hooks/AppStateHookController.swift | 82 +++++++- RxCode/Services/Hooks/HookManager.swift | 8 + .../Services/Hooks/hooks/AutopilotHook.swift | 176 ++++++++++++++++++ .../Hooks/hooks/SecretsAutoDownloadHook.swift | 65 ------- RxCode/Services/Secrets/SecretsService.swift | 8 + RxCode/Views/Hooks/HookBannerHost.swift | 42 +++++ RxCode/Views/Hooks/SecretsEnvBanner.swift | 96 ++++++++++ RxCode/Views/MainView.swift | 35 +++- RxCode/Views/ProjectWindowView.swift | 19 +- RxCode/Views/Secrets/SecretsDeepLink.swift | 50 +++++ .../SecretsEnvironmentDetailView.swift | 15 +- RxCode/Views/Secrets/SecretsManageSheet.swift | 22 ++- .../Views/Secrets/SecretsRepoDetailView.swift | 9 + 23 files changed, 740 insertions(+), 89 deletions(-) create mode 100644 RxCode/Services/Hooks/hooks/AutopilotHook.swift delete mode 100644 RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift create mode 100644 RxCode/Views/Hooks/HookBannerHost.swift create mode 100644 RxCode/Views/Hooks/SecretsEnvBanner.swift create mode 100644 RxCode/Views/Secrets/SecretsDeepLink.swift diff --git a/Packages/Sources/RxCodeCore/Hooks/Hook.swift b/Packages/Sources/RxCodeCore/Hooks/Hook.swift index be64f7f0..3cf17887 100644 --- a/Packages/Sources/RxCodeCore/Hooks/Hook.swift +++ b/Packages/Sources/RxCodeCore/Hooks/Hook.swift @@ -17,6 +17,7 @@ public protocol Hook: AnyObject { var isEnabled: Bool { get } func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome @@ -37,6 +38,7 @@ public extension Hook { var isEnabled: Bool { true } func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored } + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { .ignored } func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored } func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored } func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored } diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 30cdcae6..96751c8c 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -70,11 +70,44 @@ public protocol HookController: AnyObject { /// carries dynamic, non-localizable text (e.g. a list of filenames). func requestConfirmation(title: LocalizedStringKey, detail: String?) async -> Bool + // MARK: Banners + + /// Show (or replace) a hook-supplied banner in a screen surface. The hook + /// owns the entire view — including any button and its action — so banners + /// are not limited to a fixed shape. `id` is a stable key: showing again + /// with the same `(surface, id)` replaces the existing banner. Prefer the + /// `@ViewBuilder` convenience below over calling this directly. + /// + /// `projectId` scopes the banner to one project: the host only renders it + /// while that project is open, so switching projects hides it automatically. + /// Pass `nil` for a banner that should show regardless of the open project. + func showBanner(_ content: AnyView, id: String, projectId: UUID?, in surface: HookBannerSurface, position: HookBannerPosition) + /// Remove a previously-shown banner. No-op if it isn't currently shown. + func dismissBanner(id: String, in surface: HookBannerSurface) + + /// Whether the user previously dismissed the banner with this `id`. The flag + /// is persisted across launches, so a hook should check this before showing a + /// banner the user has already closed. + func isBannerDismissed(id: String) -> Bool + /// Record that the user dismissed this banner (persisted across launches) and + /// remove it from the UI now. Wired to the banner's close button. + func markBannerDismissed(id: String, in surface: HookBannerSurface) + /// Forget a persisted dismissal so the banner can show again — e.g. when the + /// owning project is deleted, so re-adding it surfaces the banner anew. + func clearBannerDismissal(id: String) + // MARK: Secrets - /// Environments configured for a repo in autopilot. Returns `[]` when there - /// are none, the user is signed out, or the request fails. - func secretEnvironments(repoFullName: String) async -> [HookChoice] + /// Environments configured for a repo in autopilot. Returns `nil` when the + /// check can't be completed (signed out, offline, request failed, or the + /// task was cancelled mid-flight) so callers can tell that apart from a + /// genuine empty `[]` and avoid surfacing a misleading banner. + func secretEnvironments(repoFullName: String) async -> [HookChoice]? + + /// Whether a secret file named `filename` already exists in *any* of the + /// repo's autopilot environments. Returns `true` on error / signed-out so + /// callers don't nag the user when the check itself can't run. + func secretFileExists(repoFullName: String, filename: String) async -> Bool /// Download + decrypt an environment's files (may prompt for the passkey). /// Does not write anything to disk. func fetchSecrets(repoFullName: String, env: String) async throws -> [HookSecretFile] @@ -82,3 +115,48 @@ public protocol HookController: AnyObject { /// unless `overwrite`. Returns the filenames actually written. func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String] } + +// MARK: - Banner surfaces + +/// A screen region a hook can attach a banner to. +public enum HookBannerSurface: String, Sendable, Hashable { + /// The new-project / empty-state composer screen. + case newProject +} + +/// Where within a surface the banner renders. +public enum HookBannerPosition: String, Sendable, Hashable { + /// Directly above the chat input box (below the title on the empty state). + case aboveInputBox +} + +/// A live banner: its stable `id`, where it sits, and the hook-built view. +public struct HookBannerItem: Identifiable { + public let id: String + public let position: HookBannerPosition + /// Project this banner belongs to; the host only renders it while that + /// project is open. `nil` shows regardless of the selected project. + public let projectId: UUID? + public let content: AnyView + + public init(id: String, position: HookBannerPosition, projectId: UUID? = nil, content: AnyView) { + self.id = id + self.position = position + self.projectId = projectId + self.content = content + } +} + +public extension HookController { + /// Ergonomic `@ViewBuilder` form mirroring the requested call site: + /// `controller.showBanner(in: .newProject, position: .aboveInputBox, id: …) { MyBanner() }`. + func showBanner( + in surface: HookBannerSurface, + position: HookBannerPosition, + id: String, + projectId: UUID? = nil, + @ViewBuilder content: () -> Content + ) { + showBanner(AnyView(content()), id: id, projectId: projectId, in: surface, position: position) + } +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index 4b78eff6..0ed01592 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -6,6 +6,7 @@ import Foundation /// event to a future out-of-process plugin. public enum HookEventKind: String, Codable, Sendable, CaseIterable { case onProjectNewChatStart + case onProjectDelete case onSessionStart case beforeSessionEnd case afterSessionEnd @@ -100,6 +101,16 @@ public struct RepositoryPayload: Codable, Sendable { } } +/// Fired when a project is removed from the IDE. Carries the full project so a +/// hook can clean up any persisted, project-scoped state (e.g. a dismissed +/// banner keyed by the project's repo) — so re-adding it starts fresh. +public struct ProjectDeletePayload: Codable, Sendable { + public let project: Project + public init(project: Project) { + self.project = project + } +} + public struct QuestionAskPayload: Codable, Sendable { public let toolUseId: String public let sessionId: String? diff --git a/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift b/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift index 457112ae..86c9dd3a 100644 --- a/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift +++ b/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift @@ -166,6 +166,33 @@ public struct SecretsFileList: Codable, Sendable { public let items: [SecretsFileMeta] } +// MARK: - Cross-environment filename search + +/// One environment that contains a file matching the searched filename. +public struct SecretsFileLocation: Codable, Sendable, Identifiable, Hashable { + public let environmentId: String + public let environmentName: String + public let fileId: String + public let size: Int? + public let updatedAt: Double? + + public var id: String { environmentId } +} + +/// Result of searching a repo's environments for a given filename, from +/// `GET /repositories/{id}/files?filename=…`. +public struct SecretsFileSearch: Codable, Sendable, Identifiable { + public let filename: String + public let environments: [SecretsFileLocation] + public let exists: Bool + + public var id: String { filename } +} + +public struct SecretsFileSearchList: Codable, Sendable { + public let items: [SecretsFileSearch] +} + public struct UpsertFileBody: Codable, Sendable { public let filename: String public let ciphertext: String diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index eca133f1..25712d9c 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -29,6 +29,21 @@ extension AppState { } } + // MARK: - New-chat banner hooks + + /// Run the "new chat started" hooks for a project's empty-state / composer + /// screen — e.g. the Autopilot `.env` backup banner. This used to fire only + /// inside the stream preflight (on first message send), so the banner never + /// appeared just from opening the screen. Views call this from `.task(id:)` + /// when a project's chat screen appears. The hook itself decides whether to + /// show or dismiss its banner, so this is safe to call repeatedly. + func runProjectNewChatHooks(projectId: UUID, sessionKey: String) async { + logger.debug("[Hook] runProjectNewChatHooks: projectId=\(projectId.uuidString, privacy: .public) sessionKey=\(sessionKey, privacy: .public)") + await hookManager.dispatchProjectNewChatStart( + NewChatStartPayload(projectId: projectId, sessionKey: sessionKey) + ) + } + // MARK: - Hook execution /// Tool-call name carried by a hook's chat card. The `Hook: ` prefix lets diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index a91945e8..4fc7269c 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -725,29 +725,43 @@ extension AppState { let summary: String let decision: PermissionDecision let nextMode: PermissionMode? + // Whether this decision moves the session out of plan mode. Only an + // *accept* does — the user has approved a plan and is ready to implement. + // A reject (with or without feedback) keeps the user in the planning + // phase: the model will revise and re-emit `ExitPlanMode`, and that next + // plan must launch with `--permission-mode plan` so the CLI actually + // pauses for the plan card instead of auto-resolving it with the + // "Exit plan mode?" placeholder (which leaves the user having to + // manually re-chat to make any decision stick). + let exitsPlanMode: Bool switch action { case .acceptAsk: summary = "Accepted with Ask" decision = .allowAndSetMode(newMode: .default) nextMode = .default + exitsPlanMode = true case .acceptWithEdits: summary = "Accepted with Edits" decision = .allowAndSetMode(newMode: .acceptEdits) nextMode = .acceptEdits + exitsPlanMode = true case .acceptAutoApprove: summary = "Accepted with Auto-approve" decision = .allowAndSetMode(newMode: .auto) nextMode = .auto + exitsPlanMode = true case .rejectWithFeedback(let reason): let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) summary = trimmed.isEmpty ? "Rejected" : "Rejected: \(trimmed)" decision = .denyWithReason(reason: trimmed.isEmpty ? "User rejected the plan." : trimmed) nextMode = nil + exitsPlanMode = false case .reject: summary = "Rejected" decision = .denyWithReason(reason: "User rejected the plan.") nextMode = nil + exitsPlanMode = false } // Record the outcome on the tool block so `PlanCardView` flips from buttons to @@ -768,9 +782,11 @@ extension AppState { } threadStore.setPlanDecision(sessionId: key, toolCallId: toolUseId, summary: summary) - // Plan-mode is one-shot — clear the pill so the next user turn isn't in plan mode. - // This also triggers a permission re-register (no-op if there's no live CLI sid). - if window.sessionPlanMode { + // Clear the plan-mode pill only when the user accepted — that's the + // transition out of planning and into implementation. On a reject we stay + // in plan mode so the revised plan re-enters the real ExitPlanMode pause + // (see `exitsPlanMode` above). The mode re-register below runs regardless. + if exitsPlanMode, window.sessionPlanMode { window.sessionPlanMode = false updateState(key) { $0.planMode = false } } diff --git a/RxCode/App/AppState+Worktree.swift b/RxCode/App/AppState+Worktree.swift index 10830020..71612ef0 100644 --- a/RxCode/App/AppState+Worktree.swift +++ b/RxCode/App/AppState+Worktree.swift @@ -393,6 +393,10 @@ extension AppState { } catch { logger.error("Failed to save projects after deletion: \(error.localizedDescription)") } + + // Let hooks clean up persisted, project-scoped state (e.g. the autopilot + // banner's stored dismissal) so re-adding the project starts fresh. + await hookManager.dispatchProjectDelete(ProjectDeletePayload(project: project)) } func deleteSession(_ session: ChatSession, in window: WindowState) async { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index d276cdd5..ed02fc02 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -912,6 +912,12 @@ final class AppState { var hookChoiceRequest: HookChoiceRequest? /// Non-nil while a hook is awaiting a confirm/cancel decision. var hookConfirmRequest: HookConfirmRequest? + /// Hook-supplied banners, keyed by surface. Each surface renders its items + /// at their requested position (see `HookBannerHost`). + var hookBanners: [HookBannerSurface: [HookBannerItem]] = [:] + /// Non-nil while the secret-setup form should be presented (e.g. opened from + /// the autopilot `.env` banner's deep link). + var secretsSetupRequest: SecretsSetupRequest? // MARK: - Services @@ -1127,7 +1133,7 @@ final class AppState { hookManager.register(CINotificationHook()) hookManager.register(RemoteConfigNotificationHook()) #if os(macOS) - hookManager.register(SecretsAutoDownloadHook()) + hookManager.register(AutopilotHook()) #endif } diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 647972cf..b832251d 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -604,7 +604,11 @@ struct MainWindowRoot: View { .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in - openMarkdownLink(url, in: windowState) + if let request = SecretsDeepLink.parse(url) { + appState.secretsSetupRequest = request + return .handled + } + return openMarkdownLink(url, in: windowState) }) .transition(.opacity) } else { @@ -612,6 +616,11 @@ struct MainWindowRoot: View { .transition(.opacity) } } + .onOpenURL { url in + if let request = SecretsDeepLink.parse(url) { + appState.secretsSetupRequest = request + } + } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) .task { await appState.initialize() @@ -674,7 +683,11 @@ struct ProjectWindowRoot: View { .environment(windowState) .environment(chatBridge) .environment(\.openURL, OpenURLAction { url in - openMarkdownLink(url, in: windowState) + if let request = SecretsDeepLink.parse(url) { + appState.secretsSetupRequest = request + return .handled + } + return openMarkdownLink(url, in: windowState) }) .transition(.opacity) } else { diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 7aa6a103..de47a632 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -184,6 +184,9 @@ } } } + }, + "%@ isn't backed up to Autopilot" : { + }, "%@ not found" : { "extractionState" : "stale", @@ -1654,6 +1657,9 @@ } } } + }, + "Back up this project's secrets to Autopilot" : { + }, "Bash" : { "localizations" : { @@ -3179,6 +3185,9 @@ } } } + }, + "Dismiss" : { + }, "Display name" : { "localizations" : { @@ -8373,6 +8382,9 @@ } } } + }, + "Set up" : { + }, "Set up encryption" : { "localizations" : { diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 6f17ad3b..2e5b3cac 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -144,16 +144,92 @@ final class AppStateHookController: HookController { } } + // MARK: Banners + + func showBanner(_ content: AnyView, id: String, projectId: UUID?, in surface: HookBannerSurface, position: HookBannerPosition) { + guard let app else { + logger.debug("[Hook] showBanner(\(id, privacy: .public)): app is nil — dropped") + return + } + let item = HookBannerItem(id: id, position: position, projectId: projectId, content: content) + var items = app.hookBanners[surface] ?? [] + let isNew = !items.contains { $0.id == id } + if let idx = items.firstIndex(where: { $0.id == id }) { + items[idx] = item + } else { + items.append(item) + } + // Animate only when a banner actually appears, so replacing an existing + // banner's content doesn't re-run the slide-in transition. + if isNew { + withAnimation(.snappy(duration: 0.28)) { app.hookBanners[surface] = items } + } else { + app.hookBanners[surface] = items + } + logger.debug("[Hook] showBanner: id=\(id, privacy: .public) surface=\(surface.rawValue, privacy: .public) position=\(position.rawValue, privacy: .public) new=\(isNew, privacy: .public) — now \(items.count, privacy: .public) banner(s) in surface") + } + + func dismissBanner(id: String, in surface: HookBannerSurface) { + guard let app else { return } + guard var items = app.hookBanners[surface] else { return } + let before = items.count + items.removeAll { $0.id == id } + guard items.count != before else { return } + withAnimation(.snappy(duration: 0.28)) { app.hookBanners[surface] = items } + logger.debug("[Hook] dismissBanner: id=\(id, privacy: .public) surface=\(surface.rawValue, privacy: .public) — \(before, privacy: .public)→\(items.count, privacy: .public) banner(s)") + } + + /// UserDefaults key holding the array of banner ids the user has dismissed. + private static let dismissedBannersKey = "hook.dismissedBanners" + + private var dismissedBannerIDs: Set { + get { Set(UserDefaults.standard.stringArray(forKey: Self.dismissedBannersKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: Self.dismissedBannersKey) } + } + + func isBannerDismissed(id: String) -> Bool { + dismissedBannerIDs.contains(id) + } + + func markBannerDismissed(id: String, in surface: HookBannerSurface) { + var ids = dismissedBannerIDs + ids.insert(id) + dismissedBannerIDs = ids + logger.debug("[Hook] markBannerDismissed: id=\(id, privacy: .public) — persisted (\(ids.count, privacy: .public) total dismissed)") + dismissBanner(id: id, in: surface) + } + + func clearBannerDismissal(id: String) { + var ids = dismissedBannerIDs + guard ids.remove(id) != nil else { return } + dismissedBannerIDs = ids + logger.debug("[Hook] clearBannerDismissal: id=\(id, privacy: .public) — forgotten (\(ids.count, privacy: .public) remain)") + } + // MARK: Secrets - func secretEnvironments(repoFullName: String) async -> [HookChoice] { - guard let app else { return [] } + func secretEnvironments(repoFullName: String) async -> [HookChoice]? { + guard let app else { return nil } do { let envs = try await app.secrets.listEnvironments(repo: repoFullName).items return envs.map { HookChoice(id: $0.id, label: $0.name) } } catch { + // nil (not []) so callers don't mistake a failed/cancelled check for + // a repo that genuinely has no environments and show a stale banner. logger.error("secretEnvironments failed: \(error.localizedDescription)") - return [] + return nil + } + } + + func secretFileExists(repoFullName: String, filename: String) async -> Bool { + guard let app else { return true } + do { + let result = try await app.secrets.searchFiles(repo: repoFullName, filenames: [filename]) + return result.items.first(where: { $0.filename == filename })?.exists ?? false + } catch { + // Don't nag when the check itself fails (offline, signed-out, etc.). + logger.error("secretFileExists failed: \(error.localizedDescription)") + return true } } diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift index a29c299d..0dc54c59 100644 --- a/RxCode/Services/Hooks/HookManager.swift +++ b/RxCode/Services/Hooks/HookManager.swift @@ -27,11 +27,19 @@ final class HookManager { // MARK: - Session lifecycle func dispatchProjectNewChatStart(_ payload: NewChatStartPayload) async { + logger.debug("[Hook] dispatchProjectNewChatStart: projectId=\(payload.projectId.uuidString, privacy: .public) enabledHooks=\(self.enabledHooks.map(\.hookID).joined(separator: ","), privacy: .public)") for hook in enabledHooks { _ = await hook.onProjectNewChatStart(payload, controller: controller) } } + func dispatchProjectDelete(_ payload: ProjectDeletePayload) async { + logger.debug("[Hook] dispatchProjectDelete: projectId=\(payload.project.id.uuidString, privacy: .public)") + for hook in enabledHooks { + _ = await hook.onProjectDelete(payload, controller: controller) + } + } + func dispatchSessionStart(_ payload: SessionStartPayload) async -> HookAggregateResult { var outcomes: [HookOutcome] = [] for hook in enabledHooks { diff --git a/RxCode/Services/Hooks/hooks/AutopilotHook.swift b/RxCode/Services/Hooks/hooks/AutopilotHook.swift new file mode 100644 index 00000000..8016caa4 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/AutopilotHook.swift @@ -0,0 +1,176 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// Integrates the IDE with autopilot's encrypted secrets. Two behaviours: +/// - On repository add (including clones — the clone path now also fires +/// `onRepositoryAdded`), check the autopilot secrets endpoint for the +/// project's GitHub repo. If any environments exist, let the user pick one in +/// a loading dialog, then download + decrypt its files into the project +/// folder. Conflicting local files are only overwritten after confirmation. +/// - On new chat, surface a banner when the project's local `.env*` files +/// aren't backed up to autopilot (see `onProjectNewChatStart`). +@MainActor +final class AutopilotHook: Hook { + let hookID = "builtin.autopilot" + private let logger = Logger(subsystem: "com.claudework", category: "AutopilotHook") + + func onRepositoryAdded(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome { + await run(project: payload.project, controller: controller) + } + + /// Stable, readable banner id for a repo, used both as the banner key and the + /// persisted "dismissed" key, e.g. repo "owner/github-pm" → + /// "github-pm-new-project-autopilot". Returns nil when the project has no repo. + private func bannerID(for project: Project) -> String? { + guard let repo = project.gitHubRepo else { return nil } + let repoSlug = repo.split(separator: "/").last.map(String.init) ?? repo + return "\(repoSlug)-new-project-autopilot" + } + + /// When a project is removed, forget any persisted dismissal of its banner so + /// re-adding the project surfaces the banner again. + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { + guard let bannerID = bannerID(for: payload.project) else { return .ignored } + logger.debug("[Hook] onProjectDelete: clearing dismissal for banner \(bannerID, privacy: .public)") + controller.clearBannerDismissal(id: bannerID) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } + + /// On each new chat, check whether the project's local `.env*` files are + /// backed up to autopilot. If the repo has no secrets at all, or a detected + /// `.env*` file is missing from every environment, surface a banner above + /// the input box prompting the user to set it up. This is a passive check — + /// it never shows the modal loading dialog. + func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { + logger.debug("[Hook] onProjectNewChatStart fired: projectId=\(payload.projectId.uuidString, privacy: .public) sessionKey=\(payload.sessionKey, privacy: .public)") + guard let project = controller.project(for: payload.projectId) else { + logger.debug("[Hook] onProjectNewChatStart: no project for id \(payload.projectId.uuidString, privacy: .public) — bailing") + return .ignored + } + guard let repo = project.gitHubRepo else { + logger.debug("[Hook] onProjectNewChatStart: project \(project.name, privacy: .public) has no gitHubRepo — bailing") + return .ignored + } + + let bannerID = bannerID(for: project) ?? "\(repo)-new-project-autopilot" + + // Respect a prior user dismissal — don't resurface a banner they closed. + if controller.isBannerDismissed(id: bannerID) { + logger.debug("[Hook] onProjectNewChatStart: banner \(bannerID, privacy: .public) was dismissed by the user — not showing") + return .ignored + } + + let detected = DetectedEnv.scan(directory: project.path) + logger.debug("[Hook] onProjectNewChatStart: repo=\(repo, privacy: .public) path=\(project.path, privacy: .public) detectedEnvFiles=\(detected.map(\.filename).joined(separator: ","), privacy: .public)") + guard !detected.isEmpty else { + logger.debug("[Hook] onProjectNewChatStart: no local .env* files detected — dismissing banner \(bannerID, privacy: .public)") + controller.dismissBanner(id: bannerID, in: .newProject) + return .ignored + } + + let path = project.path + guard let environments = await controller.secretEnvironments(repoFullName: repo) else { + // The check failed or was cancelled (e.g. the user switched chats + // mid-flight). Leave the banner untouched rather than flashing a + // stale "back up secrets" banner off an inconclusive result. + logger.debug("[Hook] onProjectNewChatStart: environments check inconclusive — leaving banner untouched") + return .ignored + } + // Bail if this run was superseded (chat switched) so a cancelled task + // never mutates the banner the fresh task is responsible for. + guard !Task.isCancelled else { + logger.debug("[Hook] onProjectNewChatStart: task cancelled after environments fetch — bailing") + return .ignored + } + logger.debug("[Hook] onProjectNewChatStart: autopilot environments count=\(environments.count, privacy: .public) names=\(environments.map(\.label).joined(separator: ","), privacy: .public)") + if environments.isEmpty { + logger.debug("[Hook] onProjectNewChatStart: repo has no environments — showing 'back up secrets' banner \(bannerID, privacy: .public)") + controller.showBanner(in: .newProject, position: .aboveInputBox, id: bannerID, projectId: project.id) { + SecretsEnvBanner(repo: repo, projectPath: path, missingFile: nil) { + controller.markBannerDismissed(id: bannerID, in: .newProject) + } + } + return .proceed + } + + // Surface the first local file that isn't backed up anywhere. + var missing: String? + for env in detected { + let exists = await controller.secretFileExists(repoFullName: repo, filename: env.filename) + logger.debug("[Hook] onProjectNewChatStart: secretFileExists(\(env.filename, privacy: .public)) = \(exists, privacy: .public)") + if !exists { + missing = env.filename + break + } + } + + // Re-check cancellation: the per-file lookups above are async, so a chat + // switch could have superseded this run while they were in flight. + guard !Task.isCancelled else { + logger.debug("[Hook] onProjectNewChatStart: task cancelled after file checks — bailing") + return .ignored + } + + if let missing { + logger.debug("[Hook] onProjectNewChatStart: \(missing, privacy: .public) is NOT backed up — showing banner \(bannerID, privacy: .public)") + controller.showBanner(in: .newProject, position: .aboveInputBox, id: bannerID, projectId: project.id) { + SecretsEnvBanner(repo: repo, projectPath: path, missingFile: missing) { + controller.markBannerDismissed(id: bannerID, in: .newProject) + } + } + } else { + logger.debug("[Hook] onProjectNewChatStart: all detected .env files are backed up — dismissing banner \(bannerID, privacy: .public)") + controller.dismissBanner(id: bannerID, in: .newProject) + } + return .proceed + } + + private func run(project: Project, controller: any HookController) async -> HookOutcome { + guard let repo = project.gitHubRepo else { return .ignored } + // Always dismiss the dialog, even on early return / thrown error. + defer { controller.endProgress() } + + controller.beginProgress("hook.secrets.checking") + let environments = await controller.secretEnvironments(repoFullName: repo) ?? [] + guard !environments.isEmpty else { return .ignored } + + // Always show the picker so the user explicitly chooses an environment. + controller.endProgress() + guard let env = await controller.requestChoice( + title: "hook.secrets.pickEnv", + choices: environments + ) else { return .ignored } + + do { + controller.beginProgress("hook.secrets.downloading") + let files = try await controller.fetchSecrets(repoFullName: repo, env: env) + + // Detect files that would clobber something already in the folder. + let conflicts = files + .map(\.filename) + .filter { FileManager.default.fileExists(atPath: (project.path as NSString).appendingPathComponent($0)) } + + var overwrite = false + if !conflicts.isEmpty { + controller.endProgress() + overwrite = await controller.requestConfirmation( + title: "hook.secrets.overwriteConfirm", + detail: conflicts.joined(separator: ", ") + ) + controller.beginProgress("hook.secrets.downloading") + } + + let written = try controller.writeSecrets(files, toPath: project.path, overwrite: overwrite) + logger.info("Downloaded \(written.count) secret file(s) for \(repo)") + return .proceed + } catch { + logger.error("Secret auto-download failed for \(repo): \(error.localizedDescription)") + return .output("Secret download failed: \(error.localizedDescription)", isError: true) + } + } +} +#endif diff --git a/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift b/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift deleted file mode 100644 index 592fd5ad..00000000 --- a/RxCode/Services/Hooks/hooks/SecretsAutoDownloadHook.swift +++ /dev/null @@ -1,65 +0,0 @@ -#if os(macOS) -import Foundation -import os -import RxCodeCore -import SwiftUI - -/// When a repository is added to the IDE (including clones — the clone path now -/// also fires `onRepositoryAdded`), check the autopilot secrets endpoint for the -/// project's GitHub repo. If any environments exist, let the user pick one in a -/// loading dialog, then download + decrypt its files into the project folder. -/// Conflicting local files are only overwritten after an explicit confirmation. -@MainActor -final class SecretsAutoDownloadHook: Hook { - let hookID = "builtin.secretsAutoDownload" - private let logger = Logger(subsystem: "com.claudework", category: "SecretsAutoDownloadHook") - - func onRepositoryAdded(_ payload: RepositoryPayload, controller: any HookController) async -> HookOutcome { - await run(project: payload.project, controller: controller) - } - - private func run(project: Project, controller: any HookController) async -> HookOutcome { - guard let repo = project.gitHubRepo else { return .ignored } - // Always dismiss the dialog, even on early return / thrown error. - defer { controller.endProgress() } - - controller.beginProgress("hook.secrets.checking") - let environments = await controller.secretEnvironments(repoFullName: repo) - guard !environments.isEmpty else { return .ignored } - - // Always show the picker so the user explicitly chooses an environment. - controller.endProgress() - guard let env = await controller.requestChoice( - title: "hook.secrets.pickEnv", - choices: environments - ) else { return .ignored } - - do { - controller.beginProgress("hook.secrets.downloading") - let files = try await controller.fetchSecrets(repoFullName: repo, env: env) - - // Detect files that would clobber something already in the folder. - let conflicts = files - .map(\.filename) - .filter { FileManager.default.fileExists(atPath: (project.path as NSString).appendingPathComponent($0)) } - - var overwrite = false - if !conflicts.isEmpty { - controller.endProgress() - overwrite = await controller.requestConfirmation( - title: "hook.secrets.overwriteConfirm", - detail: conflicts.joined(separator: ", ") - ) - controller.beginProgress("hook.secrets.downloading") - } - - let written = try controller.writeSecrets(files, toPath: project.path, overwrite: overwrite) - logger.info("Downloaded \(written.count) secret file(s) for \(repo)") - return .proceed - } catch { - logger.error("Secret auto-download failed for \(repo): \(error.localizedDescription)") - return .output("Secret download failed: \(error.localizedDescription)", isError: true) - } - } -} -#endif diff --git a/RxCode/Services/Secrets/SecretsService.swift b/RxCode/Services/Secrets/SecretsService.swift index bb50be6e..1ffe198d 100644 --- a/RxCode/Services/Secrets/SecretsService.swift +++ b/RxCode/Services/Secrets/SecretsService.swift @@ -125,6 +125,14 @@ final class SecretsService { ) } + /// Searches every environment of `repo` for files matching `filenames`, + /// in a single request. Returns one result per requested filename, each + /// listing the environments that contain it (`exists` = found anywhere). + func searchFiles(repo: String, filenames: [String]) async throws -> SecretsFileSearchList { + let query = filenames.map { URLQueryItem(name: "filename", value: $0) } + return try await get(url: url("/api/v1/secrets/repositories/\(seg(repo))/files", query: query)) + } + func deleteFile(repo: String, envId: String, fileId: String) async throws { let _: Ignored = try await send( method: "DELETE", diff --git a/RxCode/Views/Hooks/HookBannerHost.swift b/RxCode/Views/Hooks/HookBannerHost.swift new file mode 100644 index 00000000..e2b5d471 --- /dev/null +++ b/RxCode/Views/Hooks/HookBannerHost.swift @@ -0,0 +1,42 @@ +import os +import RxCodeCore +import SwiftUI + +/// Renders hook-supplied banners for a given surface + position. The host adds +/// no chrome of its own — each banner view styles itself (see `SecretsEnvBanner`). +/// Hooks publish banners through `HookController.showBanner(in:position:id:)`. +/// +/// The slide-in / fade is driven by `withAnimation` in `AppStateHookController`'s +/// `showBanner`/`dismissBanner` (so the transaction wraps the state mutation); +/// each banner carries its own `.transition`, keyed by `id` so the right view +/// animates when banners are added or removed. +struct HookBannerHost: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + let surface: HookBannerSurface + let position: HookBannerPosition + + private static let logger = Logger(subsystem: "com.claudework", category: "HookBannerHost") + + private var items: [HookBannerItem] { + let currentProjectId = windowState.selectedProject?.id + return (appState.hookBanners[surface] ?? []).filter { item in + guard item.position == position else { return false } + // A project-scoped banner only shows while its project is open; + // an unscoped banner (projectId == nil) always shows. + guard let owner = item.projectId else { return true } + return owner == currentProjectId + } + } + + var body: some View { + let items = items + let _ = Self.logger.debug("[Hook] HookBannerHost body: surface=\(surface.rawValue, privacy: .public) position=\(position.rawValue, privacy: .public) matchingItems=\(items.count, privacy: .public) totalInSurface=\(appState.hookBanners[surface]?.count ?? 0, privacy: .public)") + VStack(spacing: 8) { + ForEach(items) { item in + item.content + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } +} diff --git a/RxCode/Views/Hooks/SecretsEnvBanner.swift b/RxCode/Views/Hooks/SecretsEnvBanner.swift new file mode 100644 index 00000000..d1f23d4b --- /dev/null +++ b/RxCode/Views/Hooks/SecretsEnvBanner.swift @@ -0,0 +1,96 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Banner shown on the new-project screen when a project has a local `.env*` +/// file that isn't backed up to autopilot (or the repo has no secrets set up). +/// Built by `AutopilotHook` and rendered via `HookBannerHost`. The +/// "Set up" button opens the secrets deep link, which `MainWindowRoot` routes +/// to the secret-setup form. +struct SecretsEnvBanner: View { + let repo: String + let projectPath: String? + /// The missing file (e.g. `.env`), or nil when the repo has no secrets at all. + let missingFile: String? + /// Called when the user taps the close button. The hook persists the + /// dismissal so the banner won't reappear. + let onDismiss: () -> Void + + @Environment(\.openURL) private var openURL + @State private var isHovered = false + @State private var isCloseHovered = false + + private var message: String { + if let missingFile { + return String(format: String(localized: "%@ isn't backed up to Autopilot"), missingFile) + } + return String(localized: "Back up this project's secrets to Autopilot") + } + + var body: some View { + HStack(spacing: 8) { + // Main call-to-action: the whole row (minus the close button) opens + // the secrets setup deep link. + Button(action: open) { + HStack(spacing: 10) { + Image(systemName: "key.fill") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + + Text(message) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(2) + + Spacer(minLength: 8) + + Text("Set up") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(ClaudeTheme.accent, in: Capsule()) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .pointerCursorOnHover() + + // Dismiss: persists so the banner won't return for this project. + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: ClaudeTheme.size(11), weight: .bold)) + .foregroundStyle(ClaudeTheme.textSecondary.opacity(isCloseHovered ? 1 : 0.6)) + .frame(width: 20, height: 20) + .background( + Circle().fill(ClaudeTheme.textSecondary.opacity(isCloseHovered ? 0.12 : 0)) + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isCloseHovered = $0 } + .pointerCursorOnHover() + .help("Dismiss") + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(ClaudeTheme.accentSubtle) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(ClaudeTheme.accent.opacity(isHovered ? 0.55 : 0.35), lineWidth: 1) + ) + .padding(.horizontal, 16) + .padding(.bottom, 6) + .animation(.easeInOut(duration: 0.12), value: isHovered) + .animation(.easeInOut(duration: 0.12), value: isCloseHovered) + } + + private func open() { + guard let url = SecretsDeepLink.addURL(repo: repo, path: projectPath, file: missingFile) else { return } + openURL(url) + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 983dd49b..ac04c82e 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -41,6 +41,22 @@ struct MainView: View { } } + /// Re-runs the new-chat hooks (e.g. the Autopilot `.env` banner) whenever the + /// open project or the active session changes — so the banner appears on + /// opening the chat screen, not only after the first message is sent. + private var newChatHookKey: String { + "\(windowState.selectedProject?.id.uuidString ?? "none")|\(windowState.currentSessionId ?? "new")" + } + + /// Re-evaluate the new-chat hooks (e.g. the Autopilot `.env` banner) after the + /// secrets sheet closes, so finishing a backup immediately dismisses the + /// "back up secrets" banner instead of waiting for the next chat switch. + private func rerunNewChatHooks() { + guard let project = windowState.selectedProject else { return } + let sessionKey = windowState.currentSessionId ?? windowState.newSessionKey + Task { await appState.runProjectNewChatHooks(projectId: project.id, sessionKey: sessionKey) } + } + private var navigationTitleText: String { if windowState.showingBriefing { return "Briefing" @@ -252,10 +268,20 @@ struct MainView: View { }, bottomAccessory: { RecentChatsSuggestionList() }, aboveInputAccessory: { - PermissionQueueBanner() + VStack(spacing: 8) { + PermissionQueueBanner() + HookBannerHost(surface: .newProject, position: .aboveInputBox) + } }) } .modifier(ChatDetailModifiers()) + .task(id: newChatHookKey) { + guard let project = windowState.selectedProject else { return } + await appState.runProjectNewChatHooks( + projectId: project.id, + sessionKey: windowState.currentSessionId ?? windowState.newSessionKey + ) + } } else if !windowState.isInitialized { ProgressView() .controlSize(.small) @@ -270,6 +296,13 @@ struct MainView: View { .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, minHeight: 600, idealHeight: 1000, maxHeight: 1200) } + .sheet(item: Bindable(appState).secretsSetupRequest, onDismiss: rerunNewChatHooks) { request in + SecretsManageSheet( + currentRepoFullName: request.repoFullName, + currentProjectPath: request.projectPath + ) + .environment(appState) + } .sheet(item: Bindable(windowState).diffFile) { file in FileDiffView( filePath: file.path, diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index 5bbb3b31..4cfe7d64 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -10,6 +10,13 @@ struct ProjectWindowView: View { @State private var columnVisibility: NavigationSplitViewVisibility = .all @AppStorage(AppStorageKeys.showRightSidebar) private var showRightSidebar = false + /// Re-runs the new-chat hooks (e.g. the Autopilot `.env` banner) whenever the + /// open project or the active session changes — so the banner appears on + /// opening the chat screen, not only after the first message is sent. + private var newChatHookKey: String { + "\(windowState.selectedProject?.id.uuidString ?? "none")|\(windowState.currentSessionId ?? "new")" + } + private var navigationTitleText: String { if windowState.showingBriefing { return "Briefing" @@ -148,10 +155,20 @@ struct ProjectWindowView: View { }, bottomAccessory: { RecentChatsSuggestionList() }, aboveInputAccessory: { - PermissionQueueBanner() + VStack(spacing: 8) { + PermissionQueueBanner() + HookBannerHost(surface: .newProject, position: .aboveInputBox) + } }) } .modifier(ChatDetailModifiers()) + .task(id: newChatHookKey) { + guard let project = windowState.selectedProject else { return } + await appState.runProjectNewChatHooks( + projectId: project.id, + sessionKey: windowState.currentSessionId ?? windowState.newSessionKey + ) + } } else { ProgressView() .controlSize(.small) diff --git a/RxCode/Views/Secrets/SecretsDeepLink.swift b/RxCode/Views/Secrets/SecretsDeepLink.swift new file mode 100644 index 00000000..f9fa25dc --- /dev/null +++ b/RxCode/Views/Secrets/SecretsDeepLink.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Request to present the secret-setup form, carrying optional context so the +/// sheet can pin the right repo and pre-detect the relevant local file. +struct SecretsSetupRequest: Identifiable, Hashable { + let id = UUID() + var repoFullName: String? + var projectPath: String? + /// The local filename that triggered the prompt (e.g. `.env`), if any. + var filename: String? +} + +/// Parses `rxcode://secrets/add?repo=&path=&file=` deep +/// links into a `SecretsSetupRequest`. Returns nil for unrelated URLs. +enum SecretsDeepLink { + static let scheme = "rxcode" + + static func parse(_ url: URL) -> SecretsSetupRequest? { + guard url.scheme == scheme else { return nil } + // Accept both rxcode://secrets/add and rxcode:secrets/add forms. + let host = url.host + let firstPath = url.pathComponents.first(where: { $0 != "/" }) + let segment = host ?? firstPath + guard segment == "secrets" else { return nil } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let items = components?.queryItems ?? [] + func value(_ name: String) -> String? { + items.first(where: { $0.name == name })?.value?.removingPercentEncoding + } + return SecretsSetupRequest( + repoFullName: value("repo"), + projectPath: value("path"), + filename: value("file") + ) + } + + /// Builds the deep link the banner's button opens. + static func addURL(repo: String, path: String?, file: String?) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "secrets" + components.path = "/add" + var query: [URLQueryItem] = [URLQueryItem(name: "repo", value: repo)] + if let path { query.append(URLQueryItem(name: "path", value: path)) } + if let file { query.append(URLQueryItem(name: "file", value: file)) } + components.queryItems = query + return components.url + } +} diff --git a/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift index 88938064..ab78ab2a 100644 --- a/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift +++ b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift @@ -8,6 +8,9 @@ struct SecretsEnvironmentDetailView: View { let route: SecretsEnvRoute var projectPath: String? + /// Closes the enclosing Manage Secrets sheet (threaded from + /// `SecretsManageSheet`), so "Done" stays reachable from this pushed view. + var onClose: (() -> Void)? @State private var environmentKey: SecretsEnvironmentKey? @State private var files: [SecretsBundleFile] = [] @@ -48,13 +51,6 @@ struct SecretsEnvironmentDetailView: View { Text("No secrets yet. Add a .env file or enter values manually.") .foregroundStyle(.secondary) } - // Always-visible add affordance: the toolbar button is unreliable - // inside this sheet's navigation chrome, so keep one in the list. - Button { showAdd = true } label: { - Label("Add Secret", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - .disabled(environmentKey == nil) } .overlay { if isLoading, files.isEmpty { ProgressView() } } .overlay { @@ -76,6 +72,11 @@ struct SecretsEnvironmentDetailView: View { .animation(.default, value: deletingFilename) .navigationTitle(route.env.name) .toolbar { + if let onClose { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onClose() } + } + } ToolbarItem(placement: .primaryAction) { Button { showAdd = true } label: { Label("Add Secret", systemImage: "plus") } .disabled(environmentKey == nil) diff --git a/RxCode/Views/Secrets/SecretsManageSheet.swift b/RxCode/Views/Secrets/SecretsManageSheet.swift index 9135e448..6f71a595 100644 --- a/RxCode/Views/Secrets/SecretsManageSheet.swift +++ b/RxCode/Views/Secrets/SecretsManageSheet.swift @@ -29,9 +29,13 @@ struct SecretsManageSheet: View { @State private var isLoading = false @State private var errorMessage: String? @State private var searchTask: Task? + @State private var path = NavigationPath() + /// Whether we've already drilled into the pinned repo, so re-running + /// `reload()` (e.g. on search) doesn't fight the user's navigation. + @State private var didAutoNavigate = false var body: some View { - NavigationStack { + NavigationStack(path: $path) { VStack(spacing: 0) { content } @@ -42,10 +46,10 @@ struct SecretsManageSheet: View { } } .navigationDestination(for: SecretsManagedRepo.self) { repo in - SecretsRepoDetailView(repo: repo, projectPath: projectPath(for: repo)) + SecretsRepoDetailView(repo: repo, projectPath: projectPath(for: repo), onClose: { dismiss() }) } .navigationDestination(for: SecretsEnvRoute.self) { route in - SecretsEnvironmentDetailView(route: route, projectPath: pathIfCurrent(route.repoFullName)) + SecretsEnvironmentDetailView(route: route, projectPath: pathIfCurrent(route.repoFullName), onClose: { dismiss() }) } } .frame(width: 560, height: 560) @@ -115,6 +119,7 @@ struct SecretsManageSheet: View { repos = page.items nextCursor = page.pagination.nextCursor hasMore = page.pagination.hasMore + autoNavigateToCurrentRepoIfNeeded() } catch { errorMessage = error.localizedDescription repos = [] @@ -122,6 +127,17 @@ struct SecretsManageSheet: View { } } + /// When opened from the banner's deep link with a pinned repo, skip the repo + /// picker entirely and push straight to that repo's environments page. + private func autoNavigateToCurrentRepoIfNeeded() { + guard !didAutoNavigate, + let currentRepoFullName, + let match = repos.first(where: { $0.fullName == currentRepoFullName }) + else { return } + didAutoNavigate = true + path.append(match) + } + private func loadMore() async { guard let cursor = nextCursor else { return } isLoading = true diff --git a/RxCode/Views/Secrets/SecretsRepoDetailView.swift b/RxCode/Views/Secrets/SecretsRepoDetailView.swift index dd8e9a62..b7608ed9 100644 --- a/RxCode/Views/Secrets/SecretsRepoDetailView.swift +++ b/RxCode/Views/Secrets/SecretsRepoDetailView.swift @@ -8,6 +8,10 @@ struct SecretsRepoDetailView: View { let repo: SecretsManagedRepo var projectPath: String? + /// Closes the enclosing Manage Secrets sheet. Set by `SecretsManageSheet` so + /// a "Done" button stays available even when the deep link drills straight + /// into this pushed view (where the root sheet's toolbar isn't shown). + var onClose: (() -> Void)? @State private var repoId: String? @State private var environments: [SecretsEnvironment] = [] @@ -60,6 +64,11 @@ struct SecretsRepoDetailView: View { .overlay { if isLoading, environments.isEmpty { ProgressView() } } .navigationTitle(repo.name) .toolbar { + if let onClose { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onClose() } + } + } ToolbarItem(placement: .primaryAction) { Button { newName = ""; showCreate = true } label: { Label("New Environment", systemImage: "plus")