diff --git a/Packages/Sources/RxCodeCore/Autopilot/AutopilotConfigModels.swift b/Packages/Sources/RxCodeCore/Autopilot/AutopilotConfigModels.swift new file mode 100644 index 0000000..7c280cb --- /dev/null +++ b/Packages/Sources/RxCodeCore/Autopilot/AutopilotConfigModels.swift @@ -0,0 +1,72 @@ +import Foundation + +// Codable DTOs mirroring github-pm's autopilot configuration JSON shapes +// (`/api/v1/automation/*`, `/api/v1/preferences`, `/api/v1/repo-setup/*`). +// Arbitrary JSON (schemas, ui schemas, values, merge settings, rulesets) is +// carried as `JSONValue` so RxCodeCore never has to import the form renderer. + +public extension JSONValue { + /// A compact JSON string of this value, suitable for feeding the + /// `JSONSchemaForm` renderer (`JSONSchema(jsonString:)`, + /// `FormData.fromJSONString(_:)`). + var rawJSONString: String { + guard let data = try? JSONEncoder().encode(self), + let string = String(data: data, encoding: .utf8) + else { return "null" } + return string + } + + /// A human-readable, pretty-printed JSON string for editors. + var prettyJSONString: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(self), + let string = String(data: data, encoding: .utf8) + else { return rawJSONString } + return string + } + + /// A Foundation object tree (`[String: Any]`, `[Any]`, `NSNull`, scalars) + /// suitable for the renderer's `uiSchema: [String: Any]?` parameter. + var foundationObject: Any { + switch self { + case .string(let value): return value + case .number(let value): return value + case .bool(let value): return value + case .object(let value): return value.mapValues { $0.foundationObject } + case .array(let value): return value.map { $0.foundationObject } + case .null: return NSNull() + } + } + + /// Decodes a `JSONValue` from raw JSON bytes. + init(jsonData: Data) throws { + self = try JSONDecoder().decode(JSONValue.self, from: jsonData) + } +} + +// MARK: - Schema envelope + +/// Response of the schema endpoints: a JSON Schema plus an optional RJSF-style +/// ui schema (`/api/v1/automation/schema`, `/api/v1/repo-setup/schema`). +public struct SchemaEnvelope: Codable, Sendable { + public let schema: JSONValue + public let uiSchema: JSONValue? + + public init(schema: JSONValue, uiSchema: JSONValue? = nil) { + self.schema = schema + self.uiSchema = uiSchema + } +} + +// MARK: - Automation preferences (values) + +/// Wrapper for `GET`/`PUT /api/v1/preferences` — the saved automation settings +/// values, opaque to the app (rendered/validated against the schema). +public struct PreferencesValues: Codable, Sendable { + public let values: JSONValue + + public init(values: JSONValue) { + self.values = values + } +} diff --git a/Packages/Sources/RxCodeCore/Autopilot/RepoSetupModels.swift b/Packages/Sources/RxCodeCore/Autopilot/RepoSetupModels.swift new file mode 100644 index 0000000..e6c5fd5 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Autopilot/RepoSetupModels.swift @@ -0,0 +1,133 @@ +import Foundation + +// Codable DTOs for github-pm's repo-setup templates +// (`/api/v1/repo-setup/templates`). A template bundles GitHub merge settings +// (rendered from a JSON schema) with an optional raw GitHub ruleset. + +// MARK: - Template + +public struct RepoSetupTemplate: Codable, Sendable, Identifiable, Hashable { + public let id: String + public var name: String + public var enabled: Bool + public var isDefault: Bool + /// GitHub PR merge settings; shape defined by the repo-setup JSON schema. + public var mergeSettings: JSONValue + /// Raw GitHub ruleset JSON, or `nil` when no ruleset is attached. + public var rulesetConfig: JSONValue? + + public init( + id: String, + name: String, + enabled: Bool, + isDefault: Bool, + mergeSettings: JSONValue, + rulesetConfig: JSONValue? = nil + ) { + self.id = id + self.name = name + self.enabled = enabled + self.isDefault = isDefault + self.mergeSettings = mergeSettings + self.rulesetConfig = rulesetConfig + } +} + +public struct RepoSetupTemplateList: Codable, Sendable { + public let items: [RepoSetupTemplate] + + public init(items: [RepoSetupTemplate]) { + self.items = items + } +} + +/// Create/update payload for a repo-setup template. +public struct RepoSetupTemplateInput: Codable, Sendable { + public var name: String + public var enabled: Bool + public var isDefault: Bool + public var mergeSettings: JSONValue + public var rulesetConfig: JSONValue? + + public init( + name: String, + enabled: Bool, + isDefault: Bool, + mergeSettings: JSONValue, + rulesetConfig: JSONValue? = nil + ) { + self.name = name + self.enabled = enabled + self.isDefault = isDefault + self.mergeSettings = mergeSettings + self.rulesetConfig = rulesetConfig + } +} + +// MARK: - GitHub ruleset validation + +/// A validated summary of a raw GitHub ruleset JSON, mirroring the webapp's +/// upload check (`components/repo-setup/template-form.tsx`): the JSON must be an +/// object carrying `name`, `target`, `enforcement`, and `rules`. +public struct GitHubRulesetSummary: Sendable, Equatable { + public let name: String + public let target: String + public let enforcement: String + public let ruleCount: Int + /// The parsed ruleset, ready to store as a template's `rulesetConfig`. + public let value: JSONValue + + public enum ValidationError: Error, Equatable, LocalizedError { + case empty + case invalidJSON + case notAnObject + case missingFields([String]) + + public var errorDescription: String? { + switch self { + case .empty: + return "Paste or import a GitHub ruleset JSON." + case .invalidJSON: + return "Invalid JSON. Please check the file format." + case .notAnObject: + return "A ruleset must be a JSON object." + case .missingFields(let fields): + return "Invalid ruleset format. Missing: \(fields.joined(separator: ", ")). Expected name, target, enforcement, and rules." + } + } + } + + /// Parses and validates a raw GitHub ruleset JSON string. + public static func validate(rawJSON: String) -> Result { + let trimmed = rawJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .failure(.empty) } + guard let data = trimmed.data(using: .utf8), + let parsed = try? JSONValue(jsonData: data) + else { return .failure(.invalidJSON) } + guard case .object(let fields) = parsed else { return .failure(.notAnObject) } + + var missing: [String] = [] + let name = fields["name"]?.stringValue + let target = fields["target"]?.stringValue + let enforcement = fields["enforcement"]?.stringValue + let rules = fields["rules"]?.arrayValue + + if name == nil { missing.append("name") } + if target == nil { missing.append("target") } + if enforcement == nil { missing.append("enforcement") } + if rules == nil { missing.append("rules") } + guard missing.isEmpty, + let name, let target, let enforcement, let rules + else { return .failure(.missingFields(missing)) } + + return .success( + GitHubRulesetSummary( + name: name, + target: target, + enforcement: enforcement, + ruleCount: rules.count, + value: parsed + ) + ) + } +} diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index e88710a..7e8cf02 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -350,6 +350,24 @@ public enum IDEToolRegistry { ]), ]) ), + IDETool( + name: "ide__setup_release", + description: "Register the repository with the release service and (optionally) install its RELEASE_TOKEN GitHub Actions secret in one step, so the repo's semantic-release CI can create releases. Use this when setting up release publishing. If the repository isn't registered yet, it's registered automatically (the RxLab GitHub App must be installed on it) and its workflows are scanned. The RELEASE_TOKEN is user-supplied: ask the user for a GitHub token with `contents:write`, then pass it as `release_token` so it's installed as the repo's `RELEASE_TOKEN` secret. If the call fails with a permission error, tell the user to re-authorize the RxLab GitHub App (Actions secrets: read & write) and retry.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "repository": .object([ + "type": .string("string"), + "description": .string("Optional `owner/repo` full name. Omit to use the current project's repository."), + ]), + "release_token": .object([ + "type": .string("string"), + "description": .string("Optional user-supplied GitHub token (e.g. a PAT with `contents:write`) to install as the repo's `RELEASE_TOKEN` Actions secret. Omit to only register the repository without installing a secret."), + ]), + ]), + ]) + ), ] /// Returns the tools that should be exposed to an agent whose declared diff --git a/Packages/Sources/RxCodeCore/CIUpdates/CIUpdateModels.swift b/Packages/Sources/RxCodeCore/CIUpdates/CIUpdateModels.swift new file mode 100644 index 0000000..1e1be7d --- /dev/null +++ b/Packages/Sources/RxCodeCore/CIUpdates/CIUpdateModels.swift @@ -0,0 +1,298 @@ +import Foundation + +// Codable DTOs mirroring github-pm's `/api/v1/watched-repositories/*` JSON +// shapes (the "CI auto-update scan" feature). Field names match the server +// responses; extra fields decode harmlessly and uncertain fields are optional +// so a contract drift degrades gracefully rather than failing the whole decode. +// +// Two deliberate differences from the secrets DTOs: +// - Pagination is top-level (`nextCursor` / `hasMore`), NOT a nested +// `pagination` object like `SecretsManagedRepoPage`. +// - Timestamp encoding is INCONSISTENT across endpoints: STATUS emits epoch-ms +// numbers, while LIST/HISTORY/PRS emit ISO-8601 strings. `FlexibleTimestamp` +// tolerates both (plus null), so the client doesn't have to special-case it. + +// MARK: - Flexible timestamp + +/// A timestamp that tolerates every encoding the CI API emits: an ISO-8601 +/// string (LIST/HISTORY/PRS), an epoch-milliseconds number (STATUS), or null. +/// Exposes a single `date` accessor regardless of the wire shape. +public struct FlexibleTimestamp: Codable, Sendable, Hashable { + public let date: Date? + + public init(date: Date? = nil) { self.date = date } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.date = nil + } else if let ms = try? container.decode(Double.self) { + self.date = Date(timeIntervalSince1970: ms / 1000) + } else if let string = try? container.decode(String.self) { + self.date = FlexibleTimestamp.parseISO(string) + } else { + self.date = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + try container.encode(formatter.string(from: date)) + } else { + try container.encodeNil() + } + } + + /// ISO8601DateFormatter doesn't accept fractional seconds unless asked, and + /// the server emits both `…:00.000Z` and `…:00Z` forms — try both. Formatters + /// are created locally (the parsed lists are small) to stay `Sendable`-clean. + static func parseISO(_ string: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: string) { return date } + return ISO8601DateFormatter().date(from: string) + } +} + +// MARK: - Scan frequency + +/// How often autopilot scans a watched repo for outdated CI actions. Decoding is +/// lenient — an unrecognized value falls back to `.weekly` rather than failing +/// the whole list decode. +public enum CIScanFrequency: String, Codable, Sendable, CaseIterable, Identifiable { + case daily + case weekly + case monthly + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .daily: return "Daily" + case .weekly: return "Weekly" + case .monthly: return "Monthly" + } + } + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = CIScanFrequency(rawValue: raw) ?? .weekly + } +} + +// MARK: - Watched repository + +/// One configured repo from `GET /api/v1/watched-repositories`. +public struct WatchedRepo: Codable, Sendable, Identifiable, Hashable { + /// Internal watched-repository UUID. + public let id: String + public let installationId: Int + public let repositoryId: Int + public let repositoryFullName: String + public let owner: String? + public let repo: String? + public let lastScannedAt: FlexibleTimestamp? + public let scheduleId: String? + /// Nullable on the wire (column defaults to `"weekly"`); decode leniently and + /// expose the non-optional `scanFrequency` computed below. + public let scanFrequencyRaw: CIScanFrequency? + public let createdAt: FlexibleTimestamp? + public let updatedAt: FlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case id, installationId, repositoryId, repositoryFullName, owner, repo + case lastScannedAt, scheduleId + case scanFrequencyRaw = "scanFrequency" + case createdAt, updatedAt + } + + public var fullName: String { repositoryFullName } + public var scanFrequency: CIScanFrequency { scanFrequencyRaw ?? .weekly } + public var lastScannedDate: Date? { lastScannedAt?.date } + public var createdDate: Date? { createdAt?.date } + public var hasBeenScanned: Bool { lastScannedAt?.date != nil } + + public init( + id: String, + installationId: Int, + repositoryId: Int, + repositoryFullName: String, + owner: String? = nil, + repo: String? = nil, + lastScannedAt: FlexibleTimestamp? = nil, + scheduleId: String? = nil, + scanFrequency: CIScanFrequency = .weekly, + createdAt: FlexibleTimestamp? = nil, + updatedAt: FlexibleTimestamp? = nil + ) { + self.id = id + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + self.owner = owner + self.repo = repo + self.lastScannedAt = lastScannedAt + self.scheduleId = scheduleId + self.scanFrequencyRaw = scanFrequency + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// `GET /api/v1/watched-repositories` page. NOTE the top-level cursor/hasMore — +/// unlike secrets' nested `pagination` object. +public struct WatchedRepoPage: Codable, Sendable { + public let repositories: [WatchedRepo] + public let nextCursor: String? + public let hasMore: Bool + + public init(repositories: [WatchedRepo], nextCursor: String? = nil, hasMore: Bool = false) { + self.repositories = repositories + self.nextCursor = nextCursor + self.hasMore = hasMore + } +} + +// MARK: - Batch status (banner gating) + +/// One repo's watch status from `POST /api/v1/watched-repositories/status`. +/// `watchedRepositoryId == nil` means the repo is not yet watched. +public struct CIWatchStatus: Codable, Sendable, Hashable { + public let repositoryFullName: String + public let watchedRepositoryId: String? + public let scanFrequency: CIScanFrequency? + public let lastScannedAt: FlexibleTimestamp? + + public var isWatched: Bool { watchedRepositoryId != nil } + public var lastScannedDate: Date? { lastScannedAt?.date } + + public init( + repositoryFullName: String, + watchedRepositoryId: String? = nil, + scanFrequency: CIScanFrequency? = nil, + lastScannedAt: FlexibleTimestamp? = nil + ) { + self.repositoryFullName = repositoryFullName + self.watchedRepositoryId = watchedRepositoryId + self.scanFrequency = scanFrequency + self.lastScannedAt = lastScannedAt + } +} + +public struct CIWatchStatusList: Codable, Sendable { + public let items: [CIWatchStatus] +} + +public struct CIWatchStatusRequest: Codable, Sendable { + public let repositories: [String] + + public init(repositories: [String]) { + self.repositories = repositories + } +} + +// MARK: - Mutations + +/// Body for `POST /api/v1/watched-repositories` (register a repo to watch). +public struct AddWatchedRepoBody: Codable, Sendable { + public let installationId: Int + public let repositoryId: Int + public let repositoryFullName: String + public let scanFrequency: CIScanFrequency + + public init(installationId: Int, repositoryId: Int, repositoryFullName: String, scanFrequency: CIScanFrequency) { + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + self.scanFrequency = scanFrequency + } +} + +/// Body for `PATCH /api/v1/watched-repositories/{id}`. +public struct UpdateScanFrequencyBody: Codable, Sendable { + public let scanFrequency: CIScanFrequency + + public init(scanFrequency: CIScanFrequency) { + self.scanFrequency = scanFrequency + } +} + +/// Body for `DELETE /api/v1/watched-repositories/{id}/prs` (close a PR). +public struct ClosePRBody: Codable, Sendable { + public let prNumber: Int + + public init(prNumber: Int) { + self.prNumber = prNumber + } +} + +/// `POST /api/v1/watched-repositories/{id}/trigger` result. +public struct CITriggerResponse: Codable, Sendable { + public let success: Bool + public let prUrl: String? + public let error: String? +} + +// MARK: - Scan history + +/// One scan run from `GET /api/v1/watched-repositories/{id}/history` (a bare +/// array, not an `{ items: [...] }` wrapper). Counts may be nil for error runs. +public struct CIUpdateRunHistory: Codable, Sendable, Identifiable, Hashable { + public let id: String + public let watchedRepositoryId: String? + /// `"success"` or `"error"`. + public let status: String + public let workflowsScanned: Int? + public let actionsFound: Int? + public let outdatedActionsCount: Int? + public let prUrl: String? + public let prNumber: Int? + public let errorMessage: String? + public let startedAt: FlexibleTimestamp? + public let completedAt: FlexibleTimestamp? + + public var isSuccess: Bool { status == "success" } + public var startedDate: Date? { startedAt?.date } + public var completedDate: Date? { completedAt?.date } +} + +// MARK: - Pull requests + +/// One PR opened by the auto-updater, from +/// `GET /api/v1/watched-repositories/{id}/prs`. GitHub-native field names +/// (`number`, `html_url`, `created_at`, `merged`); all optional so decode +/// degrades gracefully. +public struct CIPullRequest: Codable, Sendable, Identifiable, Hashable { + public let number: Int? + public let title: String? + /// `"open"` or `"closed"` (a merged PR is `"closed"` + `merged: true`). + public let state: String? + public let htmlURL: String? + public let merged: Bool? + public let createdAt: FlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case number, title, state, merged + case htmlURL = "html_url" + case createdAt = "created_at" + } + + /// Stable identity: PR number when known, else the URL, else the title. + public var id: String { + if let number { return "pr-\(number)" } + if let htmlURL { return htmlURL } + return title ?? UUID().uuidString + } + + public var createdDate: Date? { createdAt?.date } + public var isOpen: Bool { (state ?? "open").lowercased() == "open" } +} + +// MARK: - Misc + +public struct CIUpdateIDResponse: Codable, Sendable { + public let id: String +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 547579e..7a32741 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -115,6 +115,14 @@ public protocol HookController: AnyObject { /// unless `overwrite`. Returns the filenames actually written. func writeSecrets(_ files: [HookSecretFile], toPath path: String, overwrite: Bool) throws -> [String] + // MARK: CI auto-update + + /// Whether the repo is configured for CI auto-updates (a "watched + /// repository"). Returns `nil` when the check can't be completed (signed out, + /// offline, request failed) so callers can tell that apart from a genuine + /// "not watched" and avoid surfacing a misleading banner. + func ciRepoIsWatched(repoFullName: String) async -> Bool? + // MARK: Docs /// Whether the repo has documentation indexed in the docs service. Returns @@ -128,6 +136,44 @@ public protocol HookController: AnyObject { /// session's system prompt and clears the pending flag. Returns `nil` /// otherwise. func consumePendingDocsSetupSkill(projectId: UUID) -> String? + + // MARK: Release + + /// Whether the repo has a release workflow set up (registered with the + /// release service AND has a selected dispatchable workflow). Returns `nil` + /// when the check can't be completed (signed out, offline, request failed) + /// so callers can tell that apart from a genuine "not set up" and avoid + /// surfacing a misleading banner. + func releaseConfigured(repoFullName: String) async -> Bool? + + /// One-shot: if a release-setup chat was kicked off for `projectId` (via the + /// release banner), returns the release skill text to inject as the session's + /// system prompt and clears the pending flag. Returns `nil` otherwise. + func consumePendingReleaseSetupSkill(projectId: UUID) -> String? + + // MARK: Setup-session tracking + + /// Record that `sessionKey` belongs to a setup chat of the given `kind` (e.g. + /// "release", "docs"). A setup hook marks the session on start and re-checks + /// the backing status on each completed turn until setup is confirmed — so the + /// marker is *peeked* (`isSetupSession`), not consumed, until `clearSetupSession`. + /// Multi-turn setups (the agent asks a question first) therefore aren't lost + /// after the first turn completes. + func markSetupSession(kind: String, sessionKey: String) + + /// Whether `sessionKey` was recorded as a setup chat of `kind`. Non-destructive. + func isSetupSession(kind: String, sessionKey: String) -> Bool + + /// Stop tracking `sessionKey` for `kind` — called once setup is confirmed + /// complete so later turns don't re-check. + func clearSetupSession(kind: String, sessionKey: String) +} + +/// Stable `kind` identifiers for `markSetupSession` / `isSetupSession` / +/// `clearSetupSession`, so hooks don't collide on free-form strings. +public enum HookSetupKind { + public static let release = "release" + public static let docs = "docs" } // MARK: - Banner surfaces diff --git a/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift index d411083..55e546f 100644 --- a/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift +++ b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift @@ -218,6 +218,14 @@ public struct CIFailingWorkflow: Codable, Sendable, Hashable { } } +/// State of the pull request associated with a branch, as tracked by autopilot +/// from GitHub webhooks. `merged` is distinct from `closed` (closed-without-merge). +public enum PRState: String, Sendable, Hashable { + case open + case closed + case merged +} + /// Per-repo result from `POST /api/v1/ci-status/batch`. public struct ProjectCIStatus: Codable, Sendable, Hashable { public let owner: String @@ -228,9 +236,19 @@ public struct ProjectCIStatus: Codable, Sendable, Hashable { public let lastUpdated: String? public let headSha: String? public let prNumber: Int? + /// Raw PR state string (`open`/`closed`/`merged`) or nil when no PR exists + /// for the branch. Stored raw so an unexpected server value never breaks + /// decoding; use ``pullRequestState`` for the typed value. + public let prState: String? + public let prUrl: String? public let workflows: [CIWorkflowStatus] public let failing: [CIFailingWorkflow] + /// Typed pull-request state, or nil when absent/unrecognized. + public var pullRequestState: PRState? { + prState.flatMap(PRState.init(rawValue:)) + } + public init( owner: String, repo: String, @@ -240,6 +258,8 @@ public struct ProjectCIStatus: Codable, Sendable, Hashable { lastUpdated: String?, headSha: String?, prNumber: Int?, + prState: String?, + prUrl: String?, workflows: [CIWorkflowStatus], failing: [CIFailingWorkflow] ) { @@ -251,11 +271,55 @@ public struct ProjectCIStatus: Codable, Sendable, Hashable { self.lastUpdated = lastUpdated self.headSha = headSha self.prNumber = prNumber + self.prState = prState + self.prUrl = prUrl self.workflows = workflows self.failing = failing } } +// MARK: - Create Pull Request DTOs + +/// Body for `POST /api/v1/pull-requests`. `base` is optional — autopilot uses +/// the repo's default branch when omitted. `head` is the branch to merge from. +public struct CreatePullRequestRequest: Codable, Sendable { + public let owner: String + public let repo: String + public let head: String + public let base: String? + public let title: String + public let body: String? + + public init( + owner: String, + repo: String, + head: String, + base: String?, + title: String, + body: String? + ) { + self.owner = owner + self.repo = repo + self.head = head + self.base = base + self.title = title + self.body = body + } +} + +/// Reply from `POST /api/v1/pull-requests`. +public struct CreatePullRequestResponse: Codable, Sendable { + public let prNumber: Int + public let prUrl: String + public let state: String + + public init(prNumber: Int, prUrl: String, state: String) { + self.prNumber = prNumber + self.prUrl = prUrl + self.state = state + } +} + public struct CIStatusBatchResponse: Codable, Sendable { public let results: [ProjectCIStatus] diff --git a/Packages/Sources/RxCodeCore/Models/ChatSession.swift b/Packages/Sources/RxCodeCore/Models/ChatSession.swift index fd46c7d..70fdc69 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatSession.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatSession.swift @@ -175,13 +175,25 @@ public struct ChatSession: Identifiable, Codable, Sendable { ) } + /// Strip inline Markdown emphasis markers (`**bold**`, `__bold__`, `` `code` ``) + /// that LLM-generated titles sometimes include. Titles are rendered as plain + /// text, so leftover markers surface literally (e.g. "**feat: …**"). Only the + /// markers are removed; the wrapped text is preserved. + public static func stripMarkdownEmphasis(from content: String) -> String { + content + .replacingOccurrences(of: "**", with: "") + .replacingOccurrences(of: "__", with: "") + .replacingOccurrences(of: "`", with: "") + } + /// Strip attachment markers from user message content so titles and prompts /// don't surface internal tokens. Removes: /// - `[Attached image: ...]`, `[Attached file: ...]`, `[Pasted text: ...]`, `[Link: ...]` /// - `[Image1]`, `[Image2]`, ... display tokens + /// - `**bold**` / `__bold__` / `` `code` `` Markdown emphasis markers /// Collapses runs of whitespace and trims edges. public static func stripAttachmentMarkers(from content: String) -> String { - content + stripMarkdownEmphasis(from: content) .replacingOccurrences( of: #"\[(Attached [A-Za-z]+|Pasted text|Link):[^\]]*\]"#, with: "", diff --git a/Packages/Sources/RxCodeCore/Release/ReleaseModels.swift b/Packages/Sources/RxCodeCore/Release/ReleaseModels.swift new file mode 100644 index 0000000..98226aa --- /dev/null +++ b/Packages/Sources/RxCodeCore/Release/ReleaseModels.swift @@ -0,0 +1,282 @@ +import Foundation + +// Codable DTOs mirroring github-pm's `/api/v1/release-repositories/*` JSON +// shapes. Field names match the server responses (Drizzle column names); +// extra fields decode harmlessly and uncertain fields are optional so a +// contract drift degrades gracefully rather than failing the whole decode. + +// MARK: - Repositories + +/// A release-managed repository from `GET /api/v1/release-repositories`. Rows +/// are returned verbatim from the `releaseRepository` table, so dates arrive as +/// ISO strings. +public struct ReleaseRepo: Codable, Sendable, Identifiable, Hashable { + /// Internal release-repository UUID. + public let id: String + public let installationId: Int? + public let repositoryId: Int? + public let repositoryFullName: String + public let owner: String? + public let repo: String? + public let lastScannedAt: String? + /// Cached latest release tag (e.g. `v1.4.2`), if any release exists yet. + public let latestReleaseTag: String? + public let latestReleaseAt: String? + public let versionsListVisibility: String? + public let createdAt: String? + public let updatedAt: String? + + public var fullName: String { repositoryFullName } + + public init( + id: String, + installationId: Int? = nil, + repositoryId: Int? = nil, + repositoryFullName: String, + owner: String? = nil, + repo: String? = nil, + lastScannedAt: String? = nil, + latestReleaseTag: String? = nil, + latestReleaseAt: String? = nil, + versionsListVisibility: String? = nil, + createdAt: String? = nil, + updatedAt: String? = nil + ) { + self.id = id + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + self.owner = owner + self.repo = repo + self.lastScannedAt = lastScannedAt + self.latestReleaseTag = latestReleaseTag + self.latestReleaseAt = latestReleaseAt + self.versionsListVisibility = versionsListVisibility + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct ReleaseRepoListResponse: Codable, Sendable { + public let items: [ReleaseRepo] + public let pagination: Pagination? + + public struct Pagination: Codable, Sendable { + public let nextCursor: String? + public let hasMore: Bool + } +} + +/// Body for `POST /api/v1/release-repositories` (register a repo for releases). +public struct AddReleaseRepoBody: Codable, Sendable { + public let installationId: Int + public let repositoryId: Int + public let repositoryFullName: String + + public init(installationId: Int, repositoryId: Int, repositoryFullName: String) { + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + } +} + +/// `{ id }` returned by `POST /api/v1/release-repositories`. +public struct ReleaseIDResponse: Codable, Sendable { + public let id: String +} + +// MARK: - Batch status + +/// Body for the batch status endpoint `POST /api/v1/release-repositories/status`. +public struct ReleaseRepoStatusRequest: Codable, Sendable { + public let repositories: [String] + + public init(repositories: [String]) { self.repositories = repositories } +} + +/// Per-repo release status from `POST /api/v1/release-repositories/status`. A +/// repo "has the release workflow set up" when it's registered **and** has a +/// selected dispatchable workflow (`hasReleaseWorkflow`). `latestVersion` is the +/// cached `latestReleaseTag`. +public struct ReleaseRepoStatus: Codable, Sendable, Hashable { + /// `owner/repo` the status is for. + public let fullName: String + /// Registered with the release service. + public let isManaged: Bool + /// Registered AND has a selected dispatchable release workflow. + public let hasReleaseWorkflow: Bool + /// Latest published release tag, if any. + public let latestVersion: String? + /// Internal release-repository UUID, when managed. + public let releaseRepositoryId: String? + + public init( + fullName: String, + isManaged: Bool, + hasReleaseWorkflow: Bool, + latestVersion: String? = nil, + releaseRepositoryId: String? = nil + ) { + self.fullName = fullName + self.isManaged = isManaged + self.hasReleaseWorkflow = hasReleaseWorkflow + self.latestVersion = latestVersion + self.releaseRepositoryId = releaseRepositoryId + } +} + +// MARK: - Workflows + +/// One parsed `workflow_dispatch` input from a release workflow's YAML. Matches +/// github-pm's `WorkflowInput`. Decode-only: the app reads these to build the +/// dispatch form and never sends them back. +public struct ReleaseWorkflowInput: Decodable, Sendable, Hashable, Identifiable { + public let name: String + /// `string | boolean | choice | environment`. + public let type: String + public let description: String? + public let required: Bool + /// The YAML `default` flattened to a string (booleans become `"true"`/`"false"`). + public let defaultString: String? + /// The YAML `default` as a bool, when the input is a boolean. + public let defaultBool: Bool? + /// Options for `choice` inputs. + public let options: [String]? + + public var id: String { name } + + enum CodingKeys: String, CodingKey { + case name, type, description, required, options + case defaultValue = "default" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + type = (try? c.decode(String.self, forKey: .type)) ?? "string" + description = try? c.decode(String.self, forKey: .description) + required = (try? c.decode(Bool.self, forKey: .required)) ?? false + options = try? c.decode([String].self, forKey: .options) + // The server's `default` is `string | boolean`; accept either. + if let b = try? c.decode(Bool.self, forKey: .defaultValue) { + defaultBool = b + defaultString = b ? "true" : "false" + } else if let s = try? c.decode(String.self, forKey: .defaultValue) { + defaultString = s + defaultBool = (s == "true") + } else { + defaultString = nil + defaultBool = nil + } + } + + public init( + name: String, + type: String, + description: String? = nil, + required: Bool = false, + defaultString: String? = nil, + defaultBool: Bool? = nil, + options: [String]? = nil + ) { + self.name = name + self.type = type + self.description = description + self.required = required + self.defaultString = defaultString + self.defaultBool = defaultBool + self.options = options + } +} + +/// A scanned workflow from `GET /api/v1/release-repositories/{id}/workflows` +/// (raw `releaseWorkflow` rows). `content` (raw YAML) is omitted from app use. +public struct ReleaseWorkflow: Decodable, Sendable, Identifiable, Hashable { + public let id: String + public let releaseRepositoryId: String? + public let workflowPath: String + public let workflowName: String? + /// User has marked this workflow as the release workflow. + public let isSelected: Bool + /// AI classification: looks like a release workflow. + public let isReleaseWorkflow: Bool? + /// Can be triggered via `workflow_dispatch`. + public let hasWorkflowDispatch: Bool + public let inputs: [ReleaseWorkflowInput]? + public let createdAt: String? + public let updatedAt: String? + + /// Display name falls back to the file name. + public var displayName: String { + if let workflowName, !workflowName.isEmpty { return workflowName } + return workflowPath.split(separator: "/").last.map(String.init) ?? workflowPath + } + + enum CodingKeys: String, CodingKey { + case id, releaseRepositoryId, workflowPath, workflowName + case isSelected, isReleaseWorkflow, hasWorkflowDispatch, inputs + case createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + releaseRepositoryId = try? c.decode(String.self, forKey: .releaseRepositoryId) + workflowPath = try c.decode(String.self, forKey: .workflowPath) + workflowName = try? c.decode(String.self, forKey: .workflowName) + isSelected = (try? c.decode(Bool.self, forKey: .isSelected)) ?? false + isReleaseWorkflow = try? c.decode(Bool.self, forKey: .isReleaseWorkflow) + hasWorkflowDispatch = (try? c.decode(Bool.self, forKey: .hasWorkflowDispatch)) ?? false + inputs = try? c.decode([ReleaseWorkflowInput].self, forKey: .inputs) + createdAt = try? c.decode(String.self, forKey: .createdAt) + updatedAt = try? c.decode(String.self, forKey: .updatedAt) + } +} + +// MARK: - Dispatch + +/// Body for `POST /api/v1/release-repositories/{id}/dispatch`. GitHub +/// `workflow_dispatch` inputs are always strings, so booleans are sent as +/// `"true"`/`"false"`. +public struct ReleaseDispatchRequest: Codable, Sendable { + public let workflowId: String + public let branch: String + public let inputs: [String: String] + + public init(workflowId: String, branch: String, inputs: [String: String] = [:]) { + self.workflowId = workflowId + self.branch = branch + self.inputs = inputs + } +} + +/// Result of a dispatch. On success the server returns `dispatchId` + +/// `workflowRunUrl` (the repo's Actions page). +public struct ReleaseDispatchResult: Codable, Sendable { + public let success: Bool? + public let dispatchId: String? + public let workflowRunUrl: String? + public let error: String? +} + +// MARK: - GitHub secret (RELEASE_TOKEN) + +/// Body for `POST /api/v1/release-repositories/{id}/github-secret`. Unlike docs +/// (which mints a token server-side), the release token is user-supplied and +/// installed as the `RELEASE_TOKEN` Actions secret. +public struct InstallReleaseTokenBody: Codable, Sendable { + public let value: String + + public init(value: String) { self.value = value } +} + +/// Result of installing the `RELEASE_TOKEN` secret. +public struct ReleaseGithubSecretResult: Codable, Sendable { + public let secretName: String + public let repositoryFullName: String + + public init(secretName: String, repositoryFullName: String) { + self.secretName = secretName + self.repositoryFullName = repositoryFullName + } +} diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index 9c36189..2b2784a 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -102,6 +102,14 @@ public enum GitHelper { return trimmed.isEmpty ? "git checkout exited \(process.terminationStatus)" : trimmed } + /// Creates a new branch and checks it out (`git checkout -b [fromRef]`) + /// at `path`. Returns the combined git output on failure, or nil on success. + public static func createBranch(_ branch: String, at path: String, fromRef: String? = nil) async -> String? { + var args = ["checkout", "-b", branch] + if let fromRef, !fromRef.isEmpty { args.append(fromRef) } + return await runWithError(args, at: path) + } + /// Stages the given paths (`git add --`). Returns nil on success or the /// combined output on failure. public static func stage(paths: [String], at repoPath: String) async -> String? { diff --git a/Packages/Tests/RxCodeCoreTests/ChatSessionTitleTests.swift b/Packages/Tests/RxCodeCoreTests/ChatSessionTitleTests.swift index d605eb2..436a88a 100644 --- a/Packages/Tests/RxCodeCoreTests/ChatSessionTitleTests.swift +++ b/Packages/Tests/RxCodeCoreTests/ChatSessionTitleTests.swift @@ -63,5 +63,36 @@ struct StripAttachmentMarkersTests { let input = "what is swift" #expect(ChatSession.stripAttachmentMarkers(from: input) == "what is swift") } + + @Test("Strips surrounding **bold** markers") + func stripsBold() { + let input = "**feat: Complete UI and backend integration**" + #expect(ChatSession.stripAttachmentMarkers(from: input) == "feat: Complete UI and backend integration") + } + + @Test("Strips inline bold, italic-bold, and code markers") + func stripsInlineEmphasis() { + let input = "fix the `parser` and __retry__ logic" + #expect(ChatSession.stripAttachmentMarkers(from: input) == "fix the parser and retry logic") + } +} + +@Suite("ChatSession.stripMarkdownEmphasis") +struct StripMarkdownEmphasisTests { + + @Test("Removes ** markers, keeps content") + func removesBold() { + #expect(ChatSession.stripMarkdownEmphasis(from: "**feat: add release mode**") == "feat: add release mode") + } + + @Test("Removes __ and backtick markers") + func removesUnderscoreAndCode() { + #expect(ChatSession.stripMarkdownEmphasis(from: "__chore__: bump `deps`") == "chore: bump deps") + } + + @Test("Plain text untouched") + func plainText() { + #expect(ChatSession.stripMarkdownEmphasis(from: "feat: add ci status scan") == "feat: add ci status scan") + } } diff --git a/Packages/Tests/RxCodeCoreTests/GitHubRulesetSummaryTests.swift b/Packages/Tests/RxCodeCoreTests/GitHubRulesetSummaryTests.swift new file mode 100644 index 0000000..1fd86f6 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/GitHubRulesetSummaryTests.swift @@ -0,0 +1,94 @@ +import Testing +@testable import RxCodeCore + +@Suite("GitHubRulesetSummary.validate") +struct GitHubRulesetSummaryTests { + + private let validRuleset = """ + { + "name": "main protection", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, + "rules": [ + { "type": "deletion" }, + { "type": "required_linear_history" } + ] + } + """ + + @Test("valid ruleset parses with summary fields") + func validParses() throws { + let result = GitHubRulesetSummary.validate(rawJSON: validRuleset) + let summary = try result.get() + #expect(summary.name == "main protection") + #expect(summary.target == "branch") + #expect(summary.enforcement == "active") + #expect(summary.ruleCount == 2) + } + + @Test("whitespace is tolerated") + func whitespaceTolerated() throws { + let result = GitHubRulesetSummary.validate(rawJSON: "\n\n " + validRuleset + " \n") + #expect((try? result.get()) != nil) + } + + @Test("empty input fails as empty") + func emptyFails() { + let result = GitHubRulesetSummary.validate(rawJSON: " \n ") + guard case .failure(let error) = result else { + Issue.record("expected failure") + return + } + #expect(error == .empty) + } + + @Test("malformed JSON fails") + func malformedFails() { + let result = GitHubRulesetSummary.validate(rawJSON: "{ not json ") + guard case .failure(let error) = result else { + Issue.record("expected failure") + return + } + #expect(error == .invalidJSON) + } + + @Test("non-object JSON fails") + func nonObjectFails() { + let result = GitHubRulesetSummary.validate(rawJSON: "[1, 2, 3]") + guard case .failure(let error) = result else { + Issue.record("expected failure") + return + } + #expect(error == .notAnObject) + } + + @Test("missing required fields are reported") + func missingFieldsReported() { + // Missing `enforcement` and `rules`. + let json = """ + { "name": "x", "target": "branch" } + """ + let result = GitHubRulesetSummary.validate(rawJSON: json) + guard case .failure(.missingFields(let fields)) = result else { + Issue.record("expected missingFields failure") + return + } + #expect(fields.contains("enforcement")) + #expect(fields.contains("rules")) + #expect(!fields.contains("name")) + } + + @Test("rules of wrong type counts as missing") + func rulesWrongType() { + let json = """ + { "name": "x", "target": "branch", "enforcement": "active", "rules": "nope" } + """ + let result = GitHubRulesetSummary.validate(rawJSON: json) + guard case .failure(.missingFields(let fields)) = result else { + Issue.record("expected missingFields failure") + return + } + #expect(fields == ["rules"]) + } +} diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 5110e6c..d67c315 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -148,6 +148,8 @@ struct PayloadTests { lastUpdated: "2026-05-31T00:00:00Z", headSha: "abc123", prNumber: 42, + prState: "open", + prUrl: "https://github.com/rxlab/rxcode/pull/42", workflows: [], failing: [ CIFailingWorkflow( diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index abedadd..07bd07c 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -40,10 +40,13 @@ DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462A612FC6EDCE002D9562 /* RxAuthSwiftUI */; }; DF462DC92FC73FA8002D9562 /* RxAuthSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DC82FC73FA8002D9562 /* RxAuthSwift */; }; DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */; }; + DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4652672FCD34B3002D9562 /* JSONSchemaForm */; }; + DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF4652692FCD34B3002D9562 /* JSONSchemaValidator */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; + DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DFAA00022FCD34B3002D9562 /* JSONSchema */; }; E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62000002FCB000100000001 /* MemoryIntentTests.swift */; }; E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; }; E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; @@ -297,7 +300,10 @@ DF4628922FC611E6002D9562 /* RxAuthSwift in Frameworks */, E6C001022F9B000100000001 /* Sparkle in Frameworks */, E6D001032FA0000100000001 /* RxCodeCore in Frameworks */, + DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */, + DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */, DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */, + DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */, E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, @@ -604,6 +610,9 @@ DF462A612FC6EDCE002D9562 /* RxAuthSwiftUI */, DF462DC82FC73FA8002D9562 /* RxAuthSwift */, DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */, + DF4652672FCD34B3002D9562 /* JSONSchemaForm */, + DF4652692FCD34B3002D9562 /* JSONSchemaValidator */, + DFAA00022FCD34B3002D9562 /* JSONSchema */, ); productName = RxCode; productReference = E67335382F7356F600FD26C7 /* RxCode.app */; @@ -665,6 +674,8 @@ DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */, FB0000000000000000000001 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */, + DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */, + DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -1514,6 +1525,22 @@ version = 1.1.1; }; }; + DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sirily11/swift-jsonschema-form"; + requirement = { + branch = main; + kind = branch; + }; + }; + DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sirily11/swift-json-schema"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.2; + }; + }; E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; @@ -1616,10 +1643,25 @@ package = DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */; productName = RxAuthSwiftUI; }; + DF4652672FCD34B3002D9562 /* JSONSchemaForm */ = { + isa = XCSwiftPackageProductDependency; + package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; + productName = JSONSchemaForm; + }; + DF4652692FCD34B3002D9562 /* JSONSchemaValidator */ = { + isa = XCSwiftPackageProductDependency; + package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; + productName = JSONSchemaValidator; + }; DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */ = { isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; }; + DFAA00022FCD34B3002D9562 /* JSONSchema */ = { + isa = XCSwiftPackageProductDependency; + package = DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */; + productName = JSONSchema; + }; E6A001012F8A000100000001 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4dc60b7..42cfc70 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "50bc054679eb41a28514dc07979bbfd75d2474ab3096c75e489fd93ab701012a", + "originHash" : "d763c7a5f7c6c058157fe0ff994249a039d6a9b2c97db6849d68d74c036c4de7", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -163,6 +163,15 @@ "version" : "0.8.0" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -172,6 +181,24 @@ "version" : "1.3.2" } }, + { + "identity" : "swift-json-schema", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sirily11/swift-json-schema", + "state" : { + "revision" : "663afab8c131151950fd7fb114871c02a529a4b4", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-jsonschema-form", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sirily11/swift-jsonschema-form", + "state" : { + "branch" : "main", + "revision" : "a4feb400a0bca57bc39b8ea95544c71aa768fbfd" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/RxCode/App/AppState+Autopilot.swift b/RxCode/App/AppState+Autopilot.swift new file mode 100644 index 0000000..a3b4c1a --- /dev/null +++ b/RxCode/App/AppState+Autopilot.swift @@ -0,0 +1,47 @@ +import Foundation +import RxCodeCore + +/// High-level autopilot configuration intents: thin pass-throughs to +/// `AutopilotService` for the schema-driven automation settings form and the +/// repo-setup templates. Views call these instead of reaching into `autopilot`. +extension AppState { + + // MARK: - Automation settings + + func automationSchema() async throws -> SchemaEnvelope { + try await autopilot.getAutomationSchema() + } + + func automationValues() async throws -> PreferencesValues { + try await autopilot.getPreferences() + } + + @discardableResult + func saveAutomationValues(_ values: JSONValue) async throws -> PreferencesValues { + try await autopilot.putPreferences(PreferencesValues(values: values)) + } + + // MARK: - Repo setup templates + + func repoSetupSchema() async throws -> SchemaEnvelope { + try await autopilot.getRepoSetupSchema() + } + + func repoSetupTemplates() async throws -> [RepoSetupTemplate] { + try await autopilot.listSetupTemplates().items + } + + @discardableResult + func createRepoSetupTemplate(_ input: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await autopilot.createSetupTemplate(input) + } + + @discardableResult + func updateRepoSetupTemplate(id: String, _ input: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await autopilot.updateSetupTemplate(id: id, input) + } + + func deleteRepoSetupTemplate(id: String) async throws { + try await autopilot.deleteSetupTemplate(id: id) + } +} diff --git a/RxCode/App/AppState+CIStatus.swift b/RxCode/App/AppState+CIStatus.swift index f52ae7a..ed95f16 100644 --- a/RxCode/App/AppState+CIStatus.swift +++ b/RxCode/App/AppState+CIStatus.swift @@ -64,17 +64,33 @@ extension AppState { // current branch. Keep the originating project alongside each query so we // can map the response back without ambiguity. var pairs: [(project: Project, query: CIStatusQuery)] = [] + var repoByProject: [UUID: (owner: String, repo: String)] = [:] for project in projects { guard let slug = project.gitHubRepo else { continue } let parts = slug.split(separator: "/", maxSplits: 1).map(String.init) guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { continue } + repoByProject[project.id] = (parts[0], parts[1]) guard let branch = await GitHelper.currentBranch(at: project.path) else { continue } pairs.append((project, CIStatusQuery(owner: parts[0], repo: parts[1], branch: branch))) } - guard !pairs.isEmpty else { - if !ciStatusByProject.isEmpty { + // Query each project's current branch (drives the per-project map + CI + // failure handling) AND every branch shown on briefing cards (so each + // card can show PR / merge status). De-dup all queries by owner/repo#branch. + var queriesByKey: [String: CIStatusQuery] = [:] + for pair in pairs { + queriesByKey[Self.ciKey(owner: pair.query.owner, repo: pair.query.repo, branch: pair.query.branch)] = pair.query + } + for item in threadStore.allBranchBriefingItems() { + guard let slug = repoByProject[item.projectId] else { continue } + let key = Self.ciKey(owner: slug.owner, repo: slug.repo, branch: item.branch) + queriesByKey[key] = CIStatusQuery(owner: slug.owner, repo: slug.repo, branch: item.branch) + } + + guard !queriesByKey.isEmpty else { + if !ciStatusByProject.isEmpty || !ciStatusByBranchKey.isEmpty { ciStatusByProject = [:] + ciStatusByBranchKey = [:] ciStatusRevision &+= 1 } return @@ -82,7 +98,7 @@ extension AppState { let response: CIStatusBatchResponse do { - response = try await autopilot.batchCIStatus(pairs.map(\.query)) + response = try await autopilot.batchCIStatus(Array(queriesByKey.values)) } catch { logger.error("CI status fetch failed: \(error.localizedDescription)") return @@ -106,13 +122,23 @@ extension AppState { await handleCITransition(for: pair.project, status: status) } - // Drop status for projects no longer queried (e.g. removed). - if next != ciStatusByProject { + // Drop status for projects/branches no longer queried (e.g. removed). + if next != ciStatusByProject || byKey != ciStatusByBranchKey { ciStatusByProject = next + ciStatusByBranchKey = byKey ciStatusRevision &+= 1 } } + /// CI/PR status for an arbitrary project + branch (any branch, not just the + /// current one), looked up from the branch-keyed map the poller maintains. + func ciStatus(forProjectId projectId: UUID, branch: String) -> ProjectCIStatus? { + guard let slug = projects.first(where: { $0.id == projectId })?.gitHubRepo else { return nil } + let parts = slug.split(separator: "/", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return nil } + return ciStatusByBranchKey[Self.ciKey(owner: parts[0], repo: parts[1], branch: branch)] + } + // MARK: - Failure handling /// Notify (always) and, when enabled, silently spawn a fix thread — but only diff --git a/RxCode/App/AppState+CIUpdates.swift b/RxCode/App/AppState+CIUpdates.swift new file mode 100644 index 0000000..12cbbae --- /dev/null +++ b/RxCode/App/AppState+CIUpdates.swift @@ -0,0 +1,34 @@ +#if os(macOS) +import Foundation +import RxCodeCore + +/// High-level CI auto-update intents: batch status refresh + per-project +/// lookups used by the setup banner. Views/hooks call these rather than reaching +/// into `ciUpdates` directly. Mirrors `AppState+Secrets`'s status helpers. +extension AppState { + + /// Refreshes `ciStatusByRepo` for every open project's GitHub repo so the + /// CI auto-update hook can gate its banner. Leaves the previous cache + /// untouched on transient errors. + func refreshCIStatuses() async { + guard isSignedIn else { ciStatusByRepo = [:]; return } + let repos = Array(Set(projects.compactMap(\.gitHubRepo))) + guard !repos.isEmpty else { ciStatusByRepo = [:]; return } + do { + let statuses = try await ciUpdates.statuses(forRepos: repos) + ciStatusByRepo = Dictionary( + statuses.map { ($0.repositoryFullName.lowercased(), $0) }, + uniquingKeysWith: { _, last in last } + ) + } catch { + // Keep the prior cache; a failed poll shouldn't flap the banner. + } + } + + /// Whether the project's linked repo is configured for CI auto-updates. + func projectHasCIUpdates(_ project: Project) -> Bool { + guard let repo = project.gitHubRepo else { return false } + return ciStatusByRepo[repo.lowercased()]?.isWatched ?? false + } +} +#endif diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index c2328e1..9abb39a 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -211,6 +211,22 @@ extension AppState { logger.info("Pruned orphan briefing metadata summaries=\(prunedBriefingMetadata.threadSummaries) briefings=\(prunedBriefingMetadata.branchBriefings)") } + // Purge threads + search chunks left behind by projects that were + // deleted before the cascade in `deleteProject` existed (or by any + // leak). This clears them from history and the search source so they + // never resurface as "Unknown project" results. Runs before we load + // the sidebar summaries below so the loaded list already excludes them. + let knownProjectIds = Set(projects.map(\.id)) + let prunedOrphanThreads = threadStore.pruneOrphanThreads(excludingProjectIds: knownProjectIds) + if prunedOrphanThreads > 0 { + logger.info("Pruned \(prunedOrphanThreads) orphan thread(s) from deleted projects") + } + // Keep the in-memory search index consistent too (disk is already clean + // above). Detached so a fresh boot isn't blocked on the embedding actor. + Task.detached(priority: .utility) { [searchService] in + await searchService.pruneOrphans(knownProjectIds: knownProjectIds) + } + // Sidebar threads are now sourced from the local SwiftData store. // CLI session files are no longer surfaced in the sidebar list — the // CLI is still the transcript backend (replay on thread open), but diff --git a/RxCode/App/AppState+PullRequest.swift b/RxCode/App/AppState+PullRequest.swift new file mode 100644 index 0000000..abc0878 --- /dev/null +++ b/RxCode/App/AppState+PullRequest.swift @@ -0,0 +1,166 @@ +import Foundation +import RxCodeCore + +/// Errors surfaced while opening a pull request from a briefing card. +enum PullRequestError: LocalizedError { + case noGitHubRepo + case pushFailed(String) + case createFailed(String) + + var errorDescription: String? { + switch self { + case .noGitHubRepo: + return "This project isn't linked to a GitHub repository." + case .pushFailed(let message): + return "Couldn't push the branch to GitHub.\n\n\(message)" + case .createFailed(let message): + return "Couldn't create the pull request.\n\n\(message)" + } + } +} + +extension AppState { + + // MARK: - Create PR + + /// Open a pull request for `branch` of `project`: push the branch, generate a + /// Conventional-Commit title + markdown body from the branch briefing, and + /// ask autopilot to open the PR (base = repo default branch, resolved + /// server-side). Refreshes CI/PR status on success and returns the PR URL. + func createPullRequestForBranch(project: Project, branch: String) async throws -> URL { + guard let slug = project.gitHubRepo else { throw PullRequestError.noGitHubRepo } + let parts = slug.split(separator: "/", maxSplits: 1).map(String.init) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw PullRequestError.noGitHubRepo + } + let owner = parts[0] + let repo = parts[1] + + // 1. Publish the branch. `-u origin ` is idempotent: it pushes any + // new commits and sets the upstream, and is a no-op when up to date. + if let pushError = await GitHelper.push( + at: project.path, + remote: "origin", + branch: branch, + setUpstream: true + ) { + throw PullRequestError.pushFailed(pushError) + } + + // 2. Generate the title + body from the branch briefing. + let briefing = threadStore.allBranchBriefingItems() + .first(where: { $0.projectId == project.id && $0.branch == branch })? + .briefing ?? "" + let raw = await generatePullRequestContent(briefing: briefing, branch: branch) + let (title, body) = Self.parsePullRequestContent(raw, branch: branch) + + // 3. Open the PR via autopilot. + let response: CreatePullRequestResponse + do { + response = try await autopilot.createPullRequest( + CreatePullRequestRequest( + owner: owner, + repo: repo, + head: branch, + base: nil, + title: title, + body: body.isEmpty ? nil : body + ) + ) + } catch { + throw PullRequestError.createFailed(error.localizedDescription) + } + + // 4. Refresh so the card flips from "Create PR" to the PR chip. + await refreshCIStatusOnce() + + guard let url = URL(string: response.prUrl) else { + throw PullRequestError.createFailed("The server returned an invalid PR URL.") + } + return url + } + + // MARK: - Title / body generation + + /// Generate raw PR text (title on the first line, blank line, then a markdown + /// body) from a branch briefing. Routes through the configured + /// `summarizationProvider`, mirroring `generateCommitMessage`. + func generatePullRequestContent(briefing: String, branch: String) async -> String? { + switch summarizationProvider { + case .appleFoundationModel: + return await foundationModelSummarization.generatePullRequestContent( + briefing: briefing, + branch: branch + ) + case .openAI: + if openAISummarizationModel.isEmpty { + if FoundationModelSummarizationService.isAvailable { + return await foundationModelSummarization.generatePullRequestContent( + briefing: briefing, + branch: branch + ) + } + return nil + } + return await openAISummarization.generatePullRequestContent( + briefing: briefing, + branch: branch, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .selectedClient: + if FoundationModelSummarizationService.isAvailable { + return await foundationModelSummarization.generatePullRequestContent( + briefing: briefing, + branch: branch + ) + } + return await claude.generatePullRequestContent(briefing: briefing, branch: branch) + } + } + + /// Split generated PR text into a Conventional-Commit title and a markdown + /// body. Tolerant of code fences, heading markers, surrounding quotes, and a + /// stray `Title:` prefix. Falls back to a safe title when generation failed. + static func parsePullRequestContent(_ raw: String?, branch: String) -> (title: String, body: String) { + let fallbackTitle = "chore: update \(branch)" + guard var text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return (fallbackTitle, "") + } + + // Unwrap a fenced block if the model wrapped the whole output. + if text.hasPrefix("```") { + var fenced = text.components(separatedBy: "\n") + fenced.removeFirst() + if let last = fenced.last, last.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + fenced.removeLast() + } + text = fenced.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + let lines = text.components(separatedBy: "\n") + guard let firstIdx = lines.firstIndex(where: { + !$0.trimmingCharacters(in: .whitespaces).isEmpty + }) else { + return (fallbackTitle, "") + } + + var title = lines[firstIdx].trimmingCharacters(in: .whitespaces) + title = title.replacingOccurrences(of: "^#+\\s*", with: "", options: .regularExpression) + if title.lowercased().hasPrefix("title:") { + title = String(title.dropFirst("title:".count)) + } + // Strip Markdown emphasis (e.g. "**feat: …**") so the PR title isn't + // created with literal asterisks/backticks; titles render as plain text. + title = ChatSession.stripMarkdownEmphasis(from: title) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + .trimmingCharacters(in: .whitespaces) + if title.isEmpty { title = fallbackTitle } + + let body = lines[(firstIdx + 1)...] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return (title, body) + } +} diff --git a/RxCode/App/AppState+Release.swift b/RxCode/App/AppState+Release.swift new file mode 100644 index 0000000..580cb9c --- /dev/null +++ b/RxCode/App/AppState+Release.swift @@ -0,0 +1,51 @@ +#if os(macOS) +import Foundation +import RxCodeCore + +/// High-level release intents layered over `ReleaseService`. Views and the +/// release hook call these; they never build requests directly. +extension AppState { + + // MARK: - Status (batch) + + /// Refreshes `releaseStatusByRepo` for every open project's GitHub repo so + /// the release hook can decide whether to surface the "set up release" + /// banner and the sidebar / briefing can gate the "Create Release" action. + /// Leaves the previous cache untouched on transient errors. + func refreshReleaseStatuses() async { + guard isSignedIn else { releaseStatusByRepo = [:]; return } + let repos = Array(Set(projects.compactMap(\.gitHubRepo))) + guard !repos.isEmpty else { releaseStatusByRepo = [:]; return } + do { + let statuses = try await release.statuses(forRepos: repos) + releaseStatusByRepo = Dictionary( + statuses.map { ($0.fullName.lowercased(), $0) }, + uniquingKeysWith: { _, last in last } + ) + } catch { + // Keep the prior cache; a failed poll shouldn't flip the banner. + } + } + + /// Whether the project's linked repo has a release workflow set up + /// (registered + a selected dispatchable workflow). Gates the "Create + /// Release" affordances. + func projectHasReleaseWorkflow(_ project: Project) -> Bool { + guard let repo = project.gitHubRepo else { return false } + return releaseStatusByRepo[repo.lowercased()]?.hasReleaseWorkflow ?? false + } + + /// Whether the project's linked repo is registered with the release service. + /// Returns `nil` when we have no status for the repo yet (treat as unknown). + func projectReleaseConfigured(_ repoFullName: String) -> Bool? { + releaseStatusByRepo[repoFullName.lowercased()]?.isManaged + } + + /// The latest released version (tag) for the project's repo, if any. Drives + /// the briefing card version chip. + func projectLatestReleaseVersion(_ project: Project) -> String? { + guard let repo = project.gitHubRepo else { return nil } + return releaseStatusByRepo[repo.lowercased()]?.latestVersion + } +} +#endif diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift index 7c3fff6..cf23ff1 100644 --- a/RxCode/App/AppState+SessionLifecycle.swift +++ b/RxCode/App/AppState+SessionLifecycle.swift @@ -65,6 +65,19 @@ extension AppState { return current } + /// True when `sessionKey` is currently tracked as an in-flight setup chat of + /// `kind` (see `HookSetupKind`). Resolves CLI session-id renames so a thread + /// that has rotated `pending-… → real` still matches. This is the single + /// source of truth shared by the hook controller (re-check on session end) + /// and the setup banners (which disable their "Set up" action while you're + /// already inside that setup thread — clicking again would spawn a duplicate + /// setup chat). + func isSetupSession(kind: String, sessionKey: String) -> Bool { + guard let keys = setupSessionKeys[kind], !keys.isEmpty else { return false } + let target = resolveCurrentSessionId(sessionKey) + return keys.contains { resolveCurrentSessionId($0) == target } + } + /// Spawn a one-shot summarization call to generate a 3–6 word title for the given /// session, then persist it via `renameSession` if the title is still the placeholder. /// No-op if the session was already renamed manually or the LLM call fails. diff --git a/RxCode/App/AppState+Worktree.swift b/RxCode/App/AppState+Worktree.swift index 71612ef..abf2df5 100644 --- a/RxCode/App/AppState+Worktree.swift +++ b/RxCode/App/AppState+Worktree.swift @@ -186,6 +186,40 @@ extension AppState { } } + /// Create a new branch in the project root and check it out (no worktree). + /// The chat keeps running in the project root, so the worktree pointer is + /// cleared — subsequent CLI invocations for this session use the main repo. + func createBranchInPlace(branch: String, in window: WindowState) async throws { + guard let project = window.selectedProject else { + throw AppError.noProjectSelected + } + if let err = await GitHelper.createBranch(branch, at: project.path) { + throw GitWorktreeService.WorktreeError.gitFailed(err) + } + + // New-chat view: no session yet. Clear any parked worktree so the chat + // runs in the project root once sendPrompt allocates a session id. + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil + return + } + + sessionStates[sessionId, default: SessionStreamState()].worktreePath = nil + sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = nil + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = nil + allSessionSummaries[idx].worktreeBranch = nil + threadStore.upsert(allSessionSummaries[idx]) + } + if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { + await updateSessionMetadata(snap.makeSession()) { s in + s.worktreePath = nil + s.worktreeBranch = nil + } + } + } + /// Mobile new-thread flow: spawn the next thread on `branch` via a Git /// worktree, not by mutating the main project repo. Reuses an existing /// linked worktree for the branch when one is around; otherwise creates a diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index ba9068e..6dfdb4f 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -431,6 +431,12 @@ final class AppState { /// `AppState+CIStatus.swift`. Held in memory only. var ciStatusByProject: [UUID: ProjectCIStatus] = [:] + /// Latest CI/PR status keyed by `owner/repo#branch` (lowercased), covering + /// every branch shown on briefing cards — not just current branches. Drives + /// the per-card PR / merge status and the Create PR button. Refreshed by the + /// same poller. Held in memory only. + var ciStatusByBranchKey: [String: ProjectCIStatus] = [:] + /// Bumped after every CI refresh so views observing CI status recompute, /// mirroring the `branchBriefingRevision` pattern. var ciStatusRevision: Int = 0 @@ -443,11 +449,21 @@ final class AppState { /// Secret" affordance so it only shows where secrets exist. Memory only. var secretsStatusByRepo: [String: SecretsRepoStatus] = [:] + /// Latest CI auto-update status keyed by lowercased `owner/repo`, refreshed + /// in `AppState+CIUpdates.swift`. Gates the "set up CI auto-update" banner so + /// it only shows for repos that aren't watched yet. Memory only. + var ciStatusByRepo: [String: CIWatchStatus] = [:] + /// Latest docs status keyed by lowercased `owner/repo`, refreshed in /// `AppState+Docs.swift`. Lets the docs hook decide whether to surface the /// "set up docs" banner without a per-chat round trip. Memory only. var docsStatusByRepo: [String: DocsRepoStatus] = [:] + /// Latest release status keyed by lowercased `owner/repo`, refreshed in + /// `AppState+Release.swift`. Drives the "set up release" banner and the + /// "Create Release" affordances + briefing version chip. Memory only. + var releaseStatusByRepo: [String: ReleaseRepoStatus] = [:] + /// Per-project signature of the last CI failure we already notified / auto-fixed, /// so a steady-state red branch doesn't re-fire every 30s. Keyed by project id; /// persisted to UserDefaults so a fix isn't re-triggered across relaunches. @@ -923,6 +939,10 @@ final class AppState { /// Non-nil while the secret-setup form should be presented (e.g. opened from /// the autopilot `.env` banner's deep link). var secretsSetupRequest: SecretsSetupRequest? + /// Non-nil while the CI auto-update manage sheet should be presented, pinned + /// to a repo (opened from the CI setup banner's deep link). `MainView` + /// consumes it to present the sheet pre-targeted at that repo. + var ciSetupRequest: CISetupRequest? /// Non-nil while a docs-setup new chat should be started (opened from the /// docs banner's deep link). `MainView` consumes it to start a fresh chat /// seeded with the docs-publishing skill. @@ -931,14 +951,31 @@ final class AppState { /// `DocsHook.onSessionStart` injects the docs skill into exactly that chat's /// system prompt, then clears it. @ObservationIgnored var pendingDocsSetupProjectId: UUID? + /// Non-nil while a release-setup new chat should be started (opened from the + /// release banner's deep link). `MainView` consumes it to start a fresh chat + /// seeded with the release skill. + var releaseSetupRequest: ReleaseSetupRequest? + /// One-shot: when a release-setup chat is kicked off, this holds the project + /// so `ReleaseHook.onSessionStart` injects the release skill into exactly + /// that chat's system prompt, then clears it. + @ObservationIgnored var pendingReleaseSetupProjectId: UUID? + /// Session keys of in-flight setup chats, keyed by setup kind ("release", + /// "docs"). Recorded when a setup hook injects its skill on session start and + /// cleared once the backing status confirms setup, so the hook can re-check on + /// each completed turn and drop the banner once setup completes — surviving + /// multi-turn setups (where the agent asks a question before doing the work). + @ObservationIgnored var setupSessionKeys: [String: Set] = [:] // MARK: - Services let rxAuth = RxAuthService.shared let autopilot: AutopilotService let secrets: SecretsService + let ciUpdates: CIUpdateService /// Talks to github-pm's docs API (search, repos, documents, upload tokens). let docs: DocsService + /// Talks to github-pm's release API (repos, workflows, dispatch, secret). + let release: ReleaseService /// Passkey-derived KEK cache for the secrets feature (macOS only). let secretsKeyVault = SecretsKeyVault() /// Cached enrollment status for the secrets feature: `nil` = unknown. @@ -1088,7 +1125,9 @@ final class AppState { self.threadStore = ThreadStore.make() self.autopilot = AutopilotService(rxAuth: RxAuthService.shared) self.secrets = SecretsService(rxAuth: RxAuthService.shared) + self.ciUpdates = CIUpdateService(rxAuth: RxAuthService.shared) self.docs = DocsService(rxAuth: RxAuthService.shared) + self.release = ReleaseService(rxAuth: RxAuthService.shared) self.runService.onTasksChanged = { [weak self] in Task { @MainActor [weak self] in self?.broadcastMobileRunTasks() @@ -1151,6 +1190,8 @@ final class AppState { #if os(macOS) hookManager.register(AutopilotHook()) hookManager.register(DocsHook()) + hookManager.register(ReleaseHook()) + hookManager.register(CIUpdateHook()) #endif } diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 2410350..72b5047 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -608,10 +608,18 @@ struct MainWindowRoot: View { appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) return .handled } + if let release = ReleaseDeepLink.parse(url), release.action == .setup { + appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) + return .handled + } if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request return .handled } + if let request = CIUpdateDeepLink.parse(url) { + appState.ciSetupRequest = request + return .handled + } return openMarkdownLink(url, in: windowState) }) .transition(.opacity) @@ -623,8 +631,12 @@ struct MainWindowRoot: View { .onOpenURL { url in if let docs = DocsDeepLink.parse(url), docs.action == .setup { appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) + } else if let release = ReleaseDeepLink.parse(url), release.action == .setup { + appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) } else if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request + } else if let request = CIUpdateDeepLink.parse(url) { + appState.ciSetupRequest = request } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) @@ -693,10 +705,18 @@ struct ProjectWindowRoot: View { appState.docsSetupRequest = DocsSetupRequest(repoFullName: docs.repoFullName) return .handled } + if let release = ReleaseDeepLink.parse(url), release.action == .setup { + appState.releaseSetupRequest = ReleaseSetupRequest(repoFullName: release.repoFullName) + return .handled + } if let request = SecretsDeepLink.parse(url) { appState.secretsSetupRequest = request return .handled } + if let request = CIUpdateDeepLink.parse(url) { + appState.ciSetupRequest = request + return .handled + } return openMarkdownLink(url, in: windowState) }) .transition(.opacity) diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 488f642..f811344 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -3,6 +3,12 @@ "strings" : { "" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -14,6 +20,12 @@ " (copy)" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : " (사본)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -40,6 +52,12 @@ }, "—" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -50,6 +68,12 @@ }, ".env" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : ".env" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -60,6 +84,12 @@ }, "·" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "·" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -70,6 +100,12 @@ }, "· %lld" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "· %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -96,6 +132,12 @@ }, "\"%@\" will be deleted. This action cannot be undone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"이(가) 삭제됩니다. 이 작업은 취소할 수 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -106,6 +148,12 @@ }, "(build only)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(빌드만)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -117,6 +165,12 @@ "@%@" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "@%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -125,6 +179,22 @@ } } }, + "#%lld" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%lld" + } + } + } + }, "%@ — %@" : { "comment" : "Notification body for MCP disconnect: server name and error detail.", "localizations" : { @@ -134,6 +204,12 @@ "value" : "%1$@ — %2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ — %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -150,6 +226,12 @@ "value" : "%1$@ · %2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -158,6 +240,28 @@ } } }, + "%@ · %@ · %lld rule%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@ · %3$lld rule%4$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · 규칙 %lld개%@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld 条规则%@" + } + } + } + }, "%@ installed%@" : { "extractionState" : "stale", "localizations" : { @@ -167,6 +271,12 @@ "value" : "%1$@ installed%2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 설치됨%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -177,6 +287,12 @@ }, "%@ is already running" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@이(가) 이미 실행 중입니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -204,6 +320,12 @@ "%@ not found" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 찾을 수 없습니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -214,6 +336,12 @@ }, "%@ Usage" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 사용량" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -224,6 +352,12 @@ }, "%@ wants to pair" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@이(가) 페어링하려고 합니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -233,10 +367,29 @@ } }, "%@ will be removed from the docs index. This can't be undone." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@이(가) 문서 색인에서 제거됩니다. 이 작업은 취소할 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 将从文档索引中移除。此操作无法撤销。" + } + } + } }, "%@, in progress" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, 진행 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -247,6 +400,12 @@ }, "%@%%" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -280,6 +439,12 @@ }, "%lld" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -290,6 +455,12 @@ }, "%lld agents available" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 가능한 에이전트 %lld개" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -332,6 +503,12 @@ }, "%lld changed" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 변경됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -348,6 +525,12 @@ "value" : "%1$lld custom Git source%2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 Git 소스 %lld개%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -364,6 +547,12 @@ "value" : "%1$lld day%2$@ after archiving" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관 후 %lld일%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -380,6 +569,12 @@ "value" : "%1$lld day%2$@ of inactivity" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성 %lld일%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -428,6 +623,12 @@ }, "%lld files" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 파일" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -444,6 +645,12 @@ "value" : "%1$lld of %2$lld agents" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld / %lld 에이전트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -452,6 +659,28 @@ } } }, + "%lld workflows · %lld actions · %lld outdated" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld workflows · %2$lld actions · %3$lld outdated" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 %lld개 · 액션 %lld개 · 오래됨 %lld개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个工作流 · %lld 个动作 · %lld 个过期" + } + } + } + }, "%lld/%lld" : { "localizations" : { "en" : { @@ -460,6 +689,12 @@ "value" : "%1$lld/%2$lld" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld/%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -470,6 +705,12 @@ }, "%lld%%" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -480,6 +721,12 @@ }, "•" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -490,6 +737,12 @@ }, "• %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -500,6 +753,12 @@ }, "• last seen %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 마지막 접속 %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -510,6 +769,12 @@ }, "^[%lld hook](inflect: true)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook %lld개" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -542,6 +807,12 @@ }, "+%lld" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -552,6 +823,12 @@ }, "+%lld more pending" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 더 대기 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -562,6 +839,12 @@ }, "=" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "=" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -572,6 +855,12 @@ }, "−%lld" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "−%lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -582,6 +871,12 @@ }, "5h" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5시간" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -609,6 +904,12 @@ }, "Absolute or project-relative path. Leave empty to use the project root." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절대 경로 또는 프로젝트 기준 상대 경로. 비워 두면 프로젝트 루트를 사용합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -619,6 +920,12 @@ }, "Accept" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수락" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -629,6 +936,12 @@ }, "ACP Clients" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 클라이언트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -639,6 +952,12 @@ }, "ACP error" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 오류" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -649,6 +968,12 @@ }, "ACP page" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 페이지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -659,6 +984,12 @@ }, "Action" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -669,6 +1000,12 @@ }, "Actions for %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 작업" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -702,6 +1039,12 @@ "Add a Git repository by URL" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL로 Git 저장소 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -712,6 +1055,12 @@ }, "Add a preset (e.g. \"dev\", \"prod\") to configure environment variables." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경 변수를 구성할 프리셋(예: \"dev\", \"prod\")을 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -722,6 +1071,12 @@ }, "Add a project to configure hooks." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook을 구성하려면 프로젝트를 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -732,6 +1087,12 @@ }, "Add a relay server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -743,6 +1104,12 @@ "Add a relay server in Settings → Mobile before pairing." : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페어링하기 전에 설정 → 모바일에서 릴레이 서버를 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -753,6 +1120,12 @@ }, "Add a relay server to pair your phone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "휴대폰을 페어링하려면 릴레이 서버를 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -763,6 +1136,12 @@ }, "Add a skill catalog from a GitHub repository" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 저장소에서 스킬 카탈로그 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -773,6 +1152,12 @@ }, "Add an Agent Client Protocol client" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent Client Protocol 클라이언트 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -782,13 +1167,45 @@ } }, "Add Document" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文档" + } + } + } }, "Add Documentation Repository" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 저장소 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文档仓库" + } + } + } }, "Add Git Skill Source" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 스킬 소스 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -799,6 +1216,12 @@ }, "Add Git Source" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 소스 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -809,6 +1232,12 @@ }, "Add Hook" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -819,6 +1248,12 @@ }, "Add MCP Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 서버 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -829,6 +1264,12 @@ }, "Add memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -839,6 +1280,12 @@ }, "Add Memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -871,6 +1318,12 @@ }, "Add Preset" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -881,6 +1334,12 @@ }, "Add Profile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로필 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -913,6 +1372,12 @@ }, "Add Relay Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -921,8 +1386,46 @@ } } }, + "Add Release Repository" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 저장소 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加发布仓库" + } + } + } + }, + "Add Repo" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加仓库" + } + } + } + }, "Add Repository" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -949,6 +1452,12 @@ }, "Add server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -959,6 +1468,12 @@ }, "Add Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -969,6 +1484,12 @@ }, "Add Source" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "소스 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1001,6 +1522,12 @@ }, "Add tools with MCP" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP로 도구 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1011,6 +1538,12 @@ }, "Add Variable" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변수 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1044,6 +1577,12 @@ "After Session Stop" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 종료 후" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1054,6 +1593,12 @@ }, "Agent Availability" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 사용 가능 여부" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1064,6 +1609,12 @@ }, "Agent CLI Setup" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 CLI 설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1074,6 +1625,12 @@ }, "Agent Runtimes" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 런타임" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1084,6 +1641,12 @@ }, "All archived chats in the current project will be deleted. This action cannot be undone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 프로젝트의 보관된 채팅이 모두 삭제됩니다. 이 작업은 취소할 수 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1094,6 +1657,12 @@ }, "All archived chats will be deleted. This action cannot be undone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관된 채팅이 모두 삭제됩니다. 이 작업은 취소할 수 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1104,6 +1673,12 @@ }, "All branches" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 브랜치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1114,6 +1689,12 @@ }, "All Chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 채팅" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1124,6 +1705,12 @@ }, "All saved memories will be removed. This action cannot be undone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 메모리가 모두 제거됩니다. 이 작업은 취소할 수 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1194,10 +1781,16 @@ }, "All sync traffic flows through relay servers, end-to-end encrypted. Add relay servers to connect with your mobile devices remotely." : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "所有同步流量都会通过中继服务器,并进行端到端加密。添加中继服务器即可远程连接移动设备。" + "value" : "모든 동기화 트래픽은 종단 간 암호화되어 릴레이 서버를 통해 전달됩니다. 모바일 기기와 원격으로 연결하려면 릴레이 서버를 추가하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有同步流量都会通过中继服务器,并进行端到端加密。添加中继服务器即可远程连接移动设备。" } } } @@ -1270,6 +1863,12 @@ }, "API Key" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "API 키" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1280,6 +1879,12 @@ }, "API_KEY" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "API_KEY" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1290,6 +1895,12 @@ }, "Apple Foundation Model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Foundation Model" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1298,8 +1909,30 @@ } } }, + "Applied automatically to newly created repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로 생성된 저장소에 자동으로 적용됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动应用于新创建的仓库。" + } + } + } + }, "Apply" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "적용" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1310,6 +1943,12 @@ }, "Approve risky actions with context" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "맥락과 함께 위험한 작업 승인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1321,6 +1960,12 @@ "Approve to run %@" : { "comment" : "Notification body when Claude queues a tool approval. %@ is the tool name.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 실행 승인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1331,6 +1976,12 @@ }, "Archive" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1341,6 +1992,12 @@ }, "Archive after" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관 기준 기간" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1367,6 +2024,12 @@ }, "archived" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1377,6 +2040,12 @@ }, "Archived" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1387,6 +2056,12 @@ }, "Args (one per line)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인수(한 줄에 하나씩)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1397,6 +2072,12 @@ }, "Arguments (optional)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인수(선택 사항)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1407,6 +2088,12 @@ }, "Assistant" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어시스턴트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1417,6 +2104,12 @@ }, "Assistant has a question" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어시스턴트가 질문이 있습니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1428,6 +2121,12 @@ "Assistant has a question%@" : { "comment" : "Notification title when Claude invokes AskUserQuestion. %@ is replaced with \" — \" or empty string.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어시스턴트가 질문이 있습니다%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1439,6 +2138,12 @@ "Assistant reply" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어시스턴트 응답" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1539,6 +2244,12 @@ }, "Auto-archive inactive chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성 채팅 자동 보관" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1549,6 +2260,12 @@ }, "Auto-create memories from completed chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료된 채팅에서 메모리 자동 생성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1559,6 +2276,12 @@ }, "Auto-delete" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1569,6 +2292,12 @@ }, "Auto-delete archived chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관된 채팅 자동 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1579,6 +2308,12 @@ }, "Auto-detected" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동 감지됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1647,6 +2382,38 @@ } } }, + "Automation" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동화" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动化" + } + } + } + }, + "Automation Settings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동화 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动化设置" + } + } + } + }, "Autopilot" : { "localizations" : { "ko" : { @@ -1679,8 +2446,46 @@ } } }, + "Autopilot watches your CI, spots failures, and opens fix pull requests." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot이 CI를 감시하여 실패를 발견하고 수정 풀 리퀘스트를 엽니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 监控你的 CI,发现失败并自动创建修复拉取请求。" + } + } + } + }, + "Autopilot will no longer scan this repo or open CI update PRs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot이 더 이상 이 저장소를 스캔하거나 CI 업데이트 PR을 열지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 将不再扫描此仓库或创建 CI 更新 PR。" + } + } + } + }, "Awaiting check" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인 대기 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1729,6 +2534,12 @@ }, "Bash" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bash" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1740,6 +2551,12 @@ "Before Session Start" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 시작 전" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1751,6 +2568,12 @@ "Before Session Stop" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 종료 전" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1761,6 +2584,12 @@ }, "Binary found: %@, but version check failed" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "바이너리를 찾았습니다: %@, 그러나 버전 확인에 실패했습니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1769,8 +2598,30 @@ } } }, + "Branch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分支" + } + } + } + }, "Branch name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1781,6 +2632,12 @@ }, "Branch or Ref" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 또는 Ref" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1791,6 +2648,12 @@ }, "Briefing" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1801,6 +2664,12 @@ }, "Briefing rolls recent thread summaries into a branch-focused project update." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑은 최근 스레드 요약을 브랜치 중심의 프로젝트 업데이트로 정리합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1811,6 +2680,12 @@ }, "Briefings" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1821,6 +2696,12 @@ }, "Browse…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "찾아보기…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1831,6 +2712,12 @@ }, "build" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빌드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1841,6 +2728,12 @@ }, "Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빌드는 `xcodebuild build`를 실행합니다. 빌드를 실행한 다음 생성된 .app(macOS)을 실행하거나 선택한 시뮬레이터에 설치 후 실행합니다. 대상은 실행 도구 모음에서 선택하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1917,6 +2810,12 @@ }, "Check the scheme and container path." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스킴과 컨테이너 경로를 확인하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1981,6 +2880,12 @@ }, "Choose one" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하나 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -1991,6 +2896,12 @@ }, "Choose the agent for this thread" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 스레드의 에이전트를 선택하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2001,6 +2912,12 @@ }, "Choose which branches to show briefings for." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑을 표시할 브랜치를 선택하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2011,6 +2928,12 @@ }, "Choose…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2019,6 +2942,38 @@ } } }, + "CI Auto-Update" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 자동 업데이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 自动更新" + } + } + } + }, + "CI auto-update scan" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 자동 업데이트 스캔" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 自动更新扫描" + } + } + } + }, "CI failed%@" : { "comment" : "Notification title when GitHub Actions CI fails on a project's current branch. %@ is replaced with \" — \" or empty string.", "localizations" : { @@ -2109,6 +3064,12 @@ }, "Clear destination — pick again from the toolbar" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상 지우기 — 도구 모음에서 다시 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2119,6 +3080,12 @@ }, "Clear task" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 지우기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2129,6 +3096,12 @@ }, "Click + in the toolbar to add one." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "도구 모음의 +를 클릭하여 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2139,6 +3112,12 @@ }, "Click + to add one." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "+를 클릭하여 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2149,6 +3128,12 @@ }, "Click to select · Command-right-click to add or remove · Right-click for Show Diff" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클릭하여 선택 · Command-우클릭으로 추가/제거 · 우클릭으로 차이 보기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2159,6 +3144,12 @@ }, "Client" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클라이언트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2170,6 +3161,12 @@ "Clone" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클론" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2181,6 +3178,12 @@ "Clone & Add" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클론 후 추가" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2213,6 +3216,12 @@ }, "Close — you can answer later from the queue" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기 — 나중에 대기열에서 답변할 수 있습니다" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2221,8 +3230,30 @@ } } }, + "Close PR" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR 닫기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭 PR" + } + } + } + }, "Close Terminal" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 닫기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2233,6 +3264,12 @@ }, "Collapse chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅 접기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2243,6 +3280,12 @@ }, "command" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2275,6 +3318,12 @@ }, "Commands" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2285,6 +3334,12 @@ }, "Commit %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 커밋" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2295,6 +3350,12 @@ }, "Commit message" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커밋 메시지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2305,6 +3366,12 @@ }, "Configuration" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2315,6 +3382,12 @@ }, "Configure Model Context Protocol servers used by Claude Code and Codex." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claude Code와 Codex가 사용하는 Model Context Protocol 서버를 구성합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2346,8 +3419,30 @@ } } }, + "Configure what autopilot does automatically on your repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot이 저장소에서 자동으로 수행하는 작업을 구성합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置 Autopilot 在你的仓库上自动执行的操作。" + } + } + } + }, "Connect any MCP server to give every agent extra tools. You can also skip this and add servers later in Settings → MCP." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 서버를 연결하여 모든 에이전트에 추가 도구를 제공하세요. 이 단계를 건너뛰고 나중에 설정 → MCP에서 서버를 추가할 수도 있습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2358,6 +3453,12 @@ }, "Connect at least one agent CLI" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 CLI를 하나 이상 연결하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2437,6 +3538,12 @@ }, "Connect the mobile companion" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모바일 동반 앱 연결" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2447,6 +3554,12 @@ }, "Connect to a relay server before pairing a device." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기를 페어링하기 전에 릴레이 서버에 연결하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2457,6 +3570,12 @@ }, "Connect to relay servers to sync with your mobile devices remotely. You can add multiple relays and connect to all of them simultaneously." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버에 연결하여 모바일 기기와 원격으로 동기화하세요. 여러 릴레이를 추가하고 모두 동시에 연결할 수 있습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2521,6 +3640,12 @@ }, "Connecting…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2532,6 +3657,12 @@ "connection lost" : { "comment" : "Fallback MCP disconnect detail when no error message is available.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 끊김" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2542,6 +3673,12 @@ }, "Context memories: %lld" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컨텍스트 메모리: %lld" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2552,6 +3689,12 @@ }, "Continue" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계속" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2560,6 +3703,22 @@ } } }, + "Control what autopilot does automatically — issue labeling, PR validation and linking, project field population, and more." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot이 자동으로 수행하는 작업을 제어하세요 — 이슈 라벨 지정, PR 검증 및 연결, 프로젝트 필드 채우기 등." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制 Autopilot 自动执行的操作 — 议题标记、PR 校验与关联、项目字段填充等。" + } + } + } + }, "Copied" : { "localizations" : { "en" : { @@ -2606,6 +3765,12 @@ }, "Copy briefing text" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑 텍스트 복사" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2639,6 +3804,12 @@ }, "Copy install command" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치 명령 복사" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2649,6 +3820,12 @@ }, "Could not load registry. Check your network connection." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레지스트리를 불러올 수 없습니다. 네트워크 연결을 확인하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2659,6 +3836,12 @@ }, "Could not start pairing. Pick a different relay server or add a new one." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페어링을 시작할 수 없습니다. 다른 릴레이 서버를 선택하거나 새로 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2667,35 +3850,64 @@ } } }, - "Create" : { + "Couldn't Create Pull Request" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "생성" + "value" : "풀 리퀘스트를 생성할 수 없습니다" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "创建" + "value" : "无法创建拉取请求" } } } }, - "Create and checkout" : { + "Create" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "创建并检出" + "value" : "创建" } } } }, - "Create and checkout branch" : { + "Create and checkout" : { "localizations" : { - "zh-Hans" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성 후 체크아웃" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建并检出" + } + } + } + }, + "Create and checkout branch" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 생성 후 체크아웃" + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "创建并检出分支" @@ -2703,8 +3915,30 @@ } } }, + "Create branch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建分支" + } + } + } + }, "Create new branch…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 브랜치 생성…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2713,6 +3947,102 @@ } } }, + "Create PR" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建 PR" + } + } + } + }, + "Create Release" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建发布" + } + } + } + }, + "Create Release…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 생성…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建发布…" + } + } + } + }, + "Create Releases" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建发布" + } + } + } + }, + "Create reusable templates of GitHub merge settings and branch rulesets. Your default template is applied to newly created repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 병합 설정과 브랜치 규칙 세트를 재사용 가능한 템플릿으로 만드세요. 기본 템플릿은 새로 생성된 저장소에 적용됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建可复用的 GitHub 合并设置和分支规则集模板。你的默认模板将应用于新创建的仓库。" + } + } + } + }, + "Create worktree" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크트리 생성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建工作树" + } + } + } + }, "Create your encryption key. You'll authenticate with your passkey; the key is derived from it and used to protect your secrets." : { "localizations" : { "ko" : { @@ -2731,6 +4061,12 @@ }, "Creating…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2757,6 +4093,12 @@ }, "Current branch" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 브랜치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2766,11 +4108,46 @@ } }, "Current project" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 프로젝트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前项目" + } + } + } + }, + "Current version" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 버전" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前版本" + } + } + } }, "Custom" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2797,6 +4174,12 @@ }, "Custom Sources" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 소스" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2807,6 +4190,12 @@ }, "Custom target" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 대상" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2817,6 +4206,12 @@ }, "Custom…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2825,8 +4220,30 @@ } } }, + "Cut and publish semantic releases — all from one app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시맨틱 릴리스를 한 앱에서 생성하고 게시하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在一个应用中完成语义化发布的创建与发布。" + } + } + } + }, "Debug" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디버그" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2837,6 +4254,12 @@ }, "Decision" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2965,6 +4388,22 @@ } } }, + "Default template" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 템플릿" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认模板" + } + } + } + }, "Delete" : { "localizations" : { "en" : { @@ -2989,6 +4428,12 @@ }, "Delete \"%@\"?" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"을(를) 삭제하시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2999,6 +4444,12 @@ }, "Delete after" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제 기준 기간" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3031,6 +4482,12 @@ }, "Delete All Archived" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관된 항목 모두 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3041,6 +4498,12 @@ }, "Delete all memories" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 메모리 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3051,6 +4514,12 @@ }, "Delete All Memories?" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 메모리를 삭제하시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3060,7 +4529,20 @@ } }, "Delete Document" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文档" + } + } + } }, "Delete Environment" : { "localizations" : { @@ -3080,6 +4562,12 @@ }, "Delete Hook" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3090,16 +4578,28 @@ }, "Delete memory" : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "删除记忆" + "value" : "메모리 삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除记忆" } } } }, "Delete Memory?" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리를 삭제하시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3110,6 +4610,12 @@ }, "Delete Preset" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3120,6 +4626,12 @@ }, "Delete Profile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로필 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3168,6 +4680,12 @@ }, "Delete Session" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 삭제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3177,7 +4695,20 @@ } }, "Delete this document?" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 문서를 삭제하시겠습니까?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除此文档?" + } + } + } }, "Deleting \"%@\"…" : { "localizations" : { @@ -3219,6 +4750,12 @@ }, "Destination" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3227,6 +4764,22 @@ } } }, + "Detect CI Issues" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 문제 감지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检测 CI 问题" + } + } + } + }, "dev" : { "localizations" : { "ko" : { @@ -3245,6 +4798,12 @@ }, "dev / prod / beta" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "dev / prod / beta" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3255,6 +4814,12 @@ }, "Device name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3265,6 +4830,12 @@ }, "Disable globally" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역으로 비활성화" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3275,6 +4846,12 @@ }, "Disabled" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성화됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3285,6 +4862,12 @@ }, "Disconnected" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 끊김" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3309,8 +4892,30 @@ } } }, + "dispatchable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스패치 가능" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可调度" + } + } + } + }, "Display name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시 이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3320,7 +4925,20 @@ } }, "Document" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } }, "Documentation" : { "localizations" : { @@ -3473,6 +5091,12 @@ }, "Download APK" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "APK 다운로드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3483,6 +5107,12 @@ }, "Download for iOS" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS용 다운로드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3493,6 +5123,12 @@ }, "Download links unavailable. Visit rxlab.app to install the mobile app." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 링크를 사용할 수 없습니다. rxlab.app에서 모바일 앱을 설치하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3519,6 +5155,12 @@ }, "Duplicate Hook" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook 복제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3529,6 +5171,12 @@ }, "Duplicate Profile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로필 복제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3561,6 +5209,12 @@ }, "Edit ACP Client" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 클라이언트 편집" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3571,6 +5225,12 @@ }, "Edit Configurations…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구성 편집…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3581,6 +5241,12 @@ }, "Edit memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 편집" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3591,6 +5257,12 @@ }, "Edit Memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 편집" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3601,6 +5273,12 @@ }, "Edit Relay Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버 편집" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3609,8 +5287,46 @@ } } }, + "Edit Template" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "템플릿 편집" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑模板" + } + } + } + }, + "Edit your autopilot automation preferences." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 자동화 환경설정을 편집하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑你的 Autopilot 自动化偏好设置。" + } + } + } + }, "Effort level: %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노력 수준: %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3643,6 +5359,12 @@ }, "Empty Bash Configuration" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 Bash 구성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3653,6 +5375,12 @@ }, "Empty Make Configuration" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 Make 구성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3679,6 +5407,12 @@ }, "Empty Xcode Configuration" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 Xcode 구성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3711,6 +5445,12 @@ }, "Enable globally" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역으로 활성화" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3721,6 +5461,12 @@ }, "Enable Memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 활성화" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3731,6 +5477,12 @@ }, "Enabled" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3773,6 +5525,12 @@ }, "Endpoint" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "엔드포인트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3799,6 +5557,12 @@ }, "Enter a new name for this device." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기의 새 이름을 입력하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3809,6 +5573,12 @@ }, "Enter a valid ws:// or wss:// URL." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유효한 ws:// 또는 wss:// URL을 입력하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3819,6 +5589,12 @@ }, "Env File" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Env 파일" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3829,6 +5605,12 @@ }, "Environment" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3839,12 +5621,18 @@ }, "Environments" : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "环境" + "value" : "환경" } - } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "环境" + } + } } }, "Error" : { @@ -3871,6 +5659,12 @@ }, "esc" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "esc" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3881,6 +5675,12 @@ }, "exit %d" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "exit %d" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3891,6 +5691,12 @@ }, "exit 0" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "exit 0" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3902,6 +5708,12 @@ "Exit 0 → done" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit 0 → 완료" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3912,6 +5724,12 @@ }, "Expand chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅 펼치기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3922,6 +5740,12 @@ }, "Expired" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "만료됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3932,6 +5756,12 @@ }, "Expired — generating a new QR code" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "만료됨 — 새 QR 코드 생성 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3964,6 +5794,12 @@ }, "Extra environment" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가 환경" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -3974,6 +5810,12 @@ }, "Fact" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사실" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4028,6 +5870,12 @@ }, "Fetch" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4038,6 +5886,12 @@ }, "Fetch Models" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 가져오기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4048,6 +5902,12 @@ }, "Fetch models first" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "먼저 모델을 가져오세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4134,6 +5994,12 @@ }, "First MCP Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "첫 MCP 서버" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4188,6 +6054,12 @@ }, "Follow active threads, approve permissions, and pick up work from your iPhone, iPad, or Android device." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone, iPad 또는 Android 기기에서 활성 스레드를 따라가고 권한을 승인하며 작업을 이어가세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4242,6 +6114,12 @@ }, "Force off" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강제 끄기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4252,6 +6130,12 @@ }, "Force on" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강제 켜기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4262,6 +6146,12 @@ }, "from %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에서" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4294,6 +6184,12 @@ }, "Generate" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4304,6 +6200,12 @@ }, "Generate a commit message from the staged diff" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스테이징된 변경 사항에서 커밋 메시지 생성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4314,6 +6216,12 @@ }, "Get it on Google Play" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Play에서 다운로드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4347,6 +6255,12 @@ "Git Repositories" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 저장소" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4355,9 +6269,31 @@ } } }, + "Git Ruleset" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 규칙 세트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git 规则集" + } + } + } + }, "Git URL" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Git URL" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4406,6 +6342,12 @@ }, "GitHub Repository" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 저장소" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4414,8 +6356,30 @@ } } }, + "GitHub token (ghp_… / github_pat_…)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 토큰 (ghp_… / github_pat_…)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 令牌 (ghp_… / github_pat_…)" + } + } + } + }, "Global" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4426,6 +6390,12 @@ }, "Global default" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4436,6 +6406,12 @@ }, "Global default plus per-project override" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값 및 프로젝트별 재정의" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4446,6 +6422,12 @@ }, "Headers" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "헤더" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4456,6 +6438,12 @@ }, "Hide details" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세부 정보 숨기기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4466,6 +6454,12 @@ }, "Hide Hidden Files" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨김 파일 숨기기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4476,6 +6470,12 @@ }, "Hide more" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "간략히 보기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4509,6 +6509,12 @@ "Hook runs" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook 실행" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4526,6 +6532,12 @@ "value" : "Loading…" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로딩 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4542,6 +6554,12 @@ "value" : "Checking for secrets…" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 확인 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4558,6 +6576,12 @@ "value" : "Downloading secrets…" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 다운로드 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4574,6 +6598,12 @@ "value" : "Overwrite existing files?" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 파일을 덮어쓰시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4590,6 +6620,12 @@ "value" : "Choose an environment" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경을 선택하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4600,6 +6636,12 @@ }, "Hooks" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4608,8 +6650,30 @@ } } }, + "How often autopilot scans this repo's workflows and opens a PR when actions are outdated." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot이 이 저장소의 워크플로를 스캔하고 액션이 오래되었을 때 PR을 여는 빈도입니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 扫描此仓库工作流并在动作过期时创建 PR 的频率。" + } + } + } + }, "https://example.com/mcp" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://example.com/mcp" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4620,6 +6684,12 @@ }, "https://github.com/owner/repo" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/owner/repo" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4631,6 +6701,12 @@ "https://github.com/owner/repo.git or git@github.com:owner/repo.git" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/owner/repo.git or git@github.com:owner/repo.git" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4641,6 +6717,12 @@ }, "I found the existing onboarding view and will keep the CLI check as setup." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 온보딩 화면을 찾았으며 CLI 확인을 설정 단계로 유지하겠습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4671,6 +6753,22 @@ } } }, + "Import JSON…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 가져오기…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入 JSON…" + } + } + } + }, "Import Repository" : { "localizations" : { "ko" : { @@ -4689,6 +6787,12 @@ }, "In progress" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4699,6 +6803,12 @@ }, "Inactive chats are moved to the archive automatically. Pinned chats are never auto-archived." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성 채팅은 자동으로 보관됩니다. 고정된 채팅은 자동 보관되지 않습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4709,6 +6819,12 @@ }, "Include relevant memories in agent context" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관련 메모리를 에이전트 컨텍스트에 포함" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4717,6 +6833,22 @@ } } }, + "Index your repositories into a personalized, searchable knowledge base." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소를 색인하여 개인화된 검색 가능한 지식 베이스로 만드세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将你的仓库索引为可搜索的个性化知识库。" + } + } + } + }, "Index your repositories' docs (design docs, API docs, code docs) so agents and ⌘K can search them. Set up CI to upload docs automatically with an upload token." : { "localizations" : { "ko" : { @@ -4735,6 +6867,12 @@ }, "Inherit global default" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값 상속" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4815,6 +6953,12 @@ }, "Install ACP agents" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 에이전트 설치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4823,38 +6967,50 @@ } } }, - "Install DOCS_UPLOAD_TOKEN secret" : { + "Install additional ACP-compatible agents — RxCode downloads the right binary for macOS and probes the model list." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "DOCS_UPLOAD_TOKEN 시크릿 설치" + "value" : "추가 ACP 호환 에이전트를 설치하세요 — RxCode가 macOS에 맞는 바이너리를 다운로드하고 모델 목록을 조회합니다." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安装 DOCS_UPLOAD_TOKEN 密钥" + "value" : "安装更多兼容 ACP 的代理 —— RxCode 会下载适配 macOS 的二进制并探测模型列表。" } } } }, - "Install additional ACP-compatible agents — RxCode downloads the right binary for macOS and probes the model list." : { + "Install additional coding agents from the ACP registry. You can skip this and add clients later." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 레지스트리에서 추가 코딩 에이전트를 설치하세요. 이 단계를 건너뛰고 나중에 클라이언트를 추가할 수 있습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安装更多兼容 ACP 的代理 —— RxCode 会下载适配 macOS 的二进制并探测模型列表。" + "value" : "从 ACP 注册表安装更多编程代理,可稍后再添加。" } } } }, - "Install additional coding agents from the ACP registry. You can skip this and add clients later." : { + "Install DOCS_UPLOAD_TOKEN secret" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "DOCS_UPLOAD_TOKEN 시크릿 설치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从 ACP 注册表安装更多编程代理,可稍后再添加。" + "value" : "安装 DOCS_UPLOAD_TOKEN 密钥" } } } @@ -4893,6 +7049,12 @@ }, "Install one CLI, then check again." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI를 하나 설치한 후 다시 확인하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4901,8 +7063,30 @@ } } }, + "Install RELEASE_TOKEN secret" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RELEASE_TOKEN 시크릿 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 RELEASE_TOKEN 密钥" + } + } + } + }, "Install the app" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 설치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4929,6 +7113,12 @@ }, "installed" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -4977,6 +7167,12 @@ }, "Installed skills are managed by RxCode, sourced from OpenAI Agent Skills and compatible catalogs, and enabled for Claude Code, Codex, and ACP agents where supported." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치된 스킬은 RxCode가 관리하며, OpenAI Agent Skills 및 호환 카탈로그에서 제공되고 지원되는 경우 Claude Code, Codex, ACP 에이전트에 활성화됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5031,6 +7227,12 @@ }, "Keep summaries on the thread model, or route titles and briefings through an OpenAI-compatible endpoint." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약을 스레드 모델에 유지하거나 제목과 브리핑을 OpenAI 호환 엔드포인트로 전달하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5039,8 +7241,46 @@ } } }, + "Keep this repo's GitHub Actions up to date automatically" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 저장소의 GitHub Actions를 자동으로 최신 상태로 유지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动保持此仓库的 GitHub Actions 为最新" + } + } + } + }, + "Keep your repositories' GitHub Actions up to date automatically. Autopilot scans `.github/workflows` on a schedule and opens a PR when actions are outdated." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소의 GitHub Actions를 자동으로 최신 상태로 유지하세요. Autopilot은 일정에 따라 `.github/workflows`를 스캔하고 액션이 오래되면 PR을 엽니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动保持你的仓库的 GitHub Actions 为最新。Autopilot 按计划扫描 `.github/workflows`,并在动作过期时创建 PR。" + } + } + } + }, "KEY" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "KEY" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5051,6 +7291,12 @@ }, "Kind" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종류" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5059,6 +7305,38 @@ } } }, + "Latest release: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최신 릴리스: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新发布:%@" + } + } + } + }, + "Latest: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최신: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新:%@" + } + } + } + }, "Letters, numbers, dot, dash and underscore. Max 32 characters." : { "localizations" : { "ko" : { @@ -5077,6 +7355,12 @@ }, "Lifecycle" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수명 주기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5087,6 +7371,12 @@ }, "Live channel only" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이브 채널만" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5097,6 +7387,12 @@ }, "Load from .env file" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : ".env 파일에서 불러오기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5106,7 +7402,20 @@ } }, "Load from File…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일에서 불러오기…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从文件加载…" + } + } + } }, "Load more" : { "localizations" : { @@ -5148,6 +7457,12 @@ }, "Loading destinations…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상 불러오는 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5174,6 +7489,12 @@ }, "Loading registry…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레지스트리 불러오는 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5251,6 +7572,12 @@ "Local on-device search · archived threads included" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 내 로컬 검색 · 보관된 스레드 포함" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5283,6 +7610,12 @@ }, "main" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "main" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5293,6 +7626,12 @@ }, "Make" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Make" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5303,6 +7642,12 @@ }, "Makefile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Makefile" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5313,6 +7658,12 @@ }, "Makefile (optional)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Makefile(선택 사항)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5323,6 +7674,12 @@ }, "Malformed question payload" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잘못된 형식의 질문 페이로드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5333,16 +7690,28 @@ }, "Manage" : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "管理" + "value" : "관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理" } } } }, "Manage agents that speak the Agent Client Protocol. Detected from agentclientprotocol.com." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent Client Protocol을 사용하는 에이전트를 관리하세요. agentclientprotocol.com에서 감지됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5351,6 +7720,22 @@ } } }, + "Manage autopilot automation settings and repo-setup templates." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 자동화 설정과 저장소 설정 템플릿을 관리하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理 Autopilot 自动化设置和仓库设置模板。" + } + } + } + }, "Manage Docs" : { "localizations" : { "ko" : { @@ -5408,6 +7793,12 @@ }, "Manage Hooks…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hook 관리…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5418,6 +7809,12 @@ }, "Manage MCP servers once in RxCode, then enable or disable them globally or per project." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode에서 MCP 서버를 한 번 관리한 다음 전역 또는 프로젝트별로 활성화하거나 비활성화하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5426,6 +7823,54 @@ } } }, + "Manage merge settings and rulesets applied to new repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 저장소에 적용되는 병합 설정과 규칙 세트를 관리하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理应用于新仓库的合并设置和规则集。" + } + } + } + }, + "Manage release repositories, select release workflows, and create releases." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 저장소를 관리하고 릴리스 워크플로를 선택하며 릴리스를 생성하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理发布仓库、选择发布工作流并创建发布。" + } + } + } + }, + "Manage Releases" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理发布" + } + } + } + }, "Manage Secrets" : { "localizations" : { "ko" : { @@ -5442,6 +7887,38 @@ } } }, + "Manage Settings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理设置" + } + } + } + }, + "Manage Templates" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "템플릿 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理模板" + } + } + } + }, "Manage your repositories, environments, and secrets." : { "localizations" : { "ko" : { @@ -5460,6 +7937,12 @@ }, "Manual key/value pairs" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수동 키/값 쌍" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5469,10 +7952,29 @@ } }, "Markdown content" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown 콘텐츠" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown 内容" + } + } + } }, "Matched on title" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목에서 일치" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5483,6 +7985,12 @@ }, "MCP" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5493,6 +8001,12 @@ }, "MCP error" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 오류" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5504,6 +8018,12 @@ "MCP server disconnected" : { "comment" : "Notification title when an MCP server transitions from connected to failed.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 서버 연결 끊김" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5514,6 +8034,12 @@ }, "MCP Servers" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 서버" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5546,6 +8072,12 @@ }, "Memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5556,6 +8088,12 @@ }, "Memory History" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 기록" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5566,6 +8104,12 @@ }, "Menu Bar" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메뉴 막대" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5574,6 +8118,22 @@ } } }, + "Merged" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "병합됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已合并" + } + } + } + }, "Message" : { "localizations" : { "en" : { @@ -5620,6 +8180,12 @@ }, "Mobile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모바일" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5630,6 +8196,12 @@ }, "Mobile companion" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모바일 동반 앱" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5640,6 +8212,12 @@ }, "Model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5650,6 +8228,12 @@ }, "Model Context Protocol servers expose tools and resources to every agent. Optional — you can skip this step." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model Context Protocol 서버는 모든 에이전트에 도구와 리소스를 제공합니다. 선택 사항이며 이 단계를 건너뛸 수 있습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5688,6 +8272,12 @@ "value" : "Model: %1$@ · %2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델: %@ · %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5698,6 +8288,12 @@ }, "Models" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5708,6 +8304,12 @@ }, "More" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 보기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5718,6 +8320,12 @@ }, "My Relay Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내 릴레이 서버" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5729,6 +8337,12 @@ "my-project" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "my-project" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5739,6 +8353,12 @@ }, "my-server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "my-server" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5749,6 +8369,12 @@ }, "Name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5773,6 +8399,22 @@ } } }, + "Never scanned" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스캔된 적 없음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从未扫描" + } + } + } + }, "New Chat" : { "localizations" : { "en" : { @@ -5797,6 +8439,12 @@ }, "New Chat in %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에서 새 채팅" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5824,6 +8472,12 @@ "New Hook" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 Hook" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5832,9 +8486,31 @@ } } }, - "New Terminal" : { + "New Template" : { "localizations" : { - "zh-Hans" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 템플릿" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建模板" + } + } + } + }, + "New Terminal" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 터미널" + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "新建终端" @@ -5844,6 +8520,12 @@ }, "New Terminal (⌘T)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 터미널 (⌘T)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5855,6 +8537,12 @@ "New thread starts" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 스레드 시작" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5865,6 +8553,12 @@ }, "Next" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5891,6 +8585,12 @@ }, "No active runs" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성 실행 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5901,6 +8601,12 @@ }, "No agents match “%@”." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”과(와) 일치하는 에이전트가 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5911,6 +8617,12 @@ }, "No archived chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관된 채팅 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5965,6 +8677,12 @@ }, "No chats yet" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 채팅 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5975,6 +8693,12 @@ }, "No clients installed. Add one from the Registry tab." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치된 클라이언트가 없습니다. 레지스트리 탭에서 추가하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5985,6 +8709,12 @@ }, "No custom Git sources" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 Git 소스 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -5995,6 +8725,12 @@ }, "No custom Git sources added." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가된 사용자 지정 Git 소스가 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6006,6 +8742,12 @@ "No custom repositories" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 저장소 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6016,6 +8758,12 @@ }, "No destinations found" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상을 찾을 수 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6024,6 +8772,22 @@ } } }, + "No dispatchable release workflow found for %@. Add a `workflow_dispatch` release workflow and rescan, then try again." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 대해 디스패치 가능한 릴리스 워크플로를 찾을 수 없습니다. `workflow_dispatch` 릴리스 워크플로를 추가하고 다시 스캔한 후 시도하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 %@ 的可调度发布工作流。请添加 `workflow_dispatch` 发布工作流并重新扫描,然后重试。" + } + } + } + }, "No documentation repositories yet. Set up docs publishing from a project's chat to index its docs." : { "localizations" : { "ko" : { @@ -6058,10 +8822,29 @@ } }, "No documents uploaded yet. Use the + button above to add one, or set up CI to upload them automatically." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 업로드된 문서가 없습니다. 위의 + 버튼으로 추가하거나 CI를 설정하여 자동으로 업로드하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未上传文档。使用上方的 + 按钮添加,或设置 CI 自动上传。" + } + } + } }, "No editors detected" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "감지된 편집기 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6072,6 +8855,12 @@ }, "No environment variables defined" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정의된 환경 변수 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6114,6 +8903,12 @@ }, "No hooks yet" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 Hook 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6146,6 +8941,12 @@ }, "No matching memories" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일치하는 메모리 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6156,6 +8957,12 @@ }, "No memories" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6166,6 +8973,12 @@ }, "No paired devices yet." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 페어링된 기기가 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6176,6 +8989,12 @@ }, "No profiles for this project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트에 대한 프로필 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6186,6 +9005,12 @@ }, "No profiles yet" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 프로필 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6196,6 +9021,12 @@ }, "No project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6206,6 +9037,12 @@ }, "No projects yet" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 프로젝트 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6214,8 +9051,30 @@ } } }, + "No pull requests opened by autopilot yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 Autopilot이 연 풀 리퀘스트가 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autopilot 尚未创建任何拉取请求。" + } + } + } + }, "No relay server configured" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구성된 릴레이 서버 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6226,6 +9085,12 @@ }, "No relay servers" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6234,6 +9099,38 @@ } } }, + "No release repositories yet. Set up release publishing from a project's chat to register one." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 릴리스 저장소가 없습니다. 프로젝트 채팅에서 릴리스 게시를 설정하여 등록하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无发布仓库。从项目对话中设置发布以注册一个。" + } + } + } + }, + "No releases yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 릴리스 없음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无发布" + } + } + } + }, "No remote branches" : { "localizations" : { "en" : { @@ -6294,8 +9191,37 @@ } } }, + "No repositories are watched for CI auto-updates yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 CI 자동 업데이트를 위해 감시 중인 저장소가 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无仓库被监视以进行 CI 自动更新。" + } + } + } + }, "No repositories available to add." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가할 수 있는 저장소가 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可添加的仓库。" + } + } + } }, "No repositories found." : { "localizations" : { @@ -6357,6 +9283,22 @@ } } }, + "No scans yet. Trigger one to check this repo's actions." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 스캔이 없습니다. 이 저장소의 액션을 확인하려면 스캔을 실행하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无扫描。触发一次以检查此仓库的动作。" + } + } + } + }, "No secrets yet. Add a .env file or enter values manually." : { "localizations" : { "ko" : { @@ -6375,6 +9317,12 @@ }, "No servers" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6385,6 +9333,12 @@ }, "No summary yet" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 요약 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6395,7 +9349,13 @@ }, "No tasks" : { "localizations" : { - "zh-Hans" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 없음" + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "没有任务" @@ -6403,8 +9363,30 @@ } } }, + "No templates yet. Create one to standardize new repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 템플릿이 없습니다. 새 저장소를 표준화하려면 하나 만드세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无模板。创建一个以标准化新仓库。" + } + } + } + }, "No tools advertised." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "광고된 도구가 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6413,9 +9395,31 @@ } } }, + "No workflows scanned yet. Add a release workflow (e.g. .github/workflows/create-release.yaml) to the repo, then re-add the repository to rescan." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 스캔된 워크플로가 없습니다. 저장소에 릴리스 워크플로(예: .github/workflows/create-release.yaml)를 추가한 다음 저장소를 다시 추가하여 재스캔하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未扫描任何工作流。请向仓库添加发布工作流(例如 .github/workflows/create-release.yaml),然后重新添加仓库以重新扫描。" + } + } + } + }, "Non-zero → agent continues" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "0이 아니면 → 에이전트 계속" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6426,6 +9430,12 @@ }, "None" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6458,6 +9468,12 @@ }, "not found" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "찾을 수 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6468,6 +9484,12 @@ }, "Not found" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "찾을 수 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6570,6 +9592,12 @@ }, "npx" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "npx" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6580,6 +9608,12 @@ }, "Offline" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오프라인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6612,6 +9646,12 @@ }, "On failure the hook output is sent back to the agent, which keeps fixing until the hook passes (max 3 retries)." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실패 시 Hook 출력이 에이전트로 다시 전달되며, 에이전트는 Hook이 통과할 때까지 계속 수정합니다(최대 3회 재시도)." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6622,6 +9662,12 @@ }, "Online" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온라인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6664,6 +9710,12 @@ }, "Open in External Editor" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 편집기에서 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6674,6 +9726,12 @@ }, "Open in GitHub" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub에서 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6684,6 +9742,12 @@ }, "Open in New Window" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 창에서 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6708,8 +9772,30 @@ } } }, + "Open PR" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 PR" + } + } + } + }, "Open Project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6720,6 +9806,12 @@ }, "Open project branch briefing" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 브랜치 브리핑 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6730,6 +9822,12 @@ }, "Open projects, switch threads, and keep agent conversations close to the files they change." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트를 열고 스레드를 전환하며 에이전트 대화를 변경하는 파일 가까이에 두세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6754,8 +9852,30 @@ } } }, + "Open pull request on GitHub" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub에서 풀 리퀘스트 열기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 GitHub 上打开拉取请求" + } + } + } + }, "Open RxCode" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6766,6 +9886,12 @@ }, "Open RxCode on your phone, then tap Pair Device and scan the QR code." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "휴대폰에서 RxCode를 열고 기기 페어링을 탭한 후 QR 코드를 스캔하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6798,6 +9924,12 @@ }, "Open Terminal (⌥-click for window)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 열기 (⌥-클릭 시 창으로)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6808,6 +9940,12 @@ }, "Open the branch briefing" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 브리핑 열기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6818,6 +9956,12 @@ }, "OpenAI-Compatible Endpoint" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "OpenAI 호환 엔드포인트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6826,8 +9970,30 @@ } } }, + "Optional. Paste or import a GitHub ruleset JSON exported from your repository's branch-rules settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택 사항. 저장소의 브랜치 규칙 설정에서 내보낸 GitHub 규칙 세트 JSON을 붙여넣거나 가져오세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可选。粘贴或导入从仓库分支规则设置导出的 GitHub 规则集 JSON。" + } + } + } + }, "Opus 4.6 only" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opus 4.6 전용" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6837,10 +10003,29 @@ } }, "Original link (optional)" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원본 링크(선택 사항)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "原始链接(可选)" + } + } + } }, "Other" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기타" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6852,6 +10037,12 @@ "output added to context" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "출력이 컨텍스트에 추가됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6862,6 +10053,12 @@ }, "Override" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재정의" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6952,6 +10149,12 @@ }, "Pair a new device" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 기기 페어링" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6962,6 +10165,12 @@ }, "Pair an iPhone or iPad to review threads, answer approvals, and send messages to your desktop agent." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone 또는 iPad를 페어링하여 스레드를 검토하고 승인에 응답하며 데스크톱 에이전트에 메시지를 보내세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6972,6 +10181,12 @@ }, "Pair an iPhone or iPad to view threads, get notifications, and send messages to your desktop agent." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone 또는 iPad를 페어링하여 스레드를 보고 알림을 받으며 데스크톱 에이전트에 메시지를 보내세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6982,6 +10197,12 @@ }, "Pair new device" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 기기 페어링" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -6992,6 +10213,12 @@ }, "Pair via QR" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR로 페어링" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7002,6 +10229,12 @@ }, "Pair your phone" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "휴대폰 페어링" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7012,6 +10245,12 @@ }, "Paired devices" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페어링된 기기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7020,28 +10259,50 @@ } } }, - "Permanently delete archived chats after the retention window. Pinned chats are never auto-deleted. This cannot be undone." : { + "Per-project secrets, end-to-end encrypted and easily shared with your team." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트별 시크릿을 종단 간 암호화하여 팀과 손쉽게 공유하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在保留期后永久删除归档聊天。置顶聊天不会被自动删除。此操作无法撤销。" + "value" : "按项目管理的密钥,端到端加密,可轻松与团队共享。" } } } }, - "Permission Mode" : { + "Permanently delete archived chats after the retention window. Pinned chats are never auto-deleted. This cannot be undone." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Permission Mode" - } - }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "권한 모드" + "value" : "보존 기간이 지나면 보관된 채팅을 영구 삭제합니다. 고정된 채팅은 자동 삭제되지 않습니다. 이 작업은 취소할 수 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在保留期后永久删除归档聊天。置顶聊天不会被自动删除。此操作无法撤销。" + } + } + } + }, + "Permission Mode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Permission Mode" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한 모드" } }, "zh-Hans" : { @@ -7077,6 +10338,12 @@ "Permission needed%@" : { "comment" : "Notification title when Claude queues a tool approval. %@ is replaced with \" — \" or empty string.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한 필요%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7109,6 +10376,12 @@ }, "Pick a profile in the toolbar and press Run." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "도구 모음에서 프로필을 선택하고 실행을 누르세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7119,6 +10392,12 @@ }, "Pick a summarization model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약 모델 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7157,6 +10436,12 @@ "value" : "Platform: %1$@ • %2$@" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플랫폼: %@ • %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7165,6 +10450,22 @@ } } }, + "PR #%lld" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR #%lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR #%lld" + } + } + } + }, "Precise returns fewer, stronger matches. Balanced is the default. Aggressive includes more related memories." : { "localizations" : { "ko" : { @@ -7183,6 +10484,12 @@ }, "Preference" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7193,6 +10500,12 @@ }, "Preset" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7203,6 +10516,12 @@ }, "Preset Name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋 이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7213,6 +10532,12 @@ }, "Press Command-K or use the toolbar search button to find past work across projects." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Command-K를 누르거나 도구 모음의 검색 버튼을 사용하여 프로젝트 전반의 이전 작업을 찾으세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7261,6 +10586,12 @@ }, "Project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7271,6 +10602,12 @@ }, "Project / Workspace" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 / 작업 공간" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7281,6 +10618,12 @@ }, "Project Hooks" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 Hook" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7314,6 +10657,12 @@ "Project Name" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 이름" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7324,6 +10673,12 @@ }, "Project-specific override is set" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트별 재정의가 설정됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7334,6 +10689,12 @@ }, "Project: Off" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트: 끔" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7344,6 +10705,12 @@ }, "Project: On" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트: 켬" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7376,6 +10743,12 @@ }, "Provider" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제공자" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7402,6 +10775,12 @@ }, "Push" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "푸시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7410,8 +10789,30 @@ } } }, + "Push the branch and open a pull request from this briefing" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치를 푸시하고 이 브리핑에서 풀 리퀘스트를 엽니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推送分支并从此简报创建拉取请求" + } + } + } + }, "Quit" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종료" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7422,6 +10823,12 @@ }, "Re-run %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 다시 실행" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7432,6 +10839,12 @@ }, "Ready to start." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작할 준비가 되었습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7442,6 +10855,12 @@ }, "Recent Briefings" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 브리핑" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7452,6 +10871,12 @@ }, "Reconnecting in %llds" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld초 후 재연결" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7462,6 +10887,12 @@ }, "Refactor onboarding into slides" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온보딩을 슬라이드로 리팩터링" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7494,6 +10925,12 @@ }, "Refresh & Test All" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 새로 고침 및 테스트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7504,6 +10941,12 @@ }, "Refresh installation status" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설치 상태 새로 고침" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7514,6 +10957,12 @@ }, "Refresh registry" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레지스트리 새로 고침" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7524,6 +10973,12 @@ }, "Refresh usage" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용량 새로 고침" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7534,6 +10989,12 @@ }, "Regenerate" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 생성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7542,8 +11003,30 @@ } } }, + "Register repositories for semantic-release publishing. Pick which workflow cuts releases, install the RELEASE_TOKEN secret, and trigger releases from RxCode." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "semantic-release 게시를 위해 저장소를 등록하세요. 릴리스를 생성하는 워크플로를 선택하고 RELEASE_TOKEN 시크릿을 설치한 후 RxCode에서 릴리스를 트리거하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为 semantic-release 发布注册仓库。选择执行发布的工作流,安装 RELEASE_TOKEN 密钥,并从 RxCode 触发发布。" + } + } + } + }, "Registry" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레지스트리" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7554,6 +11037,12 @@ }, "Reindex all threads" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 스레드 다시 색인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7564,6 +11053,12 @@ }, "Reindex Now" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금 다시 색인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7574,6 +11069,12 @@ }, "Reject" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "거부" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7584,6 +11085,12 @@ }, "Relay" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7594,6 +11101,12 @@ }, "Relay server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7604,6 +11117,12 @@ }, "Relay servers" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 서버" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7614,6 +11133,12 @@ }, "Relay:" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이:" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7622,22 +11147,108 @@ } } }, - "Remote" : { + "Release" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "远程" + "value" : "发布" } } } }, - "Remove" : { + "Release publishing" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { - "state" : "new", - "value" : "Remove" + "state" : "translated", + "value" : "릴리스 게시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "版本发布" + } + } + } + }, + "Release token" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 토큰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布令牌" + } + } + } + }, + "Release workflow triggered" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 워크플로가 트리거됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已触发发布工作流" + } + } + } + }, + "Releases" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布" + } + } + } + }, + "Remote" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程" + } + } + } + }, + "Remove" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove" } }, "ko" : { @@ -7656,6 +11267,12 @@ }, "Remove ACP client?" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ACP 클라이언트를 제거하시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7682,6 +11299,12 @@ }, "Remove MCP server?" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MCP 서버를 제거하시겠습니까?" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7690,6 +11313,22 @@ } } }, + "Remove Release Repository" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 저장소 제거" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除发布仓库" + } + } + } + }, "Remove this docs repository?" : { "localizations" : { "ko" : { @@ -7706,8 +11345,30 @@ } } }, + "Remove this release repository?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 릴리스 저장소를 제거하시겠습니까?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除此发布仓库?" + } + } + } + }, "Remove Variable" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변수 제거" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7718,6 +11379,12 @@ }, "Remove…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제거…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7750,6 +11417,12 @@ }, "Rename device" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 이름 변경" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7760,6 +11433,12 @@ }, "Rename Device" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 이름 변경" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7814,6 +11493,12 @@ }, "Rename…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이름 변경…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7822,6 +11507,70 @@ } } }, + "Replace RELEASE_TOKEN secret" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RELEASE_TOKEN 시크릿 교체" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "替换 RELEASE_TOKEN 密钥" + } + } + } + }, + "Repo Setup" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仓库设置" + } + } + } + }, + "Repo Setup Templates" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소 설정 템플릿" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仓库设置模板" + } + } + } + }, + "Repository" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仓库" + } + } + } + }, "Reset" : { "localizations" : { "en" : { @@ -7868,6 +11617,12 @@ }, "Reset to project root" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 루트로 재설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7878,6 +11633,12 @@ }, "Resets %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 재설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7911,6 +11672,12 @@ }, "Response in progress" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "응답 진행 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7981,6 +11748,12 @@ }, "Reveal in Finder" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder에서 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -7991,6 +11764,12 @@ }, "Review Command" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령 검토" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8001,6 +11780,12 @@ }, "Review commands, diffs, and permission requests before an agent changes your workspace." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트가 작업 공간을 변경하기 전에 명령, 차이, 권한 요청을 검토하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8011,6 +11796,12 @@ }, "Review the CLI setup check" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI 설정 확인 검토" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8037,6 +11828,12 @@ }, "Run %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 실행" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8047,6 +11844,12 @@ }, "Run commands automatically at session lifecycle points. Hooks are configured per project." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 수명 주기 시점에 명령을 자동으로 실행합니다. Hook은 프로젝트별로 구성됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8073,6 +11876,12 @@ }, "Run/Debug Configurations" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행/디버그 구성" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8083,6 +11892,12 @@ }, "Running swift build" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "swift build 실행 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8109,6 +11924,12 @@ }, "Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile)." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "`make [-f ] [arguments]`를 실행합니다. 기본 조회(Makefile / makefile / GNUmakefile)를 사용하려면 Makefile을 비워 두세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8120,6 +11941,12 @@ "Runs after streaming stops. Its output is shown only — nothing is passed back to the session." : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스트리밍이 멈춘 후 실행됩니다. 출력은 표시만 되며 세션으로 다시 전달되지 않습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8130,6 +11957,12 @@ }, "Runs on-device using Apple Intelligence. Free, private, and offline." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Intelligence를 사용하여 기기에서 실행됩니다. 무료, 비공개, 오프라인." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8140,10 +11973,16 @@ }, "Runs on-device with Apple Intelligence. Private, free, and offline." : { "localizations" : { - "zh-Hans" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "通过 Apple Intelligence 在设备端运行。私密、免费且离线。" + "value" : "Apple Intelligence로 기기에서 실행됩니다. 비공개, 무료, 오프라인." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 Apple Intelligence 在设备端运行。私密、免费且离线。" } } } @@ -8151,6 +11990,12 @@ "Runs once when a new thread starts. Its output is added to the agent's context for that turn." : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 스레드가 시작될 때 한 번 실행됩니다. 출력은 해당 턴의 에이전트 컨텍스트에 추가됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8162,6 +12007,12 @@ "Runs when streaming stops. Its output is shown and saved with the thread. If the command exits non-zero, its output is sent back to the agent to continue (up to 3 times)." : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스트리밍이 멈출 때 실행됩니다. 출력은 표시되고 스레드와 함께 저장됩니다. 명령이 0이 아닌 값으로 종료되면 출력이 에이전트로 다시 전달되어 계속됩니다(최대 3회)." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8172,6 +12023,12 @@ }, "Runs with `/bin/zsh -lc`. Absolute or project-relative working directory; leave empty to use the project root." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "`/bin/zsh -lc`로 실행됩니다. 절대 또는 프로젝트 기준 작업 디렉터리이며, 비워 두면 프로젝트 루트를 사용합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8182,6 +12039,12 @@ }, "RxCode" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8208,6 +12071,12 @@ }, "RxCode runs Claude Code or Codex locally. Install one of them to start your first project." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode는 Claude Code 또는 Codex를 로컬에서 실행합니다. 첫 프로젝트를 시작하려면 둘 중 하나를 설치하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8218,6 +12087,12 @@ }, "RxCode.xcodeproj" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode.xcodeproj" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8228,6 +12103,12 @@ }, "rxcode/feature-name" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "rxcode/feature-name" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8255,6 +12136,12 @@ }, "Save" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8265,6 +12152,12 @@ }, "Save Server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 저장" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8275,6 +12168,12 @@ }, "Saved" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8285,6 +12184,12 @@ }, "Saved memories are stored locally in SwiftData and embedded on-device." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 메모리는 SwiftData에 로컬로 저장되고 기기에서 임베딩됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8295,6 +12200,12 @@ }, "Saved memory history is available from Manage." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 메모리 기록은 관리에서 확인할 수 있습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8305,6 +12216,12 @@ }, "Saving and probing…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장 및 조회 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8313,8 +12230,30 @@ } } }, + "Scan frequency" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스캔 빈도" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扫描频率" + } + } + } + }, "Scan with the RxCode app on your iPhone or iPad." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone 또는 iPad의 RxCode 앱으로 스캔하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8323,8 +12262,46 @@ } } }, + "Scanned %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스캔됨 %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已扫描 %@" + } + } + } + }, + "Schedule" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计划" + } + } + } + }, "Scheme" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스킴" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8335,6 +12312,12 @@ }, "Scope" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "범위" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8361,6 +12344,12 @@ }, "Search agents" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 검색" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8387,6 +12376,12 @@ }, "Search every thread" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 스레드 검색" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8441,6 +12436,12 @@ }, "Search Index" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색 색인" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8451,6 +12452,12 @@ }, "Search memory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 검색" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8459,6 +12466,22 @@ } } }, + "Search release repositories" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 저장소 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索发布仓库" + } + } + } + }, "Search repos..." : { "localizations" : { "en" : { @@ -8521,6 +12544,12 @@ }, "Search Threads (⌘K)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 검색 (⌘K)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8530,11 +12559,30 @@ } }, "Search threads and docs…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 및 문서 검색…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索对话和文档…" + } + } + } }, "Search threads by topic, keyword, or feel…" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주제, 키워드 또는 느낌으로 스레드 검색…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8559,6 +12607,22 @@ } } }, + "Select a dispatchable release workflow first (re-add the repo to rescan if needed)." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "먼저 디스패치 가능한 릴리스 워크플로를 선택하세요(필요하면 저장소를 다시 추가하여 재스캔)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先选择可调度的发布工作流(如有需要,重新添加仓库以重新扫描)。" + } + } + } + }, "Select a Project" : { "localizations" : { "en" : { @@ -8621,6 +12685,12 @@ }, "Select a target…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상 선택…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8631,6 +12701,12 @@ }, "Select All in Section" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "섹션 내 모두 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8641,6 +12717,12 @@ }, "Select all that apply" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "해당하는 항목 모두 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8695,6 +12777,12 @@ }, "Select or add a hook to edit" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집할 Hook을 선택하거나 추가하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8705,6 +12793,12 @@ }, "Select or add a profile to edit" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집할 프로필을 선택하거나 추가하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8715,6 +12809,12 @@ }, "Select relay" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8725,6 +12825,12 @@ }, "Select Run Destination" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 대상 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8735,6 +12841,12 @@ }, "Select Run Profile" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 프로필 선택" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8743,8 +12855,30 @@ } } }, + "Selected release workflow" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택된 릴리스 워크플로" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已选发布工作流" + } + } + } + }, "Send test notification" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테스트 알림 보내기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8777,7 +12911,13 @@ }, "Servers" : { "localizations" : { - "zh-Hans" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버" + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "服务器" @@ -8809,6 +12949,12 @@ }, "Set the summarization model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약 모델 설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8834,7 +12980,20 @@ } }, "Set Up Docs Search" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 검색 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置文档搜索" + } + } + } }, "Set up documentation so it's searchable" : { "localizations" : { @@ -8868,8 +13027,30 @@ } } }, + "Set up releases for this repository" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 저장소의 릴리스 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为此仓库设置发布" + } + } + } + }, "Set up your first MCP server" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "첫 MCP 서버 설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8880,6 +13061,12 @@ }, "Settings" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8888,6 +13075,22 @@ } } }, + "Setup templates" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 템플릿" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置模板" + } + } + } + }, "Shortcuts" : { "localizations" : { "en" : { @@ -8912,6 +13115,12 @@ }, "Show active chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성 채팅 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8922,6 +13131,12 @@ }, "Show all chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 채팅 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8954,6 +13169,12 @@ }, "Show all threads in this project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트의 모든 스레드 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8964,6 +13185,12 @@ }, "Show archived chats" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관된 채팅 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -8996,6 +13223,12 @@ }, "Show Hidden Files" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨김 파일 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9044,6 +13277,12 @@ }, "Show menu bar icon" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메뉴 막대 아이콘 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9054,6 +13293,12 @@ }, "Show more" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "더 보기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9064,6 +13309,12 @@ }, "Show Onboarding" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온보딩 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9074,6 +13325,12 @@ }, "Show only the first five threads" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "처음 다섯 개 스레드만 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9084,6 +13341,12 @@ }, "Show thread summary" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 요약 표시" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9094,6 +13357,12 @@ }, "Showing effective MCP state for this project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트의 유효한 MCP 상태 표시 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9104,6 +13373,12 @@ }, "Showing global MCP defaults" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 MCP 기본값 표시 중" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9114,6 +13389,12 @@ }, "Shown only — nothing is passed back to the session." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시만 됨 — 세션으로 다시 전달되지 않습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9124,6 +13405,12 @@ }, "Shows in-progress chat counts and Claude Code usage limits in the macOS menu bar." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 메뉴 막대에 진행 중인 채팅 수와 Claude Code 사용 한도를 표시합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9333,6 +13620,12 @@ }, "sk-..." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "sk-..." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9366,6 +13659,12 @@ "Skip" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건너뛰기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9376,6 +13675,12 @@ }, "Skip All Questions" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 질문 건너뛰기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9407,10 +13712,29 @@ } }, "Slug (e.g. architecture/overview)" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "슬러그(예: architecture/overview)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slug(例如 architecture/overview)" + } + } + } }, "Source" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "소스" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9421,6 +13745,12 @@ }, "Start New Chat" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 채팅 시작" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9431,6 +13761,12 @@ }, "Stop '%@'" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' 중지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9441,6 +13777,12 @@ }, "Stop %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 중지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9451,6 +13793,12 @@ }, "Stop All" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 중지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9461,6 +13809,12 @@ }, "Stop running tasks" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 중인 작업 중지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9469,6 +13823,22 @@ } } }, + "Stop watching %@?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 감시를 중지하시겠습니까?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止监视 %@?" + } + } + } + }, "Store .env files end-to-end encrypted with your passkey. Secrets are encrypted on this device and never leave it unencrypted." : { "localizations" : { "ko" : { @@ -9488,6 +13858,12 @@ "streaming stops" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스트리밍 중지" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9498,6 +13874,12 @@ }, "Submit" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제출" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9506,8 +13888,30 @@ } } }, + "Success" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "성공" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功" + } + } + } + }, "Summarization Model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약 모델" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9518,6 +13922,12 @@ }, "Switch between Claude Code, Codex, and installed ACP agents without changing the global default." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값을 변경하지 않고 Claude Code, Codex, 설치된 ACP 에이전트 간에 전환하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9528,6 +13938,12 @@ }, "Switch branch" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브랜치 전환" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9561,6 +13977,12 @@ "Tap to answer" : { "comment" : "Notification body when Claude invokes AskUserQuestion.", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭하여 답변" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9571,6 +13993,12 @@ }, "Target" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9579,8 +14007,30 @@ } } }, + "Templates of GitHub merge settings and an optional branch ruleset. Your default template is applied automatically to new repositories." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 병합 설정과 선택적 브랜치 규칙 세트의 템플릿입니다. 기본 템플릿은 새 저장소에 자동으로 적용됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 合并设置和可选分支规则集的模板。你的默认模板将自动应用于新仓库。" + } + } + } + }, "Test connection" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 테스트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9591,6 +14041,12 @@ }, "Testing…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테스트 중…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9601,6 +14057,12 @@ }, "The agent wants to run a command in this project." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트가 이 프로젝트에서 명령을 실행하려고 합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9611,6 +14073,12 @@ }, "The CLI sent an AskUserQuestion call this app could not parse." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CLI가 이 앱에서 구문 분석할 수 없는 AskUserQuestion 호출을 보냈습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9619,6 +14087,38 @@ } } }, + "The next version is computed by semantic-release from the commit history." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 버전은 semantic-release가 커밋 기록에서 계산합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一个版本由 semantic-release 根据提交历史计算。" + } + } + } + }, + "The release CI authenticates to GitHub with a RELEASE_TOKEN secret. Paste a GitHub token (a PAT with contents:write) and RxCode installs it as the repository's GitHub Actions secret for you — no terminal needed." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 CI는 RELEASE_TOKEN 시크릿으로 GitHub에 인증합니다. GitHub 토큰(contents:write 권한이 있는 PAT)을 붙여넣으면 RxCode가 저장소의 GitHub Actions 시크릿으로 대신 설치합니다 — 터미널이 필요 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布 CI 使用 RELEASE_TOKEN 密钥向 GitHub 进行身份验证。粘贴一个 GitHub 令牌(具有 contents:write 权限的 PAT),RxCode 会为你将其安装为仓库的 GitHub Actions 密钥 — 无需终端。" + } + } + } + }, "Theme" : { "localizations" : { "en" : { @@ -9643,6 +14143,12 @@ }, "There are no tasks to run %@." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에서 실행할 작업이 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9653,6 +14159,12 @@ }, "This agent didn't advertise a model selector. The model picker shows a single \"Default\" entry — the agent picks its own model at runtime. Click Fetch to retry." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 에이전트는 모델 선택기를 제공하지 않았습니다. 모델 선택기에는 \"Default\" 항목 하나만 표시되며, 에이전트가 런타임에 자체 모델을 선택합니다. 다시 시도하려면 가져오기를 클릭하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9663,6 +14175,12 @@ }, "This agent reports its models over ACP. RxCode refreshes the list every session start." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 에이전트는 ACP를 통해 모델을 보고합니다. RxCode는 세션을 시작할 때마다 목록을 새로 고칩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9687,8 +14205,30 @@ } } }, + "This form couldn't be rendered. Edit the raw template JSON instead (name, enabled, mergeSettings)." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 양식을 렌더링할 수 없습니다. 대신 원시 템플릿 JSON(name, enabled, mergeSettings)을 편집하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法渲染此表单。请改为编辑原始模板 JSON(name、enabled、mergeSettings)。" + } + } + } + }, "This memory will be removed from future agent context." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 메모리는 향후 에이전트 컨텍스트에서 제거됩니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9699,6 +14239,12 @@ }, "This project" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로젝트" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9725,6 +14271,12 @@ }, "This session will be deleted. This action cannot be undone." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 세션이 삭제됩니다. 이 작업은 취소할 수 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9733,6 +14285,22 @@ } } }, + "This settings form couldn't be rendered. Edit the raw JSON instead." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 설정 양식을 렌더링할 수 없습니다. 대신 원시 JSON을 편집하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法渲染此设置表单。请改为编辑原始 JSON。" + } + } + } + }, "This unregisters %@ from the docs service and removes its indexed documents. The repo's files are not affected." : { "localizations" : { "ko" : { @@ -9749,6 +14317,22 @@ } } }, + "This unregisters %@ from the release service. The repo's files, workflows, and existing GitHub releases are not affected." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 작업은 릴리스 서비스에서 %@의 등록을 해제합니다. 저장소의 파일, 워크플로, 기존 GitHub 릴리스에는 영향을 주지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此操作将从发布服务中注销 %@。仓库的文件、工作流和现有的 GitHub 发布不受影响。" + } + } + } + }, "This will remove the project from RxCode. The files on disk will not be deleted." : { "localizations" : { "en" : { @@ -9773,6 +14357,12 @@ }, "Thread Model" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 모델" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9784,6 +14374,12 @@ "Thread saved" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 저장됨" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9794,6 +14390,12 @@ }, "Threads" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9803,10 +14405,36 @@ } }, "Threads on-device · docs unavailable: %@" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 내 스레드 · 문서 사용 불가: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对话存于设备 · 文档不可用:%@" + } + } + } }, "Threads searched on-device · docs from the docs service" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드는 기기에서 검색 · 문서는 문서 서비스에서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对话在设备内搜索 · 文档来自文档服务" + } + } + } }, "Todos (%lld/%lld)" : { "localizations" : { @@ -9816,6 +14444,12 @@ "value" : "Todos (%1$lld/%2$lld)" } }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "할 일 (%lld/%lld)" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9826,6 +14460,12 @@ }, "Toggle Inspector" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검사기 토글" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9836,6 +14476,12 @@ }, "Transport" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전송" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9846,6 +14492,12 @@ }, "Trigger" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "트리거" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9854,8 +14506,30 @@ } } }, + "Trigger scan now" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금 스캔 트리거" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即触发扫描" + } + } + } + }, "Type" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유형" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9866,6 +14540,12 @@ }, "Type your answer…" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "답변을 입력하세요…" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9876,6 +14556,12 @@ }, "Unarchive" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관 해제" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9909,6 +14595,12 @@ "Untitled" : { "extractionState" : "stale", "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 없음" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9919,6 +14611,12 @@ }, "Updated %@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트됨 %@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9928,10 +14626,29 @@ } }, "Upload" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업로드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传" + } + } + } }, "URL" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9964,6 +14681,12 @@ }, "Use a GitHub repository that exposes .claude-plugin/marketplace.json." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : ".claude-plugin/marketplace.json을 노출하는 GitHub 저장소를 사용하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9974,6 +14697,12 @@ }, "Use default Makefile lookup" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 Makefile 조회 사용" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9984,6 +14713,12 @@ }, "Use hosted relay" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호스팅 릴레이 사용" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -9994,6 +14729,12 @@ }, "Use the registry to add Agent Client Protocol tools, then pick them from the thread model menu." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레지스트리를 사용하여 Agent Client Protocol 도구를 추가한 다음 스레드 모델 메뉴에서 선택하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10004,6 +14745,12 @@ }, "Use Workspace (.xcworkspace)" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 공간(.xcworkspace) 사용" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10080,6 +14827,12 @@ }, "Used for thread titles, branch briefings, and search. Defaults to your chat model." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 제목, 브랜치 브리핑, 검색에 사용됩니다. 기본값은 채팅 모델입니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10090,6 +14843,12 @@ }, "Used to generate short session titles. The default follows each thread's model." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "짧은 세션 제목을 생성하는 데 사용됩니다. 기본값은 각 스레드의 모델을 따릅니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10122,6 +14881,12 @@ }, "Uses the model picked by the current thread. No extra configuration needed." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 스레드가 선택한 모델을 사용합니다. 추가 구성이 필요 없습니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10132,6 +14897,12 @@ }, "Uses the model saved on the current thread." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 스레드에 저장된 모델을 사용합니다." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10142,6 +14913,12 @@ }, "v%@" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10152,6 +14929,12 @@ }, "value" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "값" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10162,6 +14945,12 @@ }, "Value" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "값" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10172,6 +14961,12 @@ }, "VAR=value -j8" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VAR=value -j8" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10181,10 +14976,29 @@ } }, "Version" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버전" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "版本" + } + } + } }, "Via:" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "경유:" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10195,6 +15009,12 @@ }, "View and manage saved memories" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 메모리 보기 및 관리" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10205,6 +15025,12 @@ }, "View details" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세부 정보 보기" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10213,6 +15039,38 @@ } } }, + "View PR" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PR 보기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看 PR" + } + } + } + }, + "View workflow run on GitHub" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub에서 워크플로 실행 보기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 GitHub 上查看工作流运行" + } + } + } + }, "Waiting for authentication..." : { "extractionState" : "stale", "localizations" : { @@ -10236,6 +15094,38 @@ } } }, + "Watch repositories, set scan schedules, and review scan history and pull requests." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장소를 감시하고 스캔 일정을 설정하며 스캔 기록과 풀 리퀘스트를 검토하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监视仓库、设置扫描计划,并查看扫描历史和拉取请求。" + } + } + } + }, + "Watch Repository for CI Updates" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 업데이트를 위해 저장소 감시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监视仓库以进行 CI 更新" + } + } + } + }, "Welcome to RxCode" : { "localizations" : { "ko" : { @@ -10286,6 +15176,12 @@ }, "Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty." : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캐시된 임베딩을 지우고 의미 검색을 위해 모든 스레드를 다시 임베딩합니다. 전역 검색 결과가 오래되었거나 비어 있으면 사용하세요." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10296,6 +15192,12 @@ }, "Work with coding agents in one native app" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하나의 네이티브 앱에서 코딩 에이전트와 작업하세요" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10304,6 +15206,22 @@ } } }, + "Workflow" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流" + } + } + } + }, "Workflow “%@” failed" : { "comment" : "CI failure notification body for a single failing workflow. %@ is the workflow name.", "localizations" : { @@ -10321,8 +15239,46 @@ } } }, + "Workflow inputs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 입력" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流输入" + } + } + } + }, + "Workflows (%lld)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워크플로 (%lld)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流 (%lld)" + } + } + } + }, "Working Directory" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업 디렉터리" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10349,6 +15305,12 @@ }, "wss://relay.example.com" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10359,6 +15321,12 @@ }, "Xcode" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10369,6 +15337,12 @@ }, "You" : { "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "나" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -10376,6 +15350,9 @@ } } } + }, + "You're already in this setup chat" : { + } }, "version" : "1.1" diff --git a/RxCode/Services/AutopilotService.swift b/RxCode/Services/AutopilotService.swift index 966a59a..90be07c 100644 --- a/RxCode/Services/AutopilotService.swift +++ b/RxCode/Services/AutopilotService.swift @@ -112,8 +112,74 @@ final class AutopilotService { return try await post(url: url, body: CIStatusBatchRequest(repos: repos)) } + /// `POST /api/v1/pull-requests` — open a pull request for `head` → `base` + /// (base defaults to the repo's default branch when nil) via the signed-in + /// user's GitHub App installation. The head branch must already be pushed. + func createPullRequest(_ request: CreatePullRequestRequest) async throws -> CreatePullRequestResponse { + let url = baseURL.appendingPathComponent("/api/v1/pull-requests") + return try await post(url: url, body: request) + } + + // MARK: - Automation settings + + /// `GET /api/v1/automation/schema` — the JSON Schema + ui schema describing + /// the autopilot automation settings form. + func getAutomationSchema() async throws -> SchemaEnvelope { + try await get(url: baseURL.appendingPathComponent("/api/v1/automation/schema")) + } + + /// `GET /api/v1/preferences` — the user's saved automation settings values. + func getPreferences() async throws -> PreferencesValues { + try await get(url: baseURL.appendingPathComponent("/api/v1/preferences")) + } + + /// `PUT /api/v1/preferences` — persist new automation settings values. + @discardableResult + func putPreferences(_ body: PreferencesValues) async throws -> PreferencesValues { + try await send(method: "PUT", url: baseURL.appendingPathComponent("/api/v1/preferences"), body: body) + } + + // MARK: - Repo setup templates + + /// `GET /api/v1/repo-setup/schema` — the JSON Schema + ui schema for a + /// repo-setup template's merge settings. + func getRepoSetupSchema() async throws -> SchemaEnvelope { + try await get(url: baseURL.appendingPathComponent("/api/v1/repo-setup/schema")) + } + + func listSetupTemplates() async throws -> RepoSetupTemplateList { + try await get(url: baseURL.appendingPathComponent("/api/v1/repo-setup/templates")) + } + + func getSetupTemplate(id: String) async throws -> RepoSetupTemplate { + try await get(url: templateURL(id)) + } + + func createSetupTemplate(_ body: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await send(method: "POST", url: baseURL.appendingPathComponent("/api/v1/repo-setup/templates"), body: body) + } + + func updateSetupTemplate(id: String, _ body: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await send(method: "PUT", url: templateURL(id), body: body) + } + + func deleteSetupTemplate(id: String) async throws { + let _: Ignored = try await send(method: "DELETE", url: templateURL(id)) + } + + /// Percent-encodes a template id into the `/repo-setup/templates/{id}` path. + private func templateURL(_ id: String) -> URL { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + let encoded = id.addingPercentEncoding(withAllowedCharacters: allowed) ?? id + return baseURL.appendingPathComponent("/api/v1/repo-setup/templates/\(encoded)") + } + // MARK: - Internal + /// Sentinel for endpoints that return no decodable body (e.g. 204). + private struct Ignored: Decodable {} + private func get(url: URL) async throws -> T { try await performWithRetry { token in var request = URLRequest(url: url) @@ -142,6 +208,36 @@ final class AutopilotService { } } + /// Like `post`, but with an arbitrary HTTP method (PUT/POST) and a body. + private func send(method: String, url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + + /// A bodyless request with an arbitrary HTTP method (DELETE). + private func send(method: String, url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + /// Sign the request with the current bearer, hit the network, and retry /// exactly once after a refresh if the server returned 401. A second 401 /// posts `.rxAuthSessionExpired` so the UI can re-present sign-in. @@ -157,9 +253,11 @@ final class AutopilotService { } if http.statusCode == 401 { - // One silent refresh + retry. If that fails too, surface the - // session-expired notification so the UI re-presents sign-in. - guard let refreshed = await rxAuth.accessToken() else { + // One silent refresh + retry. Force a refresh so the retry uses a + // brand-new token: the cached one was just rejected, and its + // keychain expiry may still look fresh. If that fails too, surface + // the session-expired notification so the UI re-presents sign-in. + guard let refreshed = await rxAuth.accessToken(forceRefresh: true) else { NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) throw ServiceError.notAuthenticated } @@ -183,6 +281,9 @@ final class AutopilotService { let body = String(data: data, encoding: .utf8) ?? "no body" throw ServiceError.apiError(response.statusCode, body) } + if T.self == Ignored.self { + return Ignored() as! T + } do { return try JSONDecoder().decode(T.self, from: data) } catch { diff --git a/RxCode/Services/CIUpdates/CIUpdateService.swift b/RxCode/Services/CIUpdates/CIUpdateService.swift new file mode 100644 index 0000000..ba83264 --- /dev/null +++ b/RxCode/Services/CIUpdates/CIUpdateService.swift @@ -0,0 +1,223 @@ +import Foundation +import RxCodeCore +import os + +/// Talks to github-pm's CI auto-update API (the "watched repositories" feature, +/// same host as `AutopilotService` / `SecretsService` / `DocsService`, +/// `https://autopilot.rxlab.app`) using the rxauth bearer. Powers the CI +/// Auto-Update management UI in Settings → Autopilot and the setup banner. +/// Transport mirrors `SecretsService` / `DocsService` exactly. +@MainActor +final class CIUpdateService { + + enum ServiceError: LocalizedError { + case notAuthenticated + case invalidResponse + case apiError(Int, String) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Not signed in. Please sign in with rxlab." + case .invalidResponse: + return "Received an invalid response from the CI update service." + case .apiError(let code, let detail): + return "CI update service error (\(code)): \(detail)" + case .decodingError(let detail): + return "Failed to decode CI update response: \(detail)" + } + } + } + + private let rxAuth: RxAuthService + private let logger = Logger(subsystem: "com.claudework", category: "CIUpdateService") + private let session: URLSession = .shared + + init(rxAuth: RxAuthService) { + self.rxAuth = rxAuth + } + + var baseURL: URL { + if let override = Bundle.main.object(forInfoDictionaryKey: "CIUpdateBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + if let override = Bundle.main.object(forInfoDictionaryKey: "AutopilotBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + return URL(string: "https://autopilot.rxlab.app")! + } + + // MARK: - Watched repositories (CRUD) + + func listWatchedRepositories(cursor: String? = nil, pageSize: Int? = nil) async throws -> WatchedRepoPage { + var items: [URLQueryItem] = [] + if let cursor, !cursor.isEmpty { items.append(.init(name: "cursor", value: cursor)) } + if let pageSize { items.append(.init(name: "pageSize", value: String(pageSize))) } + return try await get(url: url("/api/v1/watched-repositories", query: items)) + } + + func addWatchedRepository(_ body: AddWatchedRepoBody) async throws -> CIUpdateIDResponse { + try await send(method: "POST", url: url("/api/v1/watched-repositories"), body: body) + } + + func updateScanFrequency(id: String, frequency: CIScanFrequency) async throws { + let _: Ignored = try await send( + method: "PATCH", + url: url("/api/v1/watched-repositories/\(seg(id))"), + body: UpdateScanFrequencyBody(scanFrequency: frequency) + ) + } + + func deleteWatchedRepository(id: String) async throws { + let _: Ignored = try await send(method: "DELETE", url: url("/api/v1/watched-repositories/\(seg(id))")) + } + + // MARK: - Status (batch, banner gating) + + /// Batch-fetches watch status for `repos` (each `owner/repo`). Returns one + /// entry per requested repo; unwatched repos report `watchedRepositoryId: + /// nil`. Used to gate the "set up CI auto-update" banner. + func statuses(forRepos repos: [String]) async throws -> [CIWatchStatus] { + guard !repos.isEmpty else { return [] } + let list: CIWatchStatusList = try await send( + method: "POST", + url: url("/api/v1/watched-repositories/status"), + body: CIWatchStatusRequest(repositories: repos) + ) + return list.items + } + + // MARK: - Scan history + + /// Newest-first scan runs for a watched repo. Returns a bare array (the + /// server does not wrap this one in `{ items: [...] }`). + func history(id: String, limit: Int? = nil) async throws -> [CIUpdateRunHistory] { + var items: [URLQueryItem] = [] + if let limit { items.append(.init(name: "limit", value: String(limit))) } + return try await get(url: url("/api/v1/watched-repositories/\(seg(id))/history", query: items)) + } + + // MARK: - Manual trigger + + func trigger(id: String) async throws -> CITriggerResponse { + try await send(method: "POST", url: url("/api/v1/watched-repositories/\(seg(id))/trigger")) + } + + // MARK: - Pull requests + + func pullRequests(id: String) async throws -> [CIPullRequest] { + try await get(url: url("/api/v1/watched-repositories/\(seg(id))/prs")) + } + + /// Closes a PR opened by the auto-updater. The PR to close is identified by + /// number in the request body. + func closePullRequest(id: String, prNumber: Int) async throws { + let _: Ignored = try await send( + method: "DELETE", + url: url("/api/v1/watched-repositories/\(seg(id))/prs"), + body: ClosePRBody(prNumber: prNumber) + ) + } + + // MARK: - URL building + + /// Percent-encodes a single path segment, including any `/` in an + /// `owner/repo` identifier so it stays one segment. + private func seg(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func url(_ path: String, query: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = (components.percentEncodedPath) + path + if !query.isEmpty { components.queryItems = query } + return components.url! + } + + // MARK: - Transport (mirrors SecretsService / DocsService) + + private struct Ignored: Decodable {} + + private func get(url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func send(method: String, url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + + private func send(method: String, url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func performWithRetry(_ build: (String) -> URLRequest) async throws -> T { + guard let token = await rxAuth.accessToken() else { + throw ServiceError.notAuthenticated + } + let request = build(token) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + + if http.statusCode == 401 { + guard let refreshed = await rxAuth.accessToken(forceRefresh: true) else { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + let retried = build(refreshed) + let (data2, response2) = try await session.data(for: retried) + guard let http2 = response2 as? HTTPURLResponse else { throw ServiceError.invalidResponse } + if http2.statusCode == 401 { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + return try decode(data: data2, response: http2) + } + return try decode(data: data, response: http) + } + + private func decode(data: Data, response: HTTPURLResponse) throws -> T { + guard (200..<300).contains(response.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "no body" + throw ServiceError.apiError(response.statusCode, body) + } + if T.self == Ignored.self { + return Ignored() as! T + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + } +} diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift index f6b586a..62d0e74 100644 --- a/RxCode/Services/ClaudeService+Summaries.swift +++ b/RxCode/Services/ClaudeService+Summaries.swift @@ -161,6 +161,15 @@ extension ClaudeCodeServer { return await generatePlainSummary(prompt: prompt, model: model, limit: 1000) } + func generatePullRequestContent( + briefing: String, + branch: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let prompt = OpenAISummarizationService.pullRequestPrompt(briefing: briefing, branch: branch) + return await generatePlainSummary(prompt: prompt, model: model, limit: 4000) + } + func generatePlainSummary(prompt: String, model: String, limit: Int) async -> String? { guard let binary = await findClaudeBinary() else { return nil } let emptyMCPConfigPath = writeEmptyMCPConfig() diff --git a/RxCode/Services/CodexAppServer+Summaries.swift b/RxCode/Services/CodexAppServer+Summaries.swift index ea45be4..54501de 100644 --- a/RxCode/Services/CodexAppServer+Summaries.swift +++ b/RxCode/Services/CodexAppServer+Summaries.swift @@ -249,7 +249,7 @@ extension CodexAppServer { } func cleanTitle(_ raw: String) -> String? { - let cleaned = raw + let cleaned = ChatSession.stripMarkdownEmphasis(from: raw) .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) guard !cleaned.isEmpty else { return nil } diff --git a/RxCode/Services/Docs/DocsService.swift b/RxCode/Services/Docs/DocsService.swift index e911f2e..fe52b96 100644 --- a/RxCode/Services/Docs/DocsService.swift +++ b/RxCode/Services/Docs/DocsService.swift @@ -265,7 +265,7 @@ final class DocsService { guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } if http.statusCode == 401 { - guard let refreshed = await rxAuth.accessToken() else { + guard let refreshed = await rxAuth.accessToken(forceRefresh: true) else { NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) throw ServiceError.notAuthenticated } diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index c51bf39..959b824 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -1,5 +1,6 @@ import Foundation import FoundationModels +import RxCodeCore import os /// On-device summarization powered by Apple's Foundation Models framework @@ -167,6 +168,15 @@ actor FoundationModelSummarizationService { return cleanSummary(raw, limit: 1000) } + func generatePullRequestContent(briefing: String, branch: String) async -> String? { + let prompt = OpenAISummarizationService.pullRequestPrompt(briefing: briefing, branch: branch) + let raw = await respond( + instructions: "You write GitHub pull request titles (Conventional Commits) and concise markdown descriptions. Output only the title line, a blank line, then the description.", + prompt: prompt + ) + return cleanSummary(raw, limit: 4000) + } + private func respond(instructions: String, prompt: String) async -> String? { guard Self.isAvailable else { return nil } return await respond(instructions: instructions, prompt: prompt, allowRollingWindow: true) @@ -318,7 +328,7 @@ actor FoundationModelSummarizationService { private func cleanTitle(_ raw: String?) -> String? { guard let raw else { return nil } - let cleaned = raw + let cleaned = ChatSession.stripMarkdownEmphasis(from: raw) .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) guard !cleaned.isEmpty else { return nil } diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 113615d..41c4d54 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -171,10 +171,17 @@ final class AppStateHookController: HookController { func dismissBanner(id: String, in surface: HookBannerSurface) { guard let app else { return } - guard var items = app.hookBanners[surface] else { return } + guard var items = app.hookBanners[surface] else { + logger.debug("[Hook] dismissBanner: id=\(id, privacy: .public) surface=\(surface.rawValue, privacy: .public) — no banners in surface, no-op") + return + } let before = items.count items.removeAll { $0.id == id } - guard items.count != before else { return } + guard items.count != before else { + let present = items.map(\.id).joined(separator: ",") + logger.debug("[Hook] dismissBanner: id=\(id, privacy: .public) surface=\(surface.rawValue, privacy: .public) — id not found, no-op. present=[\(present, privacy: .public)]") + 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)") } @@ -250,15 +257,44 @@ final class AppStateHookController: HookController { ) } + // MARK: CI auto-update + + func ciRepoIsWatched(repoFullName: String) async -> Bool? { + guard let app else { return nil } + do { + let statuses = try await app.ciUpdates.statuses(forRepos: [repoFullName]) + // nil (not false) only on a failed/cancelled check — a successful + // lookup that finds no row legitimately means "not watched". + return statuses.first(where: { $0.repositoryFullName.lowercased() == repoFullName.lowercased() })?.isWatched ?? false + } catch { + logger.error("ciRepoIsWatched failed: \(error.localizedDescription)") + return nil + } + } + // MARK: Docs func docsIndexed(repoFullName: String) async -> Bool? { guard let app else { return nil } do { let statuses = try await app.docs.statuses(forRepos: [repoFullName]) + guard let s = statuses.first(where: { $0.repository.lowercased() == repoFullName.lowercased() }) else { + logger.debug("[Hook] docsIndexed(\(repoFullName, privacy: .public)): no matching status row in \(statuses.count, privacy: .public) result(s) → treating as not set up") + return false + } // nil (not false) only on a failed/cancelled check — a successful // lookup that finds no row legitimately means "no docs". - return statuses.first(where: { $0.repository.lowercased() == repoFullName.lowercased() })?.hasDocs ?? false + // + // Gate on registration (`docsRepositoryId != nil`), not `hasDocs`: once + // the repo is registered with the docs service we should stop prompting + // setup. `hasDocs` additionally requires documents to be uploaded AND + // embedded, which only lands after the docs-publishing CI runs (i.e. + // after the workflow PR merges to the default branch) — so it stays + // false for a freshly set-up repo and would keep the banner up even + // though setup is effectively done. + let registered = s.docsRepositoryId != nil + logger.debug("[Hook] docsIndexed(\(repoFullName, privacy: .public)): registered=\(registered, privacy: .public) hasDocs=\(s.hasDocs, privacy: .public) documentsCount=\(s.documentsCount ?? -1, privacy: .public) readyCount=\(s.readyCount ?? -1, privacy: .public) docsRepositoryId=\(s.docsRepositoryId ?? "nil", privacy: .public)") + return registered } catch { logger.error("docsIndexed failed: \(error.localizedDescription)") return nil @@ -270,4 +306,67 @@ final class AppStateHookController: HookController { app.pendingDocsSetupProjectId = nil return DocsSkill.systemPrompt } + + // MARK: Release + + func releaseConfigured(repoFullName: String) async -> Bool? { + guard let app else { return nil } + do { + let statuses = try await app.release.statuses(forRepos: [repoFullName]) + if let s = statuses.first(where: { $0.fullName.lowercased() == repoFullName.lowercased() }) { + logger.debug("[Hook] releaseConfigured(\(repoFullName, privacy: .public)): isManaged=\(s.isManaged, privacy: .public) hasReleaseWorkflow=\(s.hasReleaseWorkflow, privacy: .public) latestVersion=\(s.latestVersion ?? "nil", privacy: .public)") + } else { + logger.debug("[Hook] releaseConfigured(\(repoFullName, privacy: .public)): no matching status row in \(statuses.count, privacy: .public) result(s) → treating as not managed") + } + // nil (not false) only on a failed/cancelled check — a successful + // lookup that finds no row legitimately means "not set up". + // + // Gate on `isManaged`, not `hasReleaseWorkflow`: once the repo is + // registered with the release service we should stop prompting setup. + // `hasReleaseWorkflow` additionally requires a *selected dispatchable* + // workflow, which only lands after the release workflow PR merges into + // the default branch — so it stays false for a freshly set-up repo and + // would keep the banner up even though setup is effectively done. + return statuses.first(where: { $0.fullName.lowercased() == repoFullName.lowercased() })?.isManaged ?? false + } catch { + logger.error("releaseConfigured failed: \(error.localizedDescription)") + return nil + } + } + + func consumePendingReleaseSetupSkill(projectId: UUID) -> String? { + guard let app, app.pendingReleaseSetupProjectId == projectId else { return nil } + app.pendingReleaseSetupProjectId = nil + return ReleaseSkill.systemPrompt + } + + // MARK: Setup-session tracking + + func markSetupSession(kind: String, sessionKey: String) { + app?.setupSessionKeys[kind, default: []].insert(sessionKey) + logger.debug("[Hook] markSetupSession: kind=\(kind, privacy: .public) sessionKey=\(sessionKey, privacy: .public)") + } + + func isSetupSession(kind: String, sessionKey: String) -> Bool { + guard let app else { return false } + // Canonical, redirect-aware check lives on AppState (shared with the setup + // banners). The CLI rotates the session id mid-life (`pending-` → + // real sid, and again on `compact_boundary`), so the marker stored when + // the skill was injected won't raw-match a later turn's key. + let matched = app.isSetupSession(kind: kind, sessionKey: sessionKey) + if !matched, let keys = app.setupSessionKeys[kind], !keys.isEmpty { + let target = app.resolveCurrentSessionId(sessionKey) + logger.debug("[Hook] isSetupSession(kind=\(kind, privacy: .public)): no match. query=\(sessionKey, privacy: .public)→\(target, privacy: .public) stored=[\(keys.map { "\($0)→\(app.resolveCurrentSessionId($0))" }.joined(separator: ","), privacy: .public)]") + } + return matched + } + + func clearSetupSession(kind: String, sessionKey: String) { + guard let app, let keys = app.setupSessionKeys[kind] else { return } + // Remove every stored key that resolves to the same canonical sid as the + // one being cleared (mirrors the redirect-aware match in `isSetupSession`). + let target = app.resolveCurrentSessionId(sessionKey) + let stale = keys.filter { app.resolveCurrentSessionId($0) == target } + app.setupSessionKeys[kind]?.subtract(stale) + } } diff --git a/RxCode/Services/Hooks/hooks/CIUpdateHook.swift b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift new file mode 100644 index 0000000..aefe10c --- /dev/null +++ b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift @@ -0,0 +1,72 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// Surfaces a "set up CI auto-update" banner on the new-chat screen when the +/// project has local `.github/workflows/*.yml|yaml` files but its repo isn't yet +/// watched for CI auto-updates. Passive — never blocks the chat. Mirrors +/// `DocsHook` / `AutopilotHook`. +@MainActor +final class CIUpdateHook: Hook { + let hookID = "builtin.ciupdate" + private let logger = Logger(subsystem: "com.claudework", category: "CIUpdateHook") + + /// Stable banner id for a repo, distinct from the secrets/docs banners so all + /// can coexist above the input box, e.g. repo "owner/github-pm" → + /// "github-pm-new-project-ci-update". + 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-ci-update" + } + + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { + guard let bannerID = bannerID(for: payload.project) else { return .ignored } + controller.clearBannerDismissal(id: bannerID) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } + + /// On each new chat, if the project has local workflow files and the repo + /// isn't watched, surface the setup banner above the input box. + func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { + guard let project = controller.project(for: payload.projectId) else { return .ignored } + guard let repo = project.gitHubRepo else { return .ignored } + + let bannerID = bannerID(for: project) ?? "\(repo)-new-project-ci-update" + if controller.isBannerDismissed(id: bannerID) { return .ignored } + + // Local gate first: no workflow files → nothing to keep updated. + let detected = DetectedWorkflow.scan(directory: project.path) + guard !detected.isEmpty else { + controller.dismissBanner(id: bannerID, in: .newProject) + return .ignored + } + + guard let isWatched = await controller.ciRepoIsWatched(repoFullName: repo) else { + // Inconclusive (signed out / offline / failed) — leave the banner + // untouched rather than flashing a stale prompt. + return .ignored + } + guard !Task.isCancelled else { return .ignored } + + if isWatched { + // Already watched — make sure no stale banner lingers. + controller.dismissBanner(id: bannerID, in: .newProject) + return .ignored + } + + let path = project.path + let count = detected.count + logger.debug("[Hook] repo \(repo, privacy: .public) has \(count, privacy: .public) workflow file(s) and isn't watched — showing CI banner \(bannerID, privacy: .public)") + controller.showBanner(in: .newProject, position: .aboveInputBox, id: bannerID, projectId: project.id) { + CIUpdateBanner(repo: repo, projectPath: path, workflowCount: count) { + controller.markBannerDismissed(id: bannerID, in: .newProject) + } + } + return .proceed + } +} +#endif diff --git a/RxCode/Services/Hooks/hooks/DocsHook.swift b/RxCode/Services/Hooks/hooks/DocsHook.swift index baa10e1..d32743a 100644 --- a/RxCode/Services/Hooks/hooks/DocsHook.swift +++ b/RxCode/Services/Hooks/hooks/DocsHook.swift @@ -35,7 +35,10 @@ final class DocsHook: Hook { guard let repo = project.gitHubRepo else { return .ignored } let bannerID = bannerID(for: project) ?? "\(repo)-new-project-docs" - if controller.isBannerDismissed(id: bannerID) { return .ignored } + if controller.isBannerDismissed(id: bannerID) { + logger.debug("[Hook] onProjectNewChatStart: banner \(bannerID, privacy: .public) is in the user-dismissed set — not showing (docs status not checked)") + return .ignored + } guard let hasDocs = await controller.docsIndexed(repoFullName: repo) else { // Inconclusive (signed out / offline / failed) — leave the banner @@ -66,8 +69,52 @@ final class DocsHook: Hook { guard let skill = controller.consumePendingDocsSetupSkill(projectId: payload.project.id) else { return .ignored } + // Remember this session so `afterSessionEnd` knows to re-check the docs + // status once the agent is done and drop the banner if docs are now indexed. + controller.markSetupSession(kind: HookSetupKind.docs, sessionKey: payload.sessionKey) logger.debug("[Hook] injecting docs-publishing skill into session for project \(payload.project.id.uuidString, privacy: .public)") return .output(skill) } + + /// On every completed turn of a docs-setup chat (started from the banner), + /// re-fetch the latest docs status and dismiss the banner if the repo now has + /// docs indexed. The session marker is *peeked*, not consumed — docs setup can + /// span multiple turns; we keep re-checking until it's confirmed, then stop + /// tracking. + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + guard controller.isSetupSession(kind: HookSetupKind.docs, sessionKey: payload.sessionKey) else { + logger.debug("[Hook] afterSessionEnd: session \(payload.sessionKey, privacy: .public) is not a docs-setup session — ignoring") + return .ignored + } + guard payload.reason == .completed, !payload.turnDidError else { + logger.debug("[Hook] afterSessionEnd: session \(payload.sessionKey, privacy: .public) reason=\(String(describing: payload.reason), privacy: .public) turnDidError=\(payload.turnDidError, privacy: .public) — not a clean completion, keeping marker") + return .ignored + } + guard let repo = payload.project.gitHubRepo else { + logger.debug("[Hook] afterSessionEnd: project \(payload.project.id.uuidString, privacy: .public) has no gitHubRepo — clearing marker") + controller.clearSetupSession(kind: HookSetupKind.docs, sessionKey: payload.sessionKey) + return .ignored + } + + let bannerID = bannerID(for: payload.project) ?? "\(repo)-new-project-docs" + logger.debug("[Hook] afterSessionEnd: re-checking docs status for \(repo, privacy: .public), bannerID=\(bannerID, privacy: .public)") + + guard let hasDocs = await controller.docsIndexed(repoFullName: repo) else { + // Inconclusive (signed out / offline / failed) — keep the marker so the + // next completed turn re-checks. + logger.debug("[Hook] afterSessionEnd: docsIndexed inconclusive (nil) for \(repo, privacy: .public) — keeping marker + banner, will re-check next turn") + return .ignored + } + guard hasDocs else { + // Setup isn't done yet — keep tracking and re-check after the next turn. + logger.debug("[Hook] docs-setup turn ended but repo \(repo, privacy: .public) is not yet registered — keeping banner") + return .ignored + } + + logger.debug("[Hook] docs now set up (registered) for \(repo, privacy: .public) — dismissing banner \(bannerID, privacy: .public)") + controller.clearSetupSession(kind: HookSetupKind.docs, sessionKey: payload.sessionKey) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } } #endif diff --git a/RxCode/Services/Hooks/hooks/ReleaseHook.swift b/RxCode/Services/Hooks/hooks/ReleaseHook.swift new file mode 100644 index 0000000..fd85855 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/ReleaseHook.swift @@ -0,0 +1,122 @@ +#if os(macOS) +import Foundation +import os +import RxCodeCore +import SwiftUI + +/// Surfaces a "set up release" banner on the new-chat screen when the project's +/// GitHub repo has no release workflow configured, and — when the user accepts — +/// injects the release skill into that chat's system prompt so the agent wires +/// up the `.releaserc` + CI workflow. Mirrors `DocsHook`. +@MainActor +final class ReleaseHook: Hook { + let hookID = "builtin.release" + private let logger = Logger(subsystem: "com.claudework", category: "ReleaseHook") + + /// Stable banner id for a repo, distinct from the docs/secrets banners so all + /// can coexist, e.g. repo "owner/github-pm" → "github-pm-new-project-release". + 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-release" + } + + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { + guard let bannerID = bannerID(for: payload.project) else { return .ignored } + controller.clearBannerDismissal(id: bannerID) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } + + /// On each new chat, check whether the repo has a release workflow. If not, + /// surface the "set up release" banner above the input box. Passive — never + /// blocks. + func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { + guard let project = controller.project(for: payload.projectId) else { return .ignored } + guard let repo = project.gitHubRepo else { return .ignored } + + let bannerID = bannerID(for: project) ?? "\(repo)-new-project-release" + if controller.isBannerDismissed(id: bannerID) { + logger.debug("[Hook] onProjectNewChatStart: banner \(bannerID, privacy: .public) is in the user-dismissed set — not showing (release status not checked)") + return .ignored + } + + guard let configured = await controller.releaseConfigured(repoFullName: repo) else { + // Inconclusive (signed out / offline / failed) — leave the banner + // untouched rather than flashing a stale prompt. + return .ignored + } + guard !Task.isCancelled else { return .ignored } + + if configured { + // Repo already has releases set up — make sure no stale banner lingers. + controller.dismissBanner(id: bannerID, in: .newProject) + return .ignored + } + + logger.debug("[Hook] repo \(repo, privacy: .public) has no release workflow — showing setup banner \(bannerID, privacy: .public)") + controller.showBanner(in: .newProject, position: .aboveInputBox, id: bannerID, projectId: project.id) { + ReleaseSetupBanner(repo: repo) { + controller.markBannerDismissed(id: bannerID, in: .newProject) + } + } + return .proceed + } + + /// When the user accepted the banner, a release-setup chat was started for + /// this project. Inject the release skill into the system prompt for that one + /// chat so the agent knows how to set everything up. + func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { + guard let skill = controller.consumePendingReleaseSetupSkill(projectId: payload.project.id) else { + return .ignored + } + // Remember this session so `afterSessionEnd` knows to re-check the release + // status once the agent is done and drop the banner if it's now set up. + controller.markSetupSession(kind: HookSetupKind.release, sessionKey: payload.sessionKey) + logger.debug("[Hook] injecting release skill into session for project \(payload.project.id.uuidString, privacy: .public)") + return .output(skill) + } + + /// On every completed turn of a release-setup chat (started from the banner), + /// re-fetch the latest release setup result and dismiss the banner if the repo + /// now has a release workflow. The session marker is *peeked*, not consumed — + /// the release skill asks the user a question first, so setup spans multiple + /// turns; we keep re-checking until it's confirmed, then stop tracking. + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + guard controller.isSetupSession(kind: HookSetupKind.release, sessionKey: payload.sessionKey) else { + logger.debug("[Hook] afterSessionEnd: session \(payload.sessionKey, privacy: .public) is not a release-setup session — ignoring") + return .ignored + } + guard payload.reason == .completed, !payload.turnDidError else { + logger.debug("[Hook] afterSessionEnd: session \(payload.sessionKey, privacy: .public) reason=\(String(describing: payload.reason), privacy: .public) turnDidError=\(payload.turnDidError, privacy: .public) — not a clean completion, keeping marker") + return .ignored + } + guard let repo = payload.project.gitHubRepo else { + logger.debug("[Hook] afterSessionEnd: project \(payload.project.id.uuidString, privacy: .public) has no gitHubRepo — clearing marker") + controller.clearSetupSession(kind: HookSetupKind.release, sessionKey: payload.sessionKey) + return .ignored + } + + let bannerID = bannerID(for: payload.project) ?? "\(repo)-new-project-release" + logger.debug("[Hook] afterSessionEnd: re-checking release status for \(repo, privacy: .public), bannerID=\(bannerID, privacy: .public)") + + guard let configured = await controller.releaseConfigured(repoFullName: repo) else { + // Inconclusive (signed out / offline / failed) — keep the marker so the + // next completed turn re-checks. + logger.debug("[Hook] afterSessionEnd: releaseConfigured inconclusive (nil) for \(repo, privacy: .public) — keeping marker + banner, will re-check next turn") + return .ignored + } + guard configured else { + // Setup isn't done yet (e.g. the agent just asked which workflow to + // use). Keep tracking and re-check after the next turn. + logger.debug("[Hook] release-setup turn ended but repo \(repo, privacy: .public) is not yet managed — keeping banner") + return .ignored + } + + logger.debug("[Hook] release now configured (managed) for \(repo, privacy: .public) — dismissing banner \(bannerID, privacy: .public)") + controller.clearSetupSession(kind: HookSetupKind.release, sessionKey: payload.sessionKey) + controller.dismissBanner(id: bannerID, in: .newProject) + return .proceed + } +} +#endif diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index 1a5405b..7fc9fc1 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -58,6 +58,8 @@ extension AppState: IDEToolHandling { return try await handleSearchDocs(arguments: arguments) case "ide__setup_docs_secret": return try await handleSetupDocsSecret(arguments: arguments, sessionKey: sessionKey) + case "ide__setup_release": + return try await handleSetupRelease(arguments: arguments, sessionKey: sessionKey) case "ide__ask_user": throw IDEToolError.notSupported("ide__ask_user polyfill not implemented yet — surface the question as plain assistant text instead.") default: @@ -475,6 +477,74 @@ extension AppState: IDEToolHandling { return true } + @MainActor + private func handleSetupRelease(arguments: JSONValue, sessionKey: String) async throws -> JSONValue { + // Prefer an explicit `owner/repo`; otherwise fall back to the repo linked + // to the calling session's project. + let explicit = arguments["repository"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let repo: String + if let explicit, !explicit.isEmpty { + repo = explicit + } else if let thread = threadStore.fetch(id: sessionKey), + let linked = projects.first(where: { $0.id == thread.projectId })?.gitHubRepo, + !linked.isEmpty { + repo = linked + } else { + throw IDEToolError.invalidArguments( + "No 'repository' given and the current project has no linked GitHub repo. Pass repository as 'owner/repo'." + ) + } + // Register (and scan workflows) first if needed; throws a descriptive + // IDEToolError when the repo isn't accessible to the GitHub App. + let registered = try await ensureReleaseRepoRegistered(repo) + + // The RELEASE_TOKEN is user-supplied — only install when provided. + let token = arguments["release_token"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let token, !token.isEmpty else { + return jsonTextResult(.object([ + "registered": .bool(registered), + "secret_installed": .bool(false), + "repository": .string(repo), + "note": .string("Repository registered and workflows scanned. No release_token was provided, so RELEASE_TOKEN was not installed — ask the user for a GitHub token with contents:write and call again with release_token to finish setup."), + ])) + } + do { + let result = try await release.installReleaseToken(repoId: repo, value: token) + return jsonTextResult(.object([ + "registered": .bool(registered), + "secret_installed": .bool(true), + "secret_name": .string(result.secretName), + "repository": .string(result.repositoryFullName), + ])) + } catch { + throw IDEToolError.handlerFailed(error.localizedDescription) + } + } + + /// Ensures `repo` (an `owner/repo`) is registered with the release service, + /// registering it if not. Returns true when it had to register it. Throws a + /// descriptive `IDEToolError` when the repo isn't accessible to the GitHub + /// App (so it can't be registered). + @MainActor + private func ensureReleaseRepoRegistered(_ repo: String) async throws -> Bool { + if let status = try? await release.statuses(forRepos: [repo]).first, status.isManaged { + return false + } + guard let managed = try await findManagedRepo(fullName: repo) else { + throw IDEToolError.handlerFailed( + "\(repo) isn't set up for releases and isn't accessible to the RxLab GitHub App. Install the GitHub App on this repository, then retry." + ) + } + _ = try await release.addRepository( + AddReleaseRepoBody( + installationId: managed.installationId, + repositoryId: managed.id, + repositoryFullName: managed.fullName + ) + ) + return true + } + /// Finds the accessible GitHub repo matching `fullName` (`owner/repo`) in the /// secrets `repositories/all` listing — the source of the `installationId` + /// `repositoryId` the docs add-repo API needs. Pages defensively in case the diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 51aa9ba..1915bf4 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -245,6 +245,45 @@ actor OpenAISummarizationService { } } + func generatePullRequestContent( + briefing: String, + branch: String, + endpoint: String, + apiKey: String, + model: String + ) async -> String? { + let prompt = Self.pullRequestPrompt(briefing: briefing, branch: branch) + return await generateSummary( + prompt: prompt, + endpoint: endpoint, + apiKey: apiKey, + model: model, + maxTokens: 700 + ) + } + + /// Prompt that asks for a PR title (Conventional Commits, first line) plus a + /// blank line and a markdown body, derived from a branch briefing. Shared by + /// all summarization providers so output is consistent regardless of backend. + static func pullRequestPrompt(briefing: String, branch: String) -> String { + let trimmed = String(briefing.prefix(6_000)).trimmingCharacters(in: .whitespacesAndNewlines) + return """ + Write a GitHub pull request title and description that summarize the work on a branch, using the branch briefing below. + + Format rules (MUST follow exactly): + - The FIRST line is the PR title in Conventional Commits format: `(): ` — under 72 characters, lowercase imperative mood, no trailing period. + - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - Then exactly ONE blank line. + - Then the PR description in GitHub-flavored markdown: a short summary paragraph, then a `## Changes` section with concise bullet points covering the main work. Keep it focused. + - Do NOT wrap the output in code fences. Do NOT put the title in quotes. Do NOT prefix the title with anything (no "Title:"). + + Branch: `\(branch)` + + Branch briefing: + \(trimmed.isEmpty ? "(no briefing available — summarize from the branch name)" : trimmed) + """ + } + static func branchBriefingPrompt(threadSummaries: [(title: String, summary: String)]) -> String { let joined = threadSummaries.map { item -> String in let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -383,7 +422,7 @@ actor OpenAISummarizationService { private func cleanTitle(_ raw: String?) -> String? { guard let raw else { return nil } - let cleaned = raw + let cleaned = ChatSession.stripMarkdownEmphasis(from: raw) .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) guard !cleaned.isEmpty else { return nil } diff --git a/RxCode/Services/Release/ReleaseService.swift b/RxCode/Services/Release/ReleaseService.swift new file mode 100644 index 0000000..0111fc8 --- /dev/null +++ b/RxCode/Services/Release/ReleaseService.swift @@ -0,0 +1,230 @@ +import Foundation +import RxCodeCore +import os + +/// Talks to github-pm's release API (same host as `DocsService` / +/// `SecretsService`, `https://autopilot.rxlab.app`) using the rxauth bearer. +/// Powers the release-setup hook, the release management UI, the +/// `ide__setup_release` MCP tool, and the "Create Release" dispatch dialog. +/// Transport mirrors `DocsService` / `SecretsService` exactly. +@MainActor +final class ReleaseService { + + enum ServiceError: LocalizedError { + case notAuthenticated + case invalidResponse + case apiError(Int, String) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Not signed in. Please sign in with rxlab." + case .invalidResponse: + return "Received an invalid response from the release service." + case .apiError(let code, let detail): + return "Release service error (\(code)): \(detail)" + case .decodingError(let detail): + return "Failed to decode release response: \(detail)" + } + } + } + + private let rxAuth: RxAuthService + private let logger = Logger(subsystem: "com.claudework", category: "ReleaseService") + private let session: URLSession = .shared + + init(rxAuth: RxAuthService) { + self.rxAuth = rxAuth + } + + var baseURL: URL { + if let override = Bundle.main.object(forInfoDictionaryKey: "ReleaseBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + if let override = Bundle.main.object(forInfoDictionaryKey: "AutopilotBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + return URL(string: "https://autopilot.rxlab.app")! + } + + // MARK: - Repositories + + func listRepositories(cursor: String? = nil, pageSize: Int? = nil) async throws -> ReleaseRepoListResponse { + var items: [URLQueryItem] = [] + if let cursor, !cursor.isEmpty { items.append(.init(name: "cursor", value: cursor)) } + if let pageSize { items.append(.init(name: "pageSize", value: String(pageSize))) } + return try await get(url: url("/api/v1/release-repositories", query: items)) + } + + func addRepository(_ body: AddReleaseRepoBody) async throws -> ReleaseIDResponse { + try await send(method: "POST", url: url("/api/v1/release-repositories"), body: body) + } + + func deleteRepository(id: String) async throws { + let _: Ignored = try await send(method: "DELETE", url: url("/api/v1/release-repositories/\(seg(id))")) + } + + /// Resolves release status for `repos` (each `owner/repo`) via the batch + /// status endpoint. Returns one entry per requested repo; repos that aren't + /// set up report `isManaged: false`. Used by the release hook + sidebar / + /// briefing affordances. + func statuses(forRepos repos: [String]) async throws -> [ReleaseRepoStatus] { + guard !repos.isEmpty else { return [] } + return try await send( + method: "POST", + url: url("/api/v1/release-repositories/status"), + body: ReleaseRepoStatusRequest(repositories: repos) + ) + } + + /// Pages through every release-managed repository the signed-in user can + /// read. The managed set is small (one row per set-up repo). + func allManagedRepositories() async throws -> [ReleaseRepo] { + var all: [ReleaseRepo] = [] + var cursor: String? + repeat { + let page = try await listRepositories(cursor: cursor, pageSize: 100) + all.append(contentsOf: page.items) + cursor = (page.pagination?.hasMore == true) ? page.pagination?.nextCursor : nil + } while cursor != nil + return all + } + + // MARK: - Workflows + + /// Lists the scanned workflows for a repo. `repoId` may be the internal + /// release-repo UUID or an `owner/repo` full name. + func listWorkflows(repoId: String) async throws -> [ReleaseWorkflow] { + try await get(url: url("/api/v1/release-repositories/\(seg(repoId))/workflows")) + } + + // MARK: - Dispatch + + /// Triggers a `workflow_dispatch` on the given workflow. `repoId` may be the + /// UUID or `owner/repo`. + @discardableResult + func dispatch(repoId: String, body: ReleaseDispatchRequest) async throws -> ReleaseDispatchResult { + try await send( + method: "POST", + url: url("/api/v1/release-repositories/\(seg(repoId))/dispatch"), + body: body + ) + } + + // MARK: - GitHub secret (RELEASE_TOKEN) + + /// Installs the user-supplied `value` as the repo's `RELEASE_TOKEN` GitHub + /// Actions secret in one step (encrypted server-side). `repoId` may be the + /// UUID or `owner/repo`. Powers the release-repo UI button and the + /// `ide__setup_release` MCP tool. + @discardableResult + func installReleaseToken(repoId: String, value: String) async throws -> ReleaseGithubSecretResult { + try await send( + method: "POST", + url: url("/api/v1/release-repositories/\(seg(repoId))/github-secret"), + body: InstallReleaseTokenBody(value: value) + ) + } + + // MARK: - URL building + + /// Percent-encodes a single path segment, including any `/` in an + /// `owner/repo` identifier so it stays one segment. + private func seg(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func url(_ path: String, query: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = (components.percentEncodedPath) + path + if !query.isEmpty { components.queryItems = query } + return components.url! + } + + // MARK: - Transport (mirrors DocsService / SecretsService) + + private struct Ignored: Decodable {} + + private func get(url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func send(method: String, url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + + private func send(method: String, url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func performWithRetry(_ build: (String) -> URLRequest) async throws -> T { + guard let token = await rxAuth.accessToken() else { + throw ServiceError.notAuthenticated + } + let request = build(token) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + + if http.statusCode == 401 { + guard let refreshed = await rxAuth.accessToken(forceRefresh: true) else { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + let retried = build(refreshed) + let (data2, response2) = try await session.data(for: retried) + guard let http2 = response2 as? HTTPURLResponse else { throw ServiceError.invalidResponse } + if http2.statusCode == 401 { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + return try decode(data: data2, response: http2) + } + return try decode(data: data, response: http) + } + + private func decode(data: Data, response: HTTPURLResponse) throws -> T { + guard (200..<300).contains(response.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "no body" + throw ServiceError.apiError(response.statusCode, body) + } + if T.self == Ignored.self { + return Ignored() as! T + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + } +} diff --git a/RxCode/Services/Release/ReleaseSkill.swift b/RxCode/Services/Release/ReleaseSkill.swift new file mode 100644 index 0000000..c5766c9 --- /dev/null +++ b/RxCode/Services/Release/ReleaseSkill.swift @@ -0,0 +1,131 @@ +enum ReleaseSkill { + static let systemPrompt: String = #""" + # Skill: Set up release publishing + + You are setting up automated releases for this repository using + semantic-release, so the project can cut GitHub releases (computed versions, + changelog, git tag) from CI — triggered manually from RxCode's "Create + Release" button or on a branch push. + + Set this up end to end. **Both files below are required** — without a + `.releaserc` semantic-release errors out, so always create it. + + ## 1. Create `.releaserc` + + semantic-release's config. A good default: + + ```json + { + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] + } + ``` + + Add `@semantic-release/exec` (and a `prepare`/`verifyConditions` step) only if + the repo needs to stamp a version into a manifest (e.g. `package.json`, + `Info.plist`). Inspect the repo first and adapt the plugins to its language. + + ## 2. Create the CI workflow `.github/workflows/create-release.yaml` + + **Ask the user how releases should trigger** before writing the `on:` block: + - **Manual only** — `on: { workflow_dispatch: }`. Releases happen only when + the user clicks "Create Release" (or runs the workflow). Safest default. + - **On branch push** — add `push: { branches: [main] }` so every push to the + release branch cuts a release. Offer to also keep `workflow_dispatch`. + + A workflow that supports both (push does a dry run, manual dispatch does the + real release): + + ```yaml + name: Create Release + on: + workflow_dispatch: + push: + + jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Dry Run Release + uses: cycjimmy/semantic-release-action@v6 + if: github.event_name == 'push' + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + with: + dry_run: true + extra_plugins: | + @semantic-release/exec + - name: Create Release + uses: cycjimmy/semantic-release-action@v6 + if: github.event_name == 'workflow_dispatch' + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + with: + branch: main + extra_plugins: | + @semantic-release/exec + ``` + + If the user chose manual-only, drop the `push` trigger and the "Dry Run" + step. The workflow authenticates to GitHub with the `RELEASE_TOKEN` secret + (installed in step 3). + + ## 3. Register the repo and install the RELEASE_TOKEN secret + + The workflow reads `secrets.RELEASE_TOKEN` to push tags and create releases. + semantic-release needs a real GitHub token with `contents: write` — the + built-in `GITHUB_TOKEN` works but won't re-trigger downstream workflows, so + most projects use a Personal Access Token. + + Do this: + 1. **Ask the user to provide a GitHub token** (a fine-grained or classic PAT + with `contents: write` on this repo; if they want releases to trigger + other workflows, it must be a PAT, not the default token). + 2. Call the **`ide__setup_release`** MCP tool with `repository: "/"` + and `release_token: ""`. This registers the repo + with the release service (scanning its workflows so "Create Release" can + dispatch them) and installs the token as the repo's `RELEASE_TOKEN` GitHub + Actions secret in one step. If the repo isn't registered yet, the tool + registers it for you (the RxLab GitHub App must be installed on it). + + If the user prefers not to share a token, instruct them to either use the + built-in token (`GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` in the workflow, + then call `ide__setup_release` without `release_token` just to register the + repo) or set it themselves: + + ```bash + gh secret set RELEASE_TOKEN --body "" --repo / + ``` + + If `ide__setup_release` reports a missing-permission / 403 error, tell the + user to re-authorize the RxLab GitHub App (Actions → Secrets: read & write), + then retry. + + ## 4. Verify + + - `npx semantic-release --dry-run` locally (needs `GITHUB_TOKEN` in the env) + to confirm `.releaserc` parses and the next version is computed from the + commit history. + - Commit `.releaserc` + the workflow, then create a release from RxCode's + "Create Release" button (or run the workflow via `workflow_dispatch`) and + confirm the GitHub release + tag appear. + + Be concrete: read the repo, write both files, confirm the trigger choice with + the user, install the token via `ide__setup_release`, and tell the user + exactly what you changed. + """# +} diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift index e409ab1..3780fea 100644 --- a/RxCode/Services/RxAuthService.swift +++ b/RxCode/Services/RxAuthService.swift @@ -67,12 +67,21 @@ final class RxAuthService { /// one is missing or near expiry. Returns `nil` when the user is signed /// out or refresh failed. /// + /// Pass `forceRefresh: true` to skip the cached-token fast path and always + /// rotate to a brand-new token. Callers use this after a server `401`: the + /// keychain `expires_at` can still look fresh while the server has already + /// rejected the token (rotation, revocation, or clock skew), so reusing the + /// cached value would just 401 again. Forcing a refresh is what actually + /// recovers the session instead of surfacing a spurious "Not signed in". + /// /// Note: `OAuthManager.refreshTokenIfNeeded()` refreshes *unconditionally* /// despite its name, so we gate it ourselves with the keychain `expires_at` /// to avoid a token rotation + userinfo round trip on every autopilot call. - func accessToken() async -> String? { + func accessToken(forceRefresh: Bool = false) async -> String? { // Fast path — a cached, not-yet-expiring token needs no network hop. - if let cached = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), + // Skipped on a forced refresh, where the cached token was just rejected. + if !forceRefresh, + let cached = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), !Self.accessTokenIsExpiring(service: Self.keychainService) { return cached } diff --git a/RxCode/Services/Secrets/SecretsService.swift b/RxCode/Services/Secrets/SecretsService.swift index 1ffe198..32f8678 100644 --- a/RxCode/Services/Secrets/SecretsService.swift +++ b/RxCode/Services/Secrets/SecretsService.swift @@ -215,7 +215,7 @@ final class SecretsService { guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } if http.statusCode == 401 { - guard let refreshed = await rxAuth.accessToken() else { + guard let refreshed = await rxAuth.accessToken(forceRefresh: true) else { NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) throw ServiceError.notAuthenticated } diff --git a/RxCode/Services/ThreadSearchService.swift b/RxCode/Services/ThreadSearchService.swift index 2362dbc..e765ba0 100644 --- a/RxCode/Services/ThreadSearchService.swift +++ b/RxCode/Services/ThreadSearchService.swift @@ -207,6 +207,24 @@ actor ThreadSearchService { // time it removes the threads, so the disk side is handled there. } + /// Drop every in-memory chunk whose project is no longer known. The disk + /// rows are purged separately at launch (`ThreadStore.pruneOrphanThreads`); + /// this keeps the live index consistent regardless of whether `start()` + /// loaded before or after that prune ran. + func pruneOrphans(knownProjectIds: Set) async { + let before = index.count + for (threadId, chunks) in index { + guard let projectId = chunks.first?.projectId else { continue } + if !knownProjectIds.contains(projectId) { + index.removeValue(forKey: threadId) + } + } + let removed = before - index.count + if removed > 0 { + logger.info("Pruned \(removed) orphan thread(s) from in-memory search index") + } + } + // MARK: - Search /// Return up to `limit` thread hits, grouped by project, sorted by descending score. diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 2592b9e..7e8ba34 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -133,6 +133,42 @@ final class ThreadStore { return (orphanedSummaries.count, orphanedBriefings.count) } + /// Remove every thread — and its full per-thread footprint (todo snapshots, + /// file edits, queues, plan decisions, hook status, summaries, embedding + /// chunks) — whose project is no longer known. Mirrors + /// `deleteBriefingMetadata` but covers the data that drives history and + /// global search. Used at launch to purge orphans left behind by projects + /// that were deleted before the cascade in `deleteProject` existed (or any + /// leak), so they never resurface in the sidebar or as "Unknown project" + /// search results. Returns the number of thread rows removed. + func pruneOrphanThreads(excludingProjectIds knownProjectIds: Set) -> Int { + let threadRows = (try? context.fetch(FetchDescriptor())) ?? [] + let orphans = threadRows.filter { !knownProjectIds.contains($0.projectId) } + let orphanIds = orphans.map(\.id) + + // Also sweep embedding chunks whose owning thread row is already gone — + // these are what feed the search source directly. + let chunkRows = (try? context.fetch(FetchDescriptor())) ?? [] + let orphanChunks = chunkRows.filter { !knownProjectIds.contains($0.projectId) } + + guard !orphans.isEmpty || !orphanChunks.isEmpty else { return 0 } + + for row in orphans { context.delete(row) } + for id in orphanIds { + deleteTodoSnapshotRow(sessionId: id) + deleteFileEditRows(sessionId: id) + deleteQueueRows(sessionKey: id) + deletePlanDecisionRows(sessionId: id) + deleteHookStatusRow(sessionId: id) + deleteThreadSummaryRow(sessionId: id) + deleteEmbeddingChunkRows(threadId: id) + } + for row in orphanChunks { context.delete(row) } + save() + + return orphans.count + } + // MARK: - Writes /// Insert or update a thread row from a summary. diff --git a/RxCode/Utilities/KeychainHelper.swift b/RxCode/Utilities/KeychainHelper.swift index ee6483c..6a995f3 100644 --- a/RxCode/Utilities/KeychainHelper.swift +++ b/RxCode/Utilities/KeychainHelper.swift @@ -17,6 +17,26 @@ enum KeychainHelper { return "\(status) (\(message))" } + /// Emitted immediately *before* a `SecItem` call. A keychain permission + /// dialog blocks the call synchronously, so when one appears this is the + /// last `[Keychain]` line logged before the freeze — it identifies the + /// caller and item that triggered the prompt. Pair it with the `logTiming` + /// line that follows to see whether the call returned fast (no prompt) or + /// blocked (prompt shown). + private nonisolated static func logRequest( + op: String, + service: String, + account: String?, + caller: String, + file: String, + line: Int + ) { + let acct = account ?? "" + logger.debug( + "[Keychain] → \(op, privacy: .public) requesting service=\(service, privacy: .public) account=\(acct, privacy: .public) caller=\(file, privacy: .public):\(line, privacy: .public) \(caller, privacy: .public)" + ) + } + private nonisolated static func logTiming( op: String, service: String, @@ -33,11 +53,11 @@ enum KeychainHelper { + Double(elapsed.components.seconds) * 1e3 if elapsed > promptSuspicionThreshold { logger.warning( - "\(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms ⚠️ SLOW — likely a keychain permission prompt — caller=\(location, privacy: .public)" + "[Keychain] ⚠️ PROMPT-LIKELY \(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms — a permission dialog almost certainly blocked this call (the running binary's code signature no longer matches the item's keychain ACL). caller=\(location, privacy: .public)" ) } else { logger.debug( - "\(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms caller=\(location, privacy: .public)" + "[Keychain] ✓ \(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms caller=\(location, privacy: .public)" ) } } @@ -61,6 +81,8 @@ enum KeychainHelper { query[kSecAttrAccount as String] = account } + logRequest(op: "read", service: service, account: account, caller: caller, file: file, line: line) + let clock = ContinuousClock() let start = clock.now var result: AnyObject? @@ -105,6 +127,8 @@ enum KeychainHelper { kSecAttrAccount as String: account, ] + logRequest(op: "save", service: service, account: account, caller: caller, file: file, line: line) + let updateAttributes: [String: Any] = [kSecValueData as String: data] let clock = ContinuousClock() let start = clock.now @@ -141,6 +165,9 @@ enum KeychainHelper { kSecAttrService as String: service, kSecAttrAccount as String: account, ] + + logRequest(op: "delete", service: service, account: account, caller: caller, file: file, line: line) + let clock = ContinuousClock() let start = clock.now let status = SecItemDelete(query as CFDictionary) diff --git a/RxCode/Views/Autopilot/AutomationSettingsSheet.swift b/RxCode/Views/Autopilot/AutomationSettingsSheet.swift new file mode 100644 index 0000000..5f58ea2 --- /dev/null +++ b/RxCode/Views/Autopilot/AutomationSettingsSheet.swift @@ -0,0 +1,172 @@ +import JSONSchema +import JSONSchemaForm +import RxCodeCore +import SwiftUI + +/// "Automation Settings" sheet: fetches the automation JSON Schema + current +/// values from github-pm, renders them as a native form via `JSONSchemaForm`, +/// and PUTs the edited values back. Mirrors the webapp's +/// `/dashboard/settings/automation` page. +struct AutomationSettingsSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var schema: JSONSchema? + @State private var uiSchema: [String: Any]? + @State private var formData: FormData = .object(properties: [:]) + @State private var controller = JSONSchemaFormController() + + /// Raw values JSON used by the fallback editor when the schema can't be + /// rendered by the form package. + @State private var fallbackJSON = "{}" + @State private var useFallback = false + + @State private var isLoading = true + @State private var loadError: String? + @State private var isSaving = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + footer + } + .frame(width: 560, height: 600) + .task { await load() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Automation Settings").font(.headline) + Text("Configure what autopilot does automatically on your repositories.") + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + + @ViewBuilder + private var content: some View { + if isLoading { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let loadError { + errorState(loadError) + } else if useFallback { + fallbackEditor + } else if let schema { + Form { + JSONSchemaForm( + schema: schema, + uiSchema: uiSchema, + formData: $formData, + liveValidate: true, + showSubmitButton: false, + controller: controller + ) + .padding(16) + } + .formStyle(.grouped) + } + } + + private var fallbackEditor: some View { + VStack(alignment: .leading, spacing: 6) { + Text("This settings form couldn't be rendered. Edit the raw JSON instead.") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $fallbackJSON) + .font(.system(.body, design: .monospaced)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3))) + } + .padding(16) + } + + private func errorState(_ message: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle").font(.title2).foregroundStyle(.secondary) + Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + Button("Retry") { Task { await load() } } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var footer: some View { + HStack { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button("Cancel") { dismiss() } + Button { + Task { await save() } + } label: { + if isSaving { ProgressView().controlSize(.small) } else { Text("Save") } + } + .keyboardShortcut(.defaultAction) + .disabled(isSaving || isLoading || loadError != nil) + } + .padding(16) + } + + // MARK: - Load / Save + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + async let schemaCall = appState.automationSchema() + async let valuesCall = appState.automationValues() + let (env, prefs) = try await (schemaCall, valuesCall) + + uiSchema = env.uiSchema?.foundationObject as? [String: Any] + fallbackJSON = prefs.values.prettyJSONString + do { + let parsed = try JSONSchema(jsonString: env.schema.rawJSONString) + let seeded = try FormData.fromJSONString(prefs.values.rawJSONString) + formData = seeded.applyingDefaults(schema: parsed) + schema = parsed + useFallback = false + } catch { + useFallback = true + } + } catch { + loadError = error.localizedDescription + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let values: JSONValue + if useFallback { + guard let data = fallbackJSON.data(using: .utf8), + let parsed = try? JSONValue(jsonData: data) + else { + errorMessage = "Settings must be valid JSON." + return + } + values = parsed + } else { + let ok = try await controller.submit() + guard ok else { + errorMessage = "Please fix the highlighted fields." + return + } + let data = try JSONEncoder().encode(formData) + values = try JSONValue(jsonData: data) + } + try await appState.saveAutomationValues(values) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Autopilot/RepoSetupManageSheet.swift b/RxCode/Views/Autopilot/RepoSetupManageSheet.swift new file mode 100644 index 0000000..2a292b9 --- /dev/null +++ b/RxCode/Views/Autopilot/RepoSetupManageSheet.swift @@ -0,0 +1,144 @@ +import RxCodeCore +import SwiftUI + +/// Top-level "Repo Setup" sheet: lists the user's repo-setup templates and +/// drills into an editor to create/edit/delete them. Account-level (not +/// per-repo), mirroring the webapp's `/dashboard/repo-setup` page. +struct RepoSetupManageSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var templates: [RepoSetupTemplate] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var path = NavigationPath() + @State private var pendingDelete: RepoSetupTemplate? + + enum Route: Hashable { + case create + case edit(RepoSetupTemplate) + } + + var body: some View { + NavigationStack(path: $path) { + content + .navigationTitle("Repo Setup") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { path.append(Route.create) } label: { + Label("New Template", systemImage: "plus") + } + } + } + .navigationDestination(for: Route.self) { route in + switch route { + case .create: + RepoSetupTemplateEditor(template: nil) { await reload() } + case .edit(let template): + RepoSetupTemplateEditor(template: template) { await reload() } + } + } + } + .frame(width: 560, height: 560) + .task { await reload() } + } + + @ViewBuilder + private var content: some View { + List { + Section { + Text("Templates of GitHub merge settings and an optional branch ruleset. Your default template is applied automatically to new repositories.") + .font(.caption).foregroundStyle(.secondary) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(templates) { template in + NavigationLink(value: Route.edit(template)) { + RepoSetupTemplateRow(template: template) + } + .swipeActions { + Button(role: .destructive) { + pendingDelete = template + } label: { Label("Delete", systemImage: "trash") } + } + } + if templates.isEmpty, !isLoading, errorMessage == nil { + Text("No templates yet. Create one to standardize new repositories.") + .foregroundStyle(.secondary) + } + } + .overlay { + if isLoading, templates.isEmpty { ProgressView() } + } + .confirmationDialog( + "Delete \"\(pendingDelete?.name ?? "")\"?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + let template = pendingDelete + Task { await delete(template) } + } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + templates = try await appState.repoSetupTemplates() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ template: RepoSetupTemplate?) async { + guard let template else { return } + do { + try await appState.deleteRepoSetupTemplate(id: template.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct RepoSetupTemplateRow: View { + let template: RepoSetupTemplate + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(template.name.isEmpty ? "Untitled" : template.name).font(.body) + if template.isDefault { + Text("Default") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } + if !template.enabled { + Text("Disabled").font(.caption2).foregroundStyle(.tertiary) + } + } + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 2) + } + + private var subtitle: String { + let ruleset = (template.rulesetConfig?.isNull == false) ? "ruleset attached" : "no ruleset" + return "Merge settings · \(ruleset)" + } +} diff --git a/RxCode/Views/Autopilot/RepoSetupTemplateEditor.swift b/RxCode/Views/Autopilot/RepoSetupTemplateEditor.swift new file mode 100644 index 0000000..6e1aaa3 --- /dev/null +++ b/RxCode/Views/Autopilot/RepoSetupTemplateEditor.swift @@ -0,0 +1,310 @@ +import AppKit +import JSONSchema +import JSONSchemaForm +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Create/edit a repo-setup template. Mirrors the webapp's `template-form.tsx`: +/// the merge settings (name/enabled/mergeSettings) are rendered from the +/// repo-setup JSON Schema, and a GitHub branch ruleset is supplied by +/// importing/pasting its raw JSON. +struct RepoSetupTemplateEditor: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let template: RepoSetupTemplate? + /// Called after a successful save so the list can refresh. + var onSaved: () async -> Void + + @State private var schema: JSONSchema? + @State private var uiSchema: [String: Any]? + @State private var formData: FormData = .object(properties: [:]) + @State private var controller = JSONSchemaFormController() + + /// Raw template JSON ({name, enabled, mergeSettings}) used by the fallback + /// editor when the schema can't be rendered. + @State private var fallbackJSON = "{}" + @State private var useFallback = false + + @State private var isDefault: Bool + @State private var rulesetText: String + @State private var rulesetSummary: GitHubRulesetSummary? + @State private var rulesetError: String? + + @State private var isLoading = true + @State private var loadError: String? + @State private var isSaving = false + @State private var errorMessage: String? + + init(template: RepoSetupTemplate?, onSaved: @escaping () async -> Void) { + self.template = template + self.onSaved = onSaved + _isDefault = State(initialValue: template?.isDefault ?? false) + if let ruleset = template?.rulesetConfig, ruleset.isNull == false { + _rulesetText = State(initialValue: ruleset.prettyJSONString) + } else { + _rulesetText = State(initialValue: "") + } + } + + var body: some View { + VStack(spacing: 0) { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + footer + } + .navigationTitle(template == nil ? "New Template" : "Edit Template") + .task { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let loadError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle").font(.title2).foregroundStyle(.secondary) + Text(loadError).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + Button("Retry") { Task { await load() } } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + settingsForm + Divider() + Toggle(isOn: $isDefault) { + VStack(alignment: .leading, spacing: 2) { + Text("Default template") + Text("Applied automatically to newly created repositories.") + .font(.caption).foregroundStyle(.secondary) + } + } + Divider() + rulesetSection + } + .padding(16) + } + } + } + + @ViewBuilder + private var settingsForm: some View { + if useFallback { + VStack(alignment: .leading, spacing: 6) { + Text("This form couldn't be rendered. Edit the raw template JSON instead (name, enabled, mergeSettings).") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $fallbackJSON) + .font(.system(.body, design: .monospaced)) + .frame(height: 200) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3))) + } + } else if let schema { + JSONSchemaForm( + schema: schema, + uiSchema: uiSchema, + formData: $formData, + liveValidate: true, + showSubmitButton: false, + controller: controller + ) + } + } + + private var rulesetSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Git Ruleset").font(.headline) + Spacer() + Button { + importRuleset() + } label: { Label("Import JSON…", systemImage: "square.and.arrow.down") } + if !rulesetText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button(role: .destructive) { + rulesetText = "" + validateRuleset() + } label: { Text("Remove") } + } + } + Text("Optional. Paste or import a GitHub ruleset JSON exported from your repository's branch-rules settings.") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $rulesetText) + .font(.system(.body, design: .monospaced)) + .frame(height: 140) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3))) + .onChange(of: rulesetText) { _, _ in validateRuleset() } + if let rulesetError { + Text(rulesetError).foregroundStyle(.red).font(.caption) + } else if let rulesetSummary { + rulesetSummaryCard(rulesetSummary) + } + } + } + + private func rulesetSummaryCard(_ summary: GitHubRulesetSummary) -> some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) + VStack(alignment: .leading, spacing: 1) { + Text(summary.name).font(.callout).fontWeight(.medium) + Text("\(summary.target) · \(summary.enforcement) · \(summary.ruleCount) rule\(summary.ruleCount == 1 ? "" : "s")") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private var footer: some View { + HStack { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button("Cancel") { dismiss() } + Button { + Task { await save() } + } label: { + if isSaving { ProgressView().controlSize(.small) } else { Text("Save") } + } + .keyboardShortcut(.defaultAction) + .disabled(isSaving || isLoading || loadError != nil) + } + .padding(16) + } + + // MARK: - Ruleset handling + + private func validateRuleset() { + let trimmed = rulesetText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + rulesetSummary = nil + rulesetError = nil + return + } + switch GitHubRulesetSummary.validate(rawJSON: trimmed) { + case .success(let summary): + rulesetSummary = summary + rulesetError = nil + case .failure(let error): + rulesetSummary = nil + rulesetError = error.localizedDescription + } + } + + /// Opens an `NSOpenPanel` (rather than `.fileImporter`) for consistency with + /// the rest of the app's file pickers. + private func importRuleset() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.json] + guard panel.runModal() == .OK, let url = panel.url else { return } + if let data = try? Data(contentsOf: url) { + rulesetText = String(decoding: data, as: UTF8.self) + validateRuleset() + } else { + rulesetError = "Couldn't read the selected file." + } + } + + // MARK: - Load / Save + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + let env = try await appState.repoSetupSchema() + uiSchema = env.uiSchema?.foundationObject as? [String: Any] + + // Reconstruct the schema-backed body (name/enabled/mergeSettings) + // from the existing template, or empty for a new one. + let seed: JSONValue = .object([ + "name": .string(template?.name ?? ""), + "enabled": .bool(template?.enabled ?? true), + "mergeSettings": template?.mergeSettings ?? .object([:]), + ]) + fallbackJSON = seed.prettyJSONString + do { + let parsed = try JSONSchema(jsonString: env.schema.rawJSONString) + let seeded = try FormData.fromJSONString(seed.rawJSONString) + formData = seeded.applyingDefaults(schema: parsed) + schema = parsed + useFallback = false + } catch { + useFallback = true + } + validateRuleset() + } catch { + loadError = error.localizedDescription + } + } + + private func save() async { + // Validate ruleset (if any) before touching the network. + let trimmedRuleset = rulesetText.trimmingCharacters(in: .whitespacesAndNewlines) + var rulesetValue: JSONValue? + if !trimmedRuleset.isEmpty { + switch GitHubRulesetSummary.validate(rawJSON: trimmedRuleset) { + case .success(let summary): rulesetValue = summary.value + case .failure(let error): + errorMessage = error.localizedDescription + return + } + } + + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let body: JSONValue + if useFallback { + guard let data = fallbackJSON.data(using: .utf8), + let parsed = try? JSONValue(jsonData: data), + case .object = parsed else { + errorMessage = "Template must be a valid JSON object." + return + } + body = parsed + } else { + let ok = try await controller.submit() + guard ok else { + errorMessage = "Please fix the highlighted fields." + return + } + let data = try JSONEncoder().encode(formData) + body = try JSONValue(jsonData: data) + } + + let name = (body["name"]?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + errorMessage = "Template name is required." + return + } + let input = RepoSetupTemplateInput( + name: name, + enabled: body["enabled"]?.boolValue ?? true, + isDefault: isDefault, + mergeSettings: body["mergeSettings"] ?? .object([:]), + rulesetConfig: rulesetValue + ) + + if let template { + _ = try await appState.updateRepoSetupTemplate(id: template.id, input) + } else { + _ = try await appState.createRepoSetupTemplate(input) + } + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/CIUpdates/CIAddWatchedRepoSheet.swift b/RxCode/Views/CIUpdates/CIAddWatchedRepoSheet.swift new file mode 100644 index 0000000..8639905 --- /dev/null +++ b/RxCode/Views/CIUpdates/CIAddWatchedRepoSheet.swift @@ -0,0 +1,182 @@ +import RxCodeCore +import SwiftUI + +/// Picks a GitHub repository to watch for CI auto-updates. The add API needs +/// `installationId` + `repositoryId` + `repositoryFullName`, none of which the +/// watched-repository listing carries for *new* repos — so we source the +/// accessible-repo list (which does carry the installation/repository ids) from +/// the secrets `repositories/all` endpoint, the same listing `SecretsManageSheet` +/// and `AddDocsRepoSheet` use. +struct CIAddWatchedRepoSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Full names already watched, so they're hidden from the picker. + let existingRepoFullNames: Set + /// When set (banner deep-link flow), the picker pre-filters to this repo so + /// the user lands on it without scrolling. + var presetRepoFullName: String? + /// Called after a repo is successfully registered, with its `owner/repo`. + var onAdded: (String) -> Void + + @State private var repos: [SecretsManagedRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var addingFullName: String? + @State private var errorMessage: String? + @State private var searchTask: Task? + @State private var frequency: CIScanFrequency = .weekly + @State private var didApplyPreset = false + + private var visibleRepos: [SecretsManagedRepo] { + repos.filter { !existingRepoFullNames.contains($0.fullName.lowercased()) } + } + + var body: some View { + NavigationStack { + content + .navigationTitle("Watch Repository for CI Updates") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .frame(width: 520, height: 560) + .task { + if let presetRepoFullName, !didApplyPreset { + didApplyPreset = true + search = presetRepoFullName + } + await reload() + } + } + + @ViewBuilder + private var content: some View { + List { + Section { + Picker("Scan frequency", selection: $frequency) { + ForEach(CIScanFrequency.allCases) { freq in + Text(freq.displayName).tag(freq) + } + } + Text("How often autopilot scans this repo's workflows and opens a PR when actions are outdated.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + Button { + Task { await add(repo) } + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock.fill" : "book.closed") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName).font(.body) + if repo.isCurrent { + Text("Current project").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if addingFullName == repo.fullName { + ProgressView().controlSize(.small) + } else { + Image(systemName: "plus.circle").foregroundStyle(.tint) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(addingFullName != nil) + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if visibleRepos.isEmpty, !isLoading, errorMessage == nil { + Text("No repositories available to add.") + .foregroundStyle(.secondary).font(.callout) + } + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search) + repos = page.items + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search, cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + addingFullName = repo.fullName + errorMessage = nil + defer { addingFullName = nil } + do { + _ = try await appState.ciUpdates.addWatchedRepository( + AddWatchedRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName, + scanFrequency: frequency + ) + ) + onAdded(repo.fullName) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/CIUpdates/CIUpdateDeepLink.swift b/RxCode/Views/CIUpdates/CIUpdateDeepLink.swift new file mode 100644 index 0000000..309a4a6 --- /dev/null +++ b/RxCode/Views/CIUpdates/CIUpdateDeepLink.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Request to present the CI auto-update manage sheet pinned to a repo, so the +/// banner can skip the repo picker and drop the user straight into that repo's +/// detail (or its add flow when it isn't watched yet). +struct CISetupRequest: Identifiable, Hashable { + let id = UUID() + var repoFullName: String? + var projectPath: String? +} + +/// Parses `rxcode://ci/add?repo=&path=` deep links into a +/// `CISetupRequest`. Returns nil for unrelated URLs. Mirrors `SecretsDeepLink`. +enum CIUpdateDeepLink { + static let scheme = "rxcode" + + static func parse(_ url: URL) -> CISetupRequest? { + guard url.scheme == scheme else { return nil } + // Accept both rxcode://ci/add and rxcode:ci/add forms. + let host = url.host + let firstPath = url.pathComponents.first(where: { $0 != "/" }) + let segment = host ?? firstPath + guard segment == "ci" 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 CISetupRequest( + repoFullName: value("repo"), + projectPath: value("path") + ) + } + + /// Builds the deep link the CI setup banner's button opens. + static func addURL(repo: String, path: String?) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "ci" + components.path = "/add" + var query: [URLQueryItem] = [URLQueryItem(name: "repo", value: repo)] + if let path { query.append(URLQueryItem(name: "path", value: path)) } + components.queryItems = query + return components.url + } +} diff --git a/RxCode/Views/CIUpdates/CIUpdateManageSheet.swift b/RxCode/Views/CIUpdates/CIUpdateManageSheet.swift new file mode 100644 index 0000000..9611d44 --- /dev/null +++ b/RxCode/Views/CIUpdates/CIUpdateManageSheet.swift @@ -0,0 +1,183 @@ +import RxCodeCore +import SwiftUI + +/// Top-level "CI Auto-Update" sheet: lists every repo configured for CI +/// auto-updates (watched repositories), drilling into each repo's schedule, +/// scan history, and pull requests. "Add Repo" registers a new repo to watch. +struct CIUpdateManageSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Optional project context: when opened from the setup banner's deep link, + /// skip the picker and go straight to this repo (its detail if already + /// watched, otherwise the add flow preset to it). + var currentRepoFullName: String? + var currentProjectPath: String? + + @State private var repos: [WatchedRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var path = NavigationPath() + @State private var showAdd = false + @State private var presetAddRepo: String? + /// Whether we've already handled the pinned repo, so re-running `reload()` + /// doesn't fight the user's navigation. + @State private var didAutoNavigate = false + + private var visibleRepos: [WatchedRepo] { + let trimmed = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return repos } + return repos.filter { $0.repositoryFullName.lowercased().contains(trimmed) } + } + + var body: some View { + NavigationStack(path: $path) { + content + .navigationTitle("CI Auto-Update") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { presetAddRepo = nil; showAdd = true } label: { + Label("Add Repo", systemImage: "plus") + } + } + } + .navigationDestination(for: WatchedRepo.self) { repo in + CIUpdateRepoDetailView( + repo: repo, + onRemoved: { removed in + repos.removeAll { $0.id == removed.id } + path = NavigationPath() + }, + onClose: { dismiss() } + ) + } + } + .frame(width: 580, height: 580) + .sheet(isPresented: $showAdd, onDismiss: { Task { await reload() } }) { + CIAddWatchedRepoSheet( + existingRepoFullNames: Set(repos.map { $0.repositoryFullName.lowercased() }), + presetRepoFullName: presetAddRepo + ) { _ in } + .environment(appState) + } + .task { await reload() } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + NavigationLink(value: repo) { + CIWatchedRepoRow(repo: repo) + } + } + if hasMore, search.isEmpty { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if visibleRepos.isEmpty, !isLoading, errorMessage == nil { + VStack(alignment: .leading, spacing: 10) { + Text("No repositories are watched for CI auto-updates yet.") + .foregroundStyle(.secondary) + Button { presetAddRepo = nil; showAdd = true } label: { + Label("Add Repository", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.ciUpdates.listWatchedRepositories() + repos = page.repositories + nextCursor = page.nextCursor + hasMore = page.hasMore + autoHandleCurrentRepoIfNeeded() + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + /// When opened from the banner's deep link, skip the picker: drill straight + /// into the pinned repo if it's already watched, otherwise open the add flow + /// preset to that repo. + private func autoHandleCurrentRepoIfNeeded() { + guard !didAutoNavigate, let currentRepoFullName else { return } + didAutoNavigate = true + if let match = repos.first(where: { $0.repositoryFullName.lowercased() == currentRepoFullName.lowercased() }) { + path.append(match) + } else { + presetAddRepo = currentRepoFullName + showAdd = true + } + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.ciUpdates.listWatchedRepositories(cursor: cursor) + repos.append(contentsOf: page.repositories) + nextCursor = page.nextCursor + hasMore = page.hasMore + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct CIWatchedRepoRow: View { + let repo: WatchedRepo + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.repositoryFullName).font(.body) + HStack(spacing: 6) { + Text(repo.scanFrequency.displayName) + Text("·") + if let date = repo.lastScannedDate { + Text("Scanned \(date.formatted(.relative(presentation: .named)))") + } else { + Text("Never scanned") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 2) + } +} diff --git a/RxCode/Views/CIUpdates/CIUpdateRepoDetailView.swift b/RxCode/Views/CIUpdates/CIUpdateRepoDetailView.swift new file mode 100644 index 0000000..250a640 --- /dev/null +++ b/RxCode/Views/CIUpdates/CIUpdateRepoDetailView.swift @@ -0,0 +1,386 @@ +import RxCodeCore +import SwiftUI + +/// Detail for one watched repo, mirroring the github-pm webapp detail page: +/// header badges, an editable scan schedule, a "Trigger scan now" button, a +/// "Remove" button, and tabs for Scan History + Pull Requests. +struct CIUpdateRepoDetailView: View { + @Environment(AppState.self) private var appState + @Environment(\.openURL) private var openURL + + let repo: WatchedRepo + /// Called after the repo is removed, so the list can drop it and pop back. + var onRemoved: ((WatchedRepo) -> Void)? + /// Closes the enclosing manage sheet (kept available even when the deep link + /// drills straight into this pushed view). + var onClose: (() -> Void)? + + enum Tab: String, CaseIterable, Identifiable { + case history = "Scan History" + case prs = "Pull Requests" + var id: String { rawValue } + } + + @State private var frequency: CIScanFrequency + @State private var selectedTab: Tab = .history + @State private var history: [CIUpdateRunHistory] = [] + @State private var prs: [CIPullRequest] = [] + @State private var didLoadPRs = false + @State private var isLoadingHistory = false + @State private var isLoadingPRs = false + @State private var isTriggering = false + @State private var isUpdatingFrequency = false + @State private var triggerMessage: String? + @State private var lastTriggerPRURL: String? + @State private var errorMessage: String? + @State private var showRemoveConfirm = false + @State private var closingPR: Int? + + init(repo: WatchedRepo, onRemoved: ((WatchedRepo) -> Void)? = nil, onClose: (() -> Void)? = nil) { + self.repo = repo + self.onRemoved = onRemoved + self.onClose = onClose + _frequency = State(initialValue: repo.scanFrequency) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + Picker("", selection: $selectedTab) { + ForEach(Tab.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 16) + .padding(.vertical, 10) + Divider() + Group { + switch selectedTab { + case .history: historyList + case .prs: prsList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationTitle(repo.repo ?? repo.repositoryFullName) + .toolbar { + if let onClose { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onClose() } + } + } + } + .confirmationDialog( + "Stop watching \(repo.repositoryFullName)?", + isPresented: $showRemoveConfirm, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { Task { await remove() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("Autopilot will no longer scan this repo or open CI update PRs.") + } + .task { await loadHistory() } + .onChange(of: selectedTab) { _, tab in + if tab == .prs, !didLoadPRs { Task { await loadPRs() } } + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(ClaudeTheme.accent) + Text(repo.repositoryFullName) + .font(.system(size: ClaudeTheme.size(14), weight: .semibold)) + Spacer() + } + + HStack(spacing: 8) { + badge( + repo.scheduleId != nil ? "Auto · \(frequency.displayName)" : "Manual", + systemImage: repo.scheduleId != nil ? "clock.fill" : "hand.tap.fill" + ) + if let date = repo.lastScannedDate { + badge("Last scan \(date.formatted(.relative(presentation: .named)))", systemImage: "checkmark.seal.fill") + } else { + badge("Never scanned", systemImage: "circle.dashed") + } + } + + HStack(spacing: 12) { + Picker("Schedule", selection: $frequency) { + ForEach(CIScanFrequency.allCases) { Text($0.displayName).tag($0) } + } + .frame(maxWidth: 220) + .disabled(isUpdatingFrequency) + if isUpdatingFrequency { ProgressView().controlSize(.small) } + Spacer() + } + + HStack(spacing: 10) { + Button { + Task { await trigger() } + } label: { + if isTriggering { + ProgressView().controlSize(.small) + } else { + Label("Trigger scan now", systemImage: "play.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(isTriggering) + + Button(role: .destructive) { + showRemoveConfirm = true + } label: { + Label("Remove", systemImage: "trash") + } + } + + if let triggerMessage { + HStack(spacing: 6) { + Text(triggerMessage) + .font(.caption) + .foregroundStyle(.secondary) + if let urlString = lastTriggerPRURL, let url = URL(string: urlString) { + Button("Open PR") { openURL(url) } + .buttonStyle(.link) + .font(.caption) + } + } + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.caption) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: frequency) { old, new in + guard old != new else { return } + Task { await updateFrequency(new) } + } + } + + private func badge(_ text: String, systemImage: String) -> some View { + Label(text, systemImage: systemImage) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(NSColor.controlBackgroundColor), in: Capsule()) + .overlay(Capsule().strokeBorder(Color(NSColor.separatorColor), lineWidth: 1)) + } + + // MARK: - Scan history + + @ViewBuilder + private var historyList: some View { + List { + if history.isEmpty, !isLoadingHistory { + Text("No scans yet. Trigger one to check this repo's actions.") + .foregroundStyle(.secondary) + } + ForEach(history) { run in + CIScanRunRow(run: run) { url in openURL(url) } + } + } + .overlay { if isLoadingHistory, history.isEmpty { ProgressView() } } + } + + // MARK: - Pull requests + + @ViewBuilder + private var prsList: some View { + List { + if prs.isEmpty, !isLoadingPRs { + Text("No pull requests opened by autopilot yet.") + .foregroundStyle(.secondary) + } + ForEach(prs) { pr in + CIPullRequestRow( + pr: pr, + isClosing: closingPR == pr.number, + onOpen: { url in openURL(url) }, + onClose: { number in Task { await closePR(number) } } + ) + } + } + .overlay { if isLoadingPRs, prs.isEmpty { ProgressView() } } + } + + // MARK: - Actions + + private func loadHistory() async { + isLoadingHistory = true + errorMessage = nil + defer { isLoadingHistory = false } + do { + history = try await appState.ciUpdates.history(id: repo.id, limit: 50) + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadPRs() async { + isLoadingPRs = true + defer { isLoadingPRs = false; didLoadPRs = true } + do { + prs = try await appState.ciUpdates.pullRequests(id: repo.id) + } catch { + errorMessage = error.localizedDescription + } + } + + private func updateFrequency(_ new: CIScanFrequency) async { + isUpdatingFrequency = true + errorMessage = nil + defer { isUpdatingFrequency = false } + do { + try await appState.ciUpdates.updateScanFrequency(id: repo.id, frequency: new) + } catch { + errorMessage = error.localizedDescription + frequency = repo.scanFrequency // revert the picker on failure + } + } + + private func trigger() async { + isTriggering = true + triggerMessage = nil + lastTriggerPRURL = nil + errorMessage = nil + defer { isTriggering = false } + do { + let result = try await appState.ciUpdates.trigger(id: repo.id) + if result.success { + lastTriggerPRURL = result.prUrl + triggerMessage = result.prUrl != nil ? "Scan complete — PR opened." : "Scan complete — everything up to date." + } else { + errorMessage = result.error ?? "Scan failed." + } + await loadHistory() + } catch { + errorMessage = error.localizedDescription + } + } + + private func closePR(_ number: Int) async { + closingPR = number + errorMessage = nil + defer { closingPR = nil } + do { + try await appState.ciUpdates.closePullRequest(id: repo.id, prNumber: number) + prs.removeAll { $0.number == number } + } catch { + errorMessage = error.localizedDescription + } + } + + private func remove() async { + errorMessage = nil + do { + try await appState.ciUpdates.deleteWatchedRepository(id: repo.id) + onRemoved?(repo) + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Rows + +private struct CIScanRunRow: View { + let run: CIUpdateRunHistory + let onOpenPR: (URL) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: run.isSuccess ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(run.isSuccess ? Color.green : Color.red) + Text(run.isSuccess ? "Success" : "Error") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + Spacer() + if let date = run.startedDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if run.isSuccess { + Text("\(run.workflowsScanned ?? 0) workflows · \(run.actionsFound ?? 0) actions · \(run.outdatedActionsCount ?? 0) outdated") + .font(.caption) + .foregroundStyle(.secondary) + } else if let message = run.errorMessage { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(3) + } + if let urlString = run.prUrl, let url = URL(string: urlString) { + Button { + onOpenPR(url) + } label: { + Label(run.prNumber.map { "PR #\($0)" } ?? "View PR", systemImage: "arrow.up.right.square") + .font(.caption) + } + .buttonStyle(.link) + } + } + .padding(.vertical, 4) + } +} + +private struct CIPullRequestRow: View { + let pr: CIPullRequest + let isClosing: Bool + let onOpen: (URL) -> Void + let onClose: (Int) -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.pull") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(pr.title ?? pr.number.map { "PR #\($0)" } ?? "Pull request") + .font(.body) + .lineLimit(1) + HStack(spacing: 6) { + if let number = pr.number { Text("#\(number)") } + if pr.merged == true { + Text("·"); Text("Merged") + } else if let state = pr.state { + Text("·"); Text(state.capitalized) + } + if let date = pr.createdDate { + Text("·"); Text(date.formatted(.relative(presentation: .named))) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let urlString = pr.htmlURL, let url = URL(string: urlString) { + Button { onOpen(url) } label: { + Image(systemName: "arrow.up.right.square") + } + .buttonStyle(.borderless) + .help("Open on GitHub") + } + if let number = pr.number, pr.isOpen { + if isClosing { + ProgressView().controlSize(.small) + } else { + Button(role: .destructive) { onClose(number) } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.borderless) + .help("Close PR") + } + } + } + .padding(.vertical, 3) + } +} diff --git a/RxCode/Views/CIUpdates/DetectedWorkflow.swift b/RxCode/Views/CIUpdates/DetectedWorkflow.swift new file mode 100644 index 0000000..cecfed1 --- /dev/null +++ b/RxCode/Views/CIUpdates/DetectedWorkflow.swift @@ -0,0 +1,29 @@ +import Foundation + +/// A GitHub Actions workflow file found locally under `.github/workflows`. +/// Mirrors `DetectedEnv` — `CIUpdateHook` uses this to decide, without hitting +/// the network, whether a project is even a candidate for the CI auto-update +/// banner (a repo with no workflow files has nothing to keep updated). +struct DetectedWorkflow: Identifiable { + let filename: String + var id: String { filename } + + /// Scans `/.github/workflows` for `*.yml` / `*.yaml` files. + /// Returns an empty array when the directory is missing or unreadable. + static func scan(directory: String?) -> [DetectedWorkflow] { + guard let directory else { return [] } + let workflowsDir = (directory as NSString).appendingPathComponent(".github/workflows") + guard let names = try? FileManager.default.contentsOfDirectory(atPath: workflowsDir) else { return [] } + return names + .filter { $0.hasSuffix(".yml") || $0.hasSuffix(".yaml") } + .sorted() + .compactMap { name in + let path = (workflowsDir as NSString).appendingPathComponent(name) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), !isDir.boolValue else { + return nil + } + return DetectedWorkflow(filename: name) + } + } +} diff --git a/RxCode/Views/Chat/BranchPickerChip.swift b/RxCode/Views/Chat/BranchPickerChip.swift index f8f7260..f591589 100644 --- a/RxCode/Views/Chat/BranchPickerChip.swift +++ b/RxCode/Views/Chat/BranchPickerChip.swift @@ -199,6 +199,32 @@ struct BranchPickerChip: View { // MARK: - CreateBranchSheet struct CreateBranchSheet: View { + /// How a new branch gets materialized for the chat. + enum BranchMode: String, CaseIterable, Identifiable { + /// `git worktree add -b` — isolated checkout in a sibling directory. + case worktree + /// `git checkout -b` in the project root — mutates the main repo. + case checkout + + var id: String { rawValue } + + var label: String { + switch self { + case .worktree: "New worktree" + case .checkout: "Branch + checkout" + } + } + + var hint: String { + switch self { + case .worktree: + "Creates an isolated worktree so this chat works without touching the main checkout." + case .checkout: + "Creates the branch and checks it out in the project root, changing the main checkout." + } + } + } + @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState @Environment(\.dismiss) private var dismiss @@ -207,6 +233,7 @@ struct CreateBranchSheet: View { let onCreated: () -> Void @State private var branchText: String = "rxcode/" + @State private var mode: BranchMode = .worktree @State private var isCreating = false @State private var errorMessage: String? @FocusState private var isFocused: Bool @@ -222,7 +249,7 @@ struct CreateBranchSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - Text("Create and checkout branch") + Text("Create branch") .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) .foregroundStyle(ClaudeTheme.textPrimary) Spacer() @@ -275,6 +302,43 @@ struct CreateBranchSheet: View { } } + VStack(alignment: .leading, spacing: 6) { + Menu { + Picker("", selection: $mode) { + ForEach(BranchMode.allCases) { m in + Text(m.label).tag(m) + } + } + .labelsHidden() + .pickerStyle(.inline) + } label: { + HStack(spacing: 6) { + Text(mode.label) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(ClaudeTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .disabled(isCreating) + + Text(mode.hint) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + HStack { Spacer() Button("Close") { @@ -292,7 +356,7 @@ struct CreateBranchSheet: View { Text("Creating…") } } else { - Text("Create and checkout") + Text(mode == .worktree ? "Create worktree" : "Create and checkout") } } .keyboardShortcut(.defaultAction) @@ -314,7 +378,12 @@ struct CreateBranchSheet: View { errorMessage = nil Task { do { - try await appState.attachWorktree(branch: trimmed, in: windowState) + switch mode { + case .worktree: + try await appState.attachWorktree(branch: trimmed, in: windowState) + case .checkout: + try await appState.createBranchInPlace(branch: trimmed, in: windowState) + } isCreating = false onCreated() dismiss() diff --git a/RxCode/Views/FlowLayout.swift b/RxCode/Views/FlowLayout.swift new file mode 100644 index 0000000..19ed646 --- /dev/null +++ b/RxCode/Views/FlowLayout.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// A layout that arranges its subviews left-to-right and wraps to a new line +/// whenever the next subview would overflow the available width. Subviews that +/// report a zero ideal size (e.g. empty `@ViewBuilder` conditionals) are +/// skipped so they don't leave phantom gaps. Used for the briefing card's +/// status chips, which can otherwise overflow a narrow card. +struct FlowLayout: Layout { + /// Horizontal gap between items on the same row. + var spacing: CGFloat = 6 + /// Vertical gap between wrapped rows. + var lineSpacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + let rows = computeRows(maxWidth: maxWidth, subviews: subviews) + let width = rows.map(\.width).max() ?? 0 + let height = rows.reduce(0) { $0 + $1.height } + lineSpacing * CGFloat(max(0, rows.count - 1)) + return CGSize(width: min(width, maxWidth), height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let rows = computeRows(maxWidth: bounds.width, subviews: subviews) + var y = bounds.minY + for row in rows { + var x = bounds.minX + for item in row.items { + subviews[item.index].place( + at: CGPoint(x: x, y: y + (row.height - item.size.height) / 2), + proposal: ProposedViewSize(item.size) + ) + x += item.size.width + spacing + } + y += row.height + lineSpacing + } + } + + private struct Row { + var items: [(index: Int, size: CGSize)] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func computeRows(maxWidth: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + // Skip empty subviews so conditional chips don't add spacing. + guard size.width > 0, size.height > 0 else { continue } + + if current.items.isEmpty { + current.items.append((index, size)) + current.width = size.width + current.height = size.height + } else if current.width + spacing + size.width > maxWidth { + rows.append(current) + current = Row() + current.items.append((index, size)) + current.width = size.width + current.height = size.height + } else { + current.width += spacing + size.width + current.height = max(current.height, size.height) + current.items.append((index, size)) + } + } + if !current.items.isEmpty { rows.append(current) } + return rows + } +} diff --git a/RxCode/Views/Hooks/CIUpdateBanner.swift b/RxCode/Views/Hooks/CIUpdateBanner.swift new file mode 100644 index 0000000..07ef265 --- /dev/null +++ b/RxCode/Views/Hooks/CIUpdateBanner.swift @@ -0,0 +1,38 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Banner shown on the new-project screen when a project has local +/// `.github/workflows/*.yml|yaml` files but its repo isn't yet watched for CI +/// auto-updates. Built by `CIUpdateHook` and rendered via `HookBannerHost`. The +/// "Set up" button opens the CI deep link, which `MainView` routes to the CI +/// auto-update manage sheet pre-targeted at this repo. +struct CIUpdateBanner: View { + let repo: String + let projectPath: String? + /// Number of local workflow files detected, for the message copy. + let workflowCount: Int + /// 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 + + private var message: String { + String(localized: "Keep this repo's GitHub Actions up to date automatically") + } + + var body: some View { + HookBannerRow( + icon: "arrow.triangle.2.circlepath", + message: message, + onTap: open, + onDismiss: onDismiss + ) + } + + private func open() { + guard let url = CIUpdateDeepLink.addURL(repo: repo, path: projectPath) else { return } + openURL(url) + } +} diff --git a/RxCode/Views/Hooks/DocsSetupBanner.swift b/RxCode/Views/Hooks/DocsSetupBanner.swift index 96b458a..22cf6db 100644 --- a/RxCode/Views/Hooks/DocsSetupBanner.swift +++ b/RxCode/Views/Hooks/DocsSetupBanner.swift @@ -13,66 +13,33 @@ struct DocsSetupBanner: View { let onDismiss: () -> Void @Environment(\.openURL) private var openURL - @State private var isHovered = false - @State private var isCloseHovered = false + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + /// True while the current thread *is* the docs-setup chat this banner would + /// otherwise create — either it's already marked as a docs setup session, or + /// a docs setup is staged for this project (button just clicked, first turn + /// not yet marked). In both cases tapping "Set up" again would spawn a + /// duplicate setup chat, so the action is disabled. + private var isInDocsSetupThread: Bool { + if let pid = windowState.selectedProject?.id, appState.pendingDocsSetupProjectId == pid { + return true + } + if let sid = windowState.currentSessionId, + appState.isSetupSession(kind: HookSetupKind.docs, sessionKey: sid) { + return true + } + return false + } var body: some View { - HStack(spacing: 8) { - Button(action: open) { - HStack(spacing: 10) { - Image(systemName: "books.vertical.fill") - .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - - Text("Set up documentation so it's searchable") - .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() - - 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) + HookBannerRow( + icon: "books.vertical.fill", + message: String(localized: "Set up documentation so it's searchable"), + onTap: open, + onDismiss: onDismiss, + isActionDisabled: isInDocsSetupThread ) - .padding(.horizontal, 16) - .padding(.bottom, 6) - .animation(.easeInOut(duration: 0.12), value: isHovered) - .animation(.easeInOut(duration: 0.12), value: isCloseHovered) } private func open() { diff --git a/RxCode/Views/Hooks/HookBannerHost.swift b/RxCode/Views/Hooks/HookBannerHost.swift index e2b5d47..efca4b2 100644 --- a/RxCode/Views/Hooks/HookBannerHost.swift +++ b/RxCode/Views/Hooks/HookBannerHost.swift @@ -2,14 +2,21 @@ 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:)`. +extension Color { + /// Hook banners always use the Claude brand orange, independent of the + /// active app theme (which may be blue/green/purple/etc.). + static let hookBannerAccent = Color.hex(0xD97757) +} + +/// Renders hook-supplied banners for a given surface + position as a *deck of +/// cards*: only the front banner is shown, with solid back cards peeking above +/// it (inset + offset upward) to hint there are more, and a pager (chevrons + +/// dots, plus horizontal swipe) beneath to move between them. The host supplies +/// the card chrome — each banner view (`HookBannerRow`) styles only its inner row. /// -/// 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. +/// Hooks publish banners through `HookController.showBanner(in:position:id:)`. +/// The slide-in / fade of the whole host is driven by `withAnimation` in +/// `AppStateHookController`'s `showBanner`/`dismissBanner`. struct HookBannerHost: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState @@ -32,11 +39,174 @@ struct HookBannerHost: View { 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)") + if !items.isEmpty { + HookBannerDeck(items: items) + .padding(.horizontal, 16) + .padding(.bottom, 6) + } + } +} + +/// The deck itself: tracks which banner is at the front and renders the front +/// card over peeking back cards, with a pager when more than one banner is +/// present. `index` is clamped whenever the banner set changes (e.g. when the +/// front banner is dismissed, the next one slides into place). +private struct HookBannerDeck: View { + let items: [HookBannerItem] + + @State private var index: Int = 0 + + /// Visible peeking back cards (capped) — drives the "stacked deck" depth. + /// The pager lets you reach every banner, so this is purely a depth hint; + /// raise the cap to surface more of the stack at a glance. + private var backCardCount: Int { min(3, max(0, items.count - 1)) } + + var body: some View { + let current = min(max(index, 0), items.count - 1) VStack(spacing: 8) { - ForEach(items) { item in - item.content - .transition(.move(edge: .bottom).combined(with: .opacity)) + if items.count > 1 { + pager(current: current) } + frontCard(current: current) + // Reserve room above the front card so the peeking back cards + // (which draw upward as a `.background`) don't overlap the pager + // sitting above the deck. + .padding(.top, items.count > 1 ? CGFloat(backCardCount) * Self.backCardStep + 4 : 0) + } + .onChange(of: items.count) { _, newCount in + // Keep the front index valid as banners are added/dismissed. + if index > newCount - 1 { index = max(0, newCount - 1) } } } + + /// Vertical gap between each back card's peeking top edge. + private static let backCardStep: CGFloat = 11 + + // MARK: - Front card + depth + + private func frontCard(current: Int) -> some View { + // Front card: inner row + card chrome, clipped to the rounded rect. + let card = items[current].content + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(ClaudeTheme.surfaceElevated.opacity(0.95)) + ) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(Color.hookBannerAccent.opacity(0.28)) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(Color.hookBannerAccent.opacity(0.35), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge)) + .id(items[current].id) + // Deck feel: the incoming card grows up out of the peeking back-stack + // position into the front slot, while the outgoing card lifts up and + // fades away off the top of the deck. + .transition(.asymmetric( + insertion: .scale(scale: 0.92, anchor: .top) + .combined(with: .offset(y: 14)) + .combined(with: .opacity), + removal: .scale(scale: 1.04, anchor: .top) + .combined(with: .offset(y: -12)) + .combined(with: .opacity) + )) + + // Peeking back cards are added *after* the clip so they aren't clipped + // by the front card's rounded rect; they're sized to the front card and + // nudged upward so their top edges stack above it like a real pile. + return card + .background(alignment: .top) { backCards } + .contentShape(Rectangle()) + .gesture(swipeGesture(current: current)) + } + + /// Peeking cards drawn behind the front one. Each is the same size as the + /// front card (it's a `.background`), inset horizontally and nudged upward + /// so a solid card edge stacks above the front one for a "pile" look. + @ViewBuilder + private var backCards: some View { + ZStack(alignment: .top) { + ForEach(1 ... max(1, backCardCount), id: \.self) { depth in + if depth <= backCardCount { + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(ClaudeTheme.surfaceElevated.opacity(0.95)) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .fill(Color.hookBannerAccent.opacity(0.18)) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(Color.hookBannerAccent.opacity(0.3), lineWidth: 1) + ) + // Deeper cards are narrower and sit higher, peeking above + // the front card; opacity fades them into the background. + .padding(.horizontal, 7 * CGFloat(depth)) + .offset(y: -Self.backCardStep * CGFloat(depth)) + .opacity(max(0.4, 1 - 0.22 * CGFloat(depth))) + // Keep the shallowest card drawn on top of the deeper ones. + .zIndex(-Double(depth)) + } + } + } + } + + // MARK: - Pager + + private func pager(current: Int) -> some View { + HStack(spacing: 12) { + pagerChevron(systemName: "chevron.left", disabled: current == 0) { + move(to: current - 1) + } + + HStack(spacing: 5) { + ForEach(items.indices, id: \.self) { i in + Circle() + .fill(i == current ? Color.hookBannerAccent : ClaudeTheme.textTertiary.opacity(0.35)) + .frame(width: 6, height: 6) + .onTapGesture { move(to: i) } + } + } + + pagerChevron(systemName: "chevron.right", disabled: current == items.count - 1) { + move(to: current + 1) + } + } + } + + private func pagerChevron(systemName: String, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: ClaudeTheme.size(11), weight: .bold)) + .foregroundStyle(disabled ? ClaudeTheme.textTertiary.opacity(0.35) : Color.hookBannerAccent) + .frame(width: 22, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .pointerCursorOnHover() + } + + // MARK: - Navigation + + private func move(to target: Int) { + let clamped = min(max(target, 0), items.count - 1) + guard clamped != index else { return } + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { + index = clamped + } + } + + private func swipeGesture(current: Int) -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + guard abs(value.translation.width) > abs(value.translation.height) else { return } + if value.translation.width < -40 { + move(to: current + 1) + } else if value.translation.width > 40 { + move(to: current - 1) + } + } + } } diff --git a/RxCode/Views/Hooks/HookBannerRow.swift b/RxCode/Views/Hooks/HookBannerRow.swift new file mode 100644 index 0000000..25439f8 --- /dev/null +++ b/RxCode/Views/Hooks/HookBannerRow.swift @@ -0,0 +1,117 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// A single compact hook-banner row: a leading accent icon, the message, an +/// optional "Set up" action pill, and a trailing dismiss button. Only the action +/// pill is tappable — clicking elsewhere on the banner does nothing. +/// +/// Rows render **no** outer card chrome of their own — `HookBannerHost` wraps +/// one or more rows in a single shared container so stacked banners read as one +/// grouped card instead of a column of separate boxes. +struct HookBannerRow: View { + let icon: String + let message: String + var actionTitle: String = .init(localized: "Set up") + let onTap: () -> Void + let onDismiss: () -> Void + /// When true the action pill is disabled + dimmed — used while you're already + /// inside the setup chat this banner would otherwise create a duplicate of. + var isActionDisabled: Bool = false + /// Tooltip shown on the disabled action pill, explaining why it's inert. + var disabledHelp: String = .init(localized: "You're already in this setup chat") + + @State private var isActionHovered = false + @State private var isCloseHovered = false + + var body: some View { + HStack(spacing: 8) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + .foregroundStyle(Color.hookBannerAccent) + .frame(width: ClaudeTheme.size(18)) + + Text(message) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 8) + + Button(action: onTap) { + Text(actionTitle) + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(.white.opacity(isActionDisabled ? 0.7 : 1)) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background( + Color.hookBannerAccent.opacity( + isActionDisabled ? 0.35 : (isActionHovered ? 0.8 : 1) + ), + in: Capsule() + ) + } + .buttonStyle(.plain) + .disabled(isActionDisabled) + .onHover { isActionHovered = $0 && !isActionDisabled } + .pointerCursorOnHover() + .help(isActionDisabled ? disabledHelp : "") + } + + 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, 9) + .background(Color.hookBannerAccent.opacity(0.2)) + .animation(.easeInOut(duration: 0.12), value: isActionHovered) + .animation(.easeInOut(duration: 0.12), value: isCloseHovered) + } +} + +#Preview { + VStack(spacing: 0) { + HookBannerRow( + icon: "bell.badge", + message: "Enable notifications to stay updated", + onTap: {}, + onDismiss: {} + ) + + Divider() + + HookBannerRow( + icon: "key.fill", + message: "Add your API key to unlock all features", + actionTitle: "Configure", + onTap: {}, + onDismiss: {} + ) + + Divider() + + HookBannerRow( + icon: "arrow.triangle.2.circlepath", + message: "A new version is available with important bug fixes and performance improvements", + actionTitle: "Update", + onTap: {}, + onDismiss: {} + ) + } + .frame(width: 400) + .background(Color(.windowBackgroundColor)) +} diff --git a/RxCode/Views/Hooks/ReleaseSetupBanner.swift b/RxCode/Views/Hooks/ReleaseSetupBanner.swift new file mode 100644 index 0000000..dc4dac3 --- /dev/null +++ b/RxCode/Views/Hooks/ReleaseSetupBanner.swift @@ -0,0 +1,50 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI + +/// Banner shown on the new-project screen when a project's repo has no release +/// workflow configured. Built by `ReleaseHook` and rendered via +/// `HookBannerHost`. The "Set up" button opens the release deep link, which +/// `RxCodeApp`/`MainView` route to a new chat seeded with the release skill. +/// Mirrors `DocsSetupBanner`. +struct ReleaseSetupBanner: View { + let repo: 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 + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + /// True while the current thread *is* the release-setup chat this banner would + /// otherwise create — either it's already marked as a release setup session, + /// or a release setup is staged for this project (button just clicked, first + /// turn not yet marked). Tapping "Set up" again would spawn a duplicate setup + /// chat, so the action is disabled. Mirrors `DocsSetupBanner`. + private var isInReleaseSetupThread: Bool { + if let pid = windowState.selectedProject?.id, appState.pendingReleaseSetupProjectId == pid { + return true + } + if let sid = windowState.currentSessionId, + appState.isSetupSession(kind: HookSetupKind.release, sessionKey: sid) { + return true + } + return false + } + + var body: some View { + HookBannerRow( + icon: "tag.fill", + message: String(localized: "Set up releases for this repository"), + onTap: open, + onDismiss: onDismiss, + isActionDisabled: isInReleaseSetupThread + ) + } + + private func open() { + guard let url = ReleaseDeepLink.setupURL(repo: repo) else { return } + openURL(url) + } +} diff --git a/RxCode/Views/Hooks/SecretsEnvBanner.swift b/RxCode/Views/Hooks/SecretsEnvBanner.swift index d1f23d4..3b142d8 100644 --- a/RxCode/Views/Hooks/SecretsEnvBanner.swift +++ b/RxCode/Views/Hooks/SecretsEnvBanner.swift @@ -17,8 +17,6 @@ struct SecretsEnvBanner: View { 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 { @@ -28,65 +26,12 @@ struct SecretsEnvBanner: View { } 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) + HookBannerRow( + icon: "key.fill", + message: message, + onTap: open, + onDismiss: onDismiss ) - .padding(.horizontal, 16) - .padding(.bottom, 6) - .animation(.easeInOut(duration: 0.12), value: isHovered) - .animation(.easeInOut(duration: 0.12), value: isCloseHovered) } private func open() { diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index c919d33..9deb9be 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -303,6 +303,13 @@ struct MainView: View { ) .environment(appState) } + .sheet(item: Bindable(appState).ciSetupRequest, onDismiss: rerunNewChatHooks) { request in + CIUpdateManageSheet( + currentRepoFullName: request.repoFullName, + currentProjectPath: request.projectPath + ) + .environment(appState) + } .onChange(of: appState.docsSetupRequest?.id) { _, _ in guard let request = appState.docsSetupRequest else { return } // Start a fresh chat in this project; DocsHook.onSessionStart injects @@ -319,6 +326,17 @@ struct MainView: View { // an attachment thumbnail before it's sent. Task { await appState.sendPrompt(prompt, in: windowState) } } + .onChange(of: appState.releaseSetupRequest?.id) { _, _ in + guard let request = appState.releaseSetupRequest else { return } + // Start a fresh chat in this project; ReleaseHook.onSessionStart + // injects the release skill into its system prompt on first send. + appState.pendingReleaseSetupProjectId = windowState.selectedProject?.id + appState.startNewChat(in: windowState) + let repoText = request.repoFullName.map { " for \($0)" } ?? "" + let prompt = "Set up release publishing\(repoText) by following the create-release skill: inspect the repo, create the `.releaserc` and the release CI workflow (ask me whether to trigger releases on branch push or manually), then register the repo and install the RELEASE_TOKEN via the `ide__setup_release` tool." + appState.releaseSetupRequest = nil + Task { await appState.sendPrompt(prompt, in: windowState) } + } .sheet(item: Bindable(windowState).diffFile) { file in FileDiffView( filePath: file.path, diff --git a/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift b/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift index 323bb81..ff35ae7 100644 --- a/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift +++ b/RxCode/Views/Onboarding/OnboardingAutopilotPreview.swift @@ -26,6 +26,8 @@ struct AutopilotSignInPreview: View { .font(.system(size: 12)) .foregroundStyle(.white.opacity(0.62)) + capabilitiesGrid + if appState.isSignedIn { signedInCard } else { @@ -50,6 +52,74 @@ struct AutopilotSignInPreview: View { } } + // MARK: - Capabilities + + private struct Capability: Identifiable { + let id = UUID() + let icon: String + let title: String + let description: String + } + + private let capabilities: [Capability] = [ + Capability( + icon: "key.fill", + title: String(localized: "Manage Secrets"), + description: String(localized: "Per-project secrets, end-to-end encrypted and easily shared with your team.") + ), + Capability( + icon: "books.vertical.fill", + title: String(localized: "Manage Docs"), + description: String(localized: "Index your repositories into a personalized, searchable knowledge base.") + ), + Capability( + icon: "arrow.triangle.2.circlepath", + title: String(localized: "Detect CI Issues"), + description: String(localized: "Autopilot watches your CI, spots failures, and opens fix pull requests.") + ), + Capability( + icon: "tag.fill", + title: String(localized: "Create Releases"), + description: String(localized: "Cut and publish semantic releases — all from one app.") + ), + ] + + private var capabilitiesGrid: some View { + let columns = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)] + return LazyVGrid(columns: columns, spacing: 10) { + ForEach(capabilities) { capability in + capabilityCard(capability) + } + } + } + + private func capabilityCard(_ capability: Capability) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: capability.icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text(capability.title) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.white) + Text(capability.description) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.1), lineWidth: 1) + ) + } + private var signedInCard: some View { HStack(spacing: 12) { avatar diff --git a/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift b/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift index 172b360..a8600ed 100644 --- a/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift +++ b/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift @@ -24,13 +24,37 @@ struct SummarizationSetupPreview: View { Text("Provider") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white.opacity(0.62)) - Picker("", selection: $appState.summarizationProvider) { - ForEach(SummarizationProvider.allCases) { provider in - Text(provider.displayName).tag(provider) + Menu { + Picker("", selection: $appState.summarizationProvider) { + ForEach(SummarizationProvider.allCases) { provider in + Text(provider.displayName).tag(provider) + } + } + .labelsHidden() + .pickerStyle(.inline) + } label: { + HStack(spacing: 6) { + Text(appState.summarizationProvider.displayName) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.5)) } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.white.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + ) } - .labelsHidden() - .pickerStyle(.segmented) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) } switch appState.summarizationProvider { diff --git a/RxCode/Views/Release/AddReleaseRepoSheet.swift b/RxCode/Views/Release/AddReleaseRepoSheet.swift new file mode 100644 index 0000000..a029eaf --- /dev/null +++ b/RxCode/Views/Release/AddReleaseRepoSheet.swift @@ -0,0 +1,156 @@ +import RxCodeCore +import SwiftUI + +/// Picks a GitHub repository to register for releases. The release add-repo API +/// needs `installationId` + `repositoryId` + `repositoryFullName`, none of which +/// the release listing carries — so we source the accessible-repo list (which +/// does carry the installation/repository ids) from the secrets +/// `repositories/all` endpoint, the same listing `AddDocsRepoSheet` uses. +struct AddReleaseRepoSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Full names already registered for releases, hidden from the picker. + let existingRepoFullNames: Set + /// Called after a repo is successfully registered, with its `owner/repo`. + var onAdded: (String) -> Void + + @State private var repos: [SecretsManagedRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var addingFullName: String? + @State private var errorMessage: String? + @State private var searchTask: Task? + + private var visibleRepos: [SecretsManagedRepo] { + repos.filter { !existingRepoFullNames.contains($0.fullName.lowercased()) } + } + + var body: some View { + NavigationStack { + content + .navigationTitle("Add Release Repository") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .frame(width: 520, height: 520) + .task { await reload() } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + Button { + Task { await add(repo) } + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock.fill" : "book.closed") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName).font(.body) + if repo.isCurrent { + Text("Current project").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if addingFullName == repo.fullName { + ProgressView().controlSize(.small) + } else { + Image(systemName: "plus.circle").foregroundStyle(.tint) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(addingFullName != nil) + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if visibleRepos.isEmpty, !isLoading, errorMessage == nil { + Text("No repositories available to add.") + .foregroundStyle(.secondary).font(.callout) + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search) + repos = page.items + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories(search: search, cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + addingFullName = repo.fullName + errorMessage = nil + defer { addingFullName = nil } + do { + _ = try await appState.release.addRepository( + AddReleaseRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName + ) + ) + onAdded(repo.fullName) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Release/ReleaseCreateSheet.swift b/RxCode/Views/Release/ReleaseCreateSheet.swift new file mode 100644 index 0000000..9934ee7 --- /dev/null +++ b/RxCode/Views/Release/ReleaseCreateSheet.swift @@ -0,0 +1,237 @@ +import RxCodeCore +import SwiftUI + +/// "Create Release" dispatch dialog. Shows the current version, a branch picker, +/// and the selected release workflow's `workflow_dispatch` inputs (parsed from +/// its YAML), then triggers the workflow. Mirrors the webapp's +/// workflow-dispatch dialog. +struct ReleaseCreateSheet: View { + /// UUID or `owner/repo` — both resolve server-side. + let repoId: String + let repoFullName: String + /// Latest released tag, shown as the current version. + let currentVersion: String? + /// Branch names to offer (e.g. the project's local git branches). When + /// empty and `projectPath` is set, the project's local branches are loaded; + /// otherwise the branch becomes a free-text field. + var branches: [String] = [] + /// Local checkout path used to source branches when `branches` is empty. + var projectPath: String? = nil + var defaultBranch: String = "main" + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var workflows: [ReleaseWorkflow] = [] + @State private var selectedWorkflowId: String? + @State private var branch: String = "" + @State private var branchOptions: [String] = [] + /// Input values keyed by input name (booleans stored as "true"/"false"). + @State private var inputValues: [String: String] = [:] + + @State private var isLoading = false + @State private var isDispatching = false + @State private var loadError: String? + @State private var dispatchError: String? + @State private var dispatchedURL: URL? + + private var dispatchableWorkflows: [ReleaseWorkflow] { + workflows.filter { $0.hasWorkflowDispatch } + } + + private var selectedWorkflow: ReleaseWorkflow? { + workflows.first(where: { $0.id == selectedWorkflowId }) + } + + var body: some View { + NavigationStack { + Form { + if isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let loadError { + Text(loadError).foregroundStyle(.red) + } else if dispatchableWorkflows.isEmpty { + Text("No dispatchable release workflow found for \(repoFullName). Add a `workflow_dispatch` release workflow and rescan, then try again.") + .foregroundStyle(.secondary) + } else { + detailsSection + if let workflow = selectedWorkflow, let inputs = workflow.inputs, !inputs.isEmpty { + inputsSection(inputs) + } + if let dispatchError { + Section { Text(dispatchError).foregroundStyle(.red).font(.callout) } + } + if let dispatchedURL { + Section { + Label("Release workflow triggered", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + Link("View workflow run on GitHub", destination: dispatchedURL) + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Create Release") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(dispatchedURL == nil ? "Cancel" : "Close") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + Task { await dispatch() } + } label: { + if isDispatching { ProgressView().controlSize(.small) } else { Text("Create Release") } + } + .disabled(isDispatching || selectedWorkflow == nil || resolvedBranch.isEmpty || dispatchedURL != nil) + } + } + } + .frame(width: 480, height: 480) + .task { await load() } + } + + private var resolvedBranch: String { + branch.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Sections + + @ViewBuilder + private var detailsSection: some View { + Section("Release") { + LabeledContent("Repository", value: repoFullName) + LabeledContent("Current version", value: currentVersion ?? "—") + + if dispatchableWorkflows.count > 1 { + Picker("Workflow", selection: $selectedWorkflowId) { + ForEach(dispatchableWorkflows) { wf in + Text(wf.displayName).tag(Optional(wf.id)) + } + } + } else if let wf = dispatchableWorkflows.first { + LabeledContent("Workflow", value: wf.displayName) + } + + if branchOptions.isEmpty { + TextField("Branch", text: $branch) + } else { + Picker("Branch", selection: $branch) { + ForEach(branchOptions, id: \.self) { Text($0).tag($0) } + } + } + + Text("The next version is computed by semantic-release from the commit history.") + .font(.caption).foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func inputsSection(_ inputs: [ReleaseWorkflowInput]) -> some View { + Section("Workflow inputs") { + ForEach(inputs) { input in + inputField(input) + } + } + } + + @ViewBuilder + private func inputField(_ input: ReleaseWorkflowInput) -> some View { + let label = input.name + (input.required ? " *" : "") + switch input.type { + case "boolean": + Toggle(label, isOn: boolBinding(for: input.name)) + case "choice": + Picker(label, selection: stringBinding(for: input.name)) { + ForEach(input.options ?? [], id: \.self) { Text($0).tag($0) } + } + default: + VStack(alignment: .leading, spacing: 2) { + TextField(label, text: stringBinding(for: input.name)) + if let desc = input.description, !desc.isEmpty { + Text(desc).font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + private func stringBinding(for name: String) -> Binding { + Binding( + get: { inputValues[name] ?? "" }, + set: { inputValues[name] = $0 } + ) + } + + private func boolBinding(for name: String) -> Binding { + Binding( + get: { inputValues[name] == "true" }, + set: { inputValues[name] = $0 ? "true" : "false" } + ) + } + + // MARK: - Actions + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + // Source branches: explicit list wins, else the project's local git. + if !branches.isEmpty { + branchOptions = branches + } else if let projectPath { + branchOptions = await GitHelper.listLocalBranches(at: projectPath) + } + workflows = try await appState.release.listWorkflows(repoId: repoId) + // Default to the selected dispatchable workflow, else the first. + let preferred = workflows.first(where: { $0.isSelected && $0.hasWorkflowDispatch }) + ?? dispatchableWorkflows.first + selectedWorkflowId = preferred?.id + if branch.isEmpty { + branch = branchOptions.first ?? defaultBranch + } + seedDefaults(for: preferred) + } catch { + loadError = error.localizedDescription + } + } + + /// Prefill `inputValues` from each input's default so the form starts in a + /// valid state. + private func seedDefaults(for workflow: ReleaseWorkflow?) { + guard let inputs = workflow?.inputs else { return } + for input in inputs where inputValues[input.name] == nil { + if input.type == "boolean" { + inputValues[input.name] = (input.defaultBool ?? false) ? "true" : "false" + } else { + inputValues[input.name] = input.defaultString ?? (input.options?.first ?? "") + } + } + } + + private func dispatch() async { + guard let workflow = selectedWorkflow else { return } + isDispatching = true + dispatchError = nil + defer { isDispatching = false } + do { + let result = try await appState.release.dispatch( + repoId: repoId, + body: ReleaseDispatchRequest( + workflowId: workflow.id, + branch: resolvedBranch, + inputs: inputValues + ) + ) + if let urlString = result.workflowRunUrl, let url = URL(string: urlString) { + dispatchedURL = url + } else if result.success == false { + dispatchError = result.error ?? "Failed to trigger the release workflow." + } else { + // Triggered but no URL returned — still a success. + dispatchedURL = URL(string: "https://github.com/\(repoFullName)/actions") + } + } catch { + dispatchError = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Release/ReleaseDeepLink.swift b/RxCode/Views/Release/ReleaseDeepLink.swift new file mode 100644 index 0000000..a3837f6 --- /dev/null +++ b/RxCode/Views/Release/ReleaseDeepLink.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Request to kick off release setup for a repo: start a fresh chat seeded with +/// the release skill so the agent wires up the `.releaserc` + CI workflow. +struct ReleaseSetupRequest: Identifiable, Hashable { + let id = UUID() + var repoFullName: String? +} + +/// Parses `rxcode://release/setup?repo=` and +/// `rxcode://release/manage?repo=` deep links. Returns nil for +/// unrelated URLs. +enum ReleaseDeepLink { + static let scheme = "rxcode" + + enum Action: String { + case setup + case manage + } + + struct Parsed { + let action: Action + let repoFullName: String? + } + + static func parse(_ url: URL) -> Parsed? { + guard url.scheme == scheme else { return nil } + // Accept both rxcode://release/setup and rxcode:release/setup forms. + let host = url.host + let firstPath = url.pathComponents.first(where: { $0 != "/" }) + let segment = host ?? firstPath + guard segment == "release" else { return nil } + + // The action is the path component after the host (e.g. "setup"). + let actionRaw = (host != nil ? url.pathComponents.first(where: { $0 != "/" }) : url.pathComponents.dropFirst().first(where: { $0 != "/" })) + let action = Action(rawValue: actionRaw ?? "setup") ?? .setup + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let items = components?.queryItems ?? [] + let repo = items.first(where: { $0.name == "repo" })?.value?.removingPercentEncoding + return Parsed(action: action, repoFullName: repo) + } + + /// Builds the deep link the release banner's button opens. + static func setupURL(repo: String) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "release" + components.path = "/setup" + components.queryItems = [URLQueryItem(name: "repo", value: repo)] + return components.url + } + + static func manageURL(repo: String) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "release" + components.path = "/manage" + components.queryItems = [URLQueryItem(name: "repo", value: repo)] + return components.url + } +} diff --git a/RxCode/Views/Release/ReleaseManageSheet.swift b/RxCode/Views/Release/ReleaseManageSheet.swift new file mode 100644 index 0000000..b466154 --- /dev/null +++ b/RxCode/Views/Release/ReleaseManageSheet.swift @@ -0,0 +1,164 @@ +import RxCodeCore +import SwiftUI + +/// Top-level "Manage Releases" sheet: lists every release-managed repository and +/// drills into a repo to select its release workflow, install the RELEASE_TOKEN +/// secret, and create releases. Mirrors `DocsManageSheet`. +struct ReleaseManageSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Optional repo to pin/auto-open (e.g. from the release banner deep link). + var currentRepoFullName: String? + + @State private var repos: [ReleaseRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var path = NavigationPath() + @State private var didAutoNavigate = false + @State private var showAddRepo = false + + /// The list endpoint doesn't filter server-side, so narrow the loaded page + /// by the search box client-side. + private var visibleRepos: [ReleaseRepo] { + let q = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return repos } + return repos.filter { $0.repositoryFullName.lowercased().contains(q) } + } + + var body: some View { + NavigationStack(path: $path) { + VStack(spacing: 0) { + content + } + .navigationTitle("Manage Releases") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + showAddRepo = true + } label: { + Label("Add Repository", systemImage: "plus") + } + } + } + .navigationDestination(for: ReleaseRepo.self) { repo in + ReleaseRepoDetailView(repo: repo, onClose: { dismiss() }) + } + } + .frame(width: 560, height: 560) + .task { await reload() } + .sheet(isPresented: $showAddRepo) { + AddReleaseRepoSheet( + existingRepoFullNames: Set(repos.map { $0.repositoryFullName.lowercased() }), + onAdded: { _ in Task { await reload() } } + ) + .environment(appState) + } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search release repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(visibleRepos) { repo in + NavigationLink(value: repo) { + ReleaseRepoRow(repo: repo) + } + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if repos.isEmpty, !isLoading, errorMessage == nil { + Text("No release repositories yet. Set up release publishing from a project's chat to register one.") + .foregroundStyle(.secondary) + .font(.callout) + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.release.listRepositories() + repos = page.items + nextCursor = page.pagination?.nextCursor + hasMore = page.pagination?.hasMore ?? false + autoNavigateToCurrentRepoIfNeeded() + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func autoNavigateToCurrentRepoIfNeeded() { + guard !didAutoNavigate, + let currentRepoFullName, + let match = repos.first(where: { $0.repositoryFullName == currentRepoFullName }) + else { return } + didAutoNavigate = true + path.append(match) + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.release.listRepositories(cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination?.nextCursor + hasMore = page.pagination?.hasMore ?? false + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct ReleaseRepoRow: View { + let repo: ReleaseRepo + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "shippingbox") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.repositoryFullName).font(.body) + Text(repo.latestReleaseTag.map { "Latest: \($0)" } ?? "No releases yet") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if let tag = repo.latestReleaseTag { + Text(tag) + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(.vertical, 2) + } +} diff --git a/RxCode/Views/Release/ReleaseRepoDetailView.swift b/RxCode/Views/Release/ReleaseRepoDetailView.swift new file mode 100644 index 0000000..d455fce --- /dev/null +++ b/RxCode/Views/Release/ReleaseRepoDetailView.swift @@ -0,0 +1,215 @@ +import AppKit +import RxCodeCore +import SwiftUI + +/// Detail for one release repository: lists its scanned workflows (highlighting +/// the selected dispatchable release workflow), lets the user install a +/// user-supplied `RELEASE_TOKEN` GitHub Actions secret, and create a release. +/// Mirrors `DocsRepoDetailView`. +struct ReleaseRepoDetailView: View { + let repo: ReleaseRepo + var onClose: () -> Void + + @Environment(AppState.self) private var appState + + @State private var workflows: [ReleaseWorkflow] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + @State private var releaseToken = "" + @State private var isInstallingSecret = false + @State private var installedSecret: ReleaseGithubSecretResult? + @State private var installError: String? + + @State private var showDeleteConfirm = false + @State private var showCreateRelease = false + + /// The workflow that "Create Release" dispatches: the selected dispatchable + /// one, else any dispatchable one. + private var defaultWorkflow: ReleaseWorkflow? { + workflows.first(where: { $0.isSelected && $0.hasWorkflowDispatch }) + ?? workflows.first(where: { $0.hasWorkflowDispatch }) + } + + var body: some View { + List { + workflowsSection + tokenSection + createSection + dangerSection + } + .navigationTitle(repo.repositoryFullName) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { onClose() } + } + } + .task { await loadWorkflows() } + .sheet(isPresented: $showCreateRelease) { + ReleaseCreateSheet( + repoId: repo.id, + repoFullName: repo.repositoryFullName, + currentVersion: repo.latestReleaseTag, + branches: [] + ) + .environment(appState) + } + .alert("Remove this release repository?", isPresented: $showDeleteConfirm) { + Button("Remove", role: .destructive) { Task { await deleteRepo() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This unregisters \(repo.repositoryFullName) from the release service. The repo's files, workflows, and existing GitHub releases are not affected.") + } + } + + // MARK: - Workflows + + @ViewBuilder + private var workflowsSection: some View { + Section { + if isLoading, workflows.isEmpty { + HStack { Spacer(); ProgressView().controlSize(.small); Spacer() } + } else if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } else if workflows.isEmpty { + Text("No workflows scanned yet. Add a release workflow (e.g. .github/workflows/create-release.yaml) to the repo, then re-add the repository to rescan.") + .foregroundStyle(.secondary).font(.callout) + } else { + ForEach(workflows) { wf in + HStack(spacing: 10) { + Image(systemName: wf.hasWorkflowDispatch ? "play.circle" : "doc.plaintext") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(wf.displayName).font(.body) + Text(wf.workflowPath).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if wf.isSelected && wf.hasWorkflowDispatch { + Label("Release", systemImage: "checkmark.seal.fill") + .labelStyle(.iconOnly) + .foregroundStyle(.green) + .help("Selected release workflow") + } else if wf.hasWorkflowDispatch { + Text("dispatchable") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(.vertical, 2) + } + } + } header: { + Text("Workflows (\(workflows.count))") + } + } + + // MARK: - RELEASE_TOKEN + + @ViewBuilder + private var tokenSection: some View { + Section("Release token") { + Text("The release CI authenticates to GitHub with a RELEASE_TOKEN secret. Paste a GitHub token (a PAT with contents:write) and RxCode installs it as the repository's GitHub Actions secret for you — no terminal needed.") + .font(.caption) + .foregroundStyle(.secondary) + + SecureField("GitHub token (ghp_… / github_pat_…)", text: $releaseToken) + .textFieldStyle(.roundedBorder) + + if let installed = installedSecret { + Label("Installed \(installed.secretName) on \(installed.repositoryFullName)", systemImage: "checkmark.seal.fill") + .font(.callout) + .foregroundStyle(.green) + } + + if let installError { + Text(installError).foregroundStyle(.red).font(.caption) + } + + Button { + Task { await installSecret() } + } label: { + if isInstallingSecret { + ProgressView().controlSize(.small) + } else { + Text(installedSecret == nil ? "Install RELEASE_TOKEN secret" : "Replace RELEASE_TOKEN secret") + } + } + .disabled(isInstallingSecret || releaseToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + // MARK: - Create release + + @ViewBuilder + private var createSection: some View { + Section { + Button { + showCreateRelease = true + } label: { + Label("Create Release…", systemImage: "tag.fill") + } + .disabled(defaultWorkflow == nil) + if defaultWorkflow == nil { + Text("Select a dispatchable release workflow first (re-add the repo to rescan if needed).") + .font(.caption).foregroundStyle(.secondary) + } + } header: { + Text("Releases") + } footer: { + if let tag = repo.latestReleaseTag { + Text("Latest release: \(tag)") + } + } + } + + // MARK: - Danger zone + + @ViewBuilder + private var dangerSection: some View { + Section { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Text("Remove Release Repository") + } + } + } + + // MARK: - Actions + + private func loadWorkflows() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + workflows = try await appState.release.listWorkflows(repoId: repo.id) + } catch { + errorMessage = error.localizedDescription + } + } + + private func installSecret() async { + let value = releaseToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + isInstallingSecret = true + installError = nil + defer { isInstallingSecret = false } + do { + installedSecret = try await appState.release.installReleaseToken(repoId: repo.id, value: value) + releaseToken = "" + } catch { + installError = error.localizedDescription + } + } + + private func deleteRepo() async { + do { + try await appState.release.deleteRepository(id: repo.id) + onClose() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Search/GlobalSearchOverlay.swift b/RxCode/Views/Search/GlobalSearchOverlay.swift index c475cad..ad52890 100644 --- a/RxCode/Views/Search/GlobalSearchOverlay.swift +++ b/RxCode/Views/Search/GlobalSearchOverlay.swift @@ -694,7 +694,12 @@ struct GlobalSearchOverlay: View { // Drop the current thread from the semantic groups when we already // have in-thread literal matches — same thread shouldn't appear twice. let currentId = inThreadHits.isEmpty ? nil : windowState.currentSessionId + // Hide hits that belong to a project no longer in the projects list. + // These are orphans from a deleted project; surfacing them as + // "Unknown project" is confusing, so we drop them from the results. + let knownProjectIds = Set(appState.projects.map(\.id)) groups = results.compactMap { group in + guard knownProjectIds.contains(group.projectId) else { return nil } let filtered = group.hits.filter { $0.threadId != currentId } guard !filtered.isEmpty else { return nil } return ThreadSearchService.Group(projectId: group.projectId, hits: filtered) diff --git a/RxCode/Views/Settings/AutopilotSettingsTab.swift b/RxCode/Views/Settings/AutopilotSettingsTab.swift index c2b95db..a63d084 100644 --- a/RxCode/Views/Settings/AutopilotSettingsTab.swift +++ b/RxCode/Views/Settings/AutopilotSettingsTab.swift @@ -15,6 +15,13 @@ struct AutopilotSettingsTab: View { @State private var showManageDocs = false + @State private var showManageRelease = false + + @State private var showManageCIUpdates = false + + @State private var showManageAutomation = false + @State private var showManageRepoSetup = false + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -22,10 +29,18 @@ struct AutopilotSettingsTab: View { Divider() accountSection if appState.isSignedIn { + Divider() + automationSection + Divider() + repoSetupSection Divider() secretsSection Divider() + ciUpdatesSection + Divider() docsSection + Divider() + releaseSection } } .padding(24) @@ -41,15 +56,131 @@ struct AutopilotSettingsTab: View { SecretsManageSheet() .environment(appState) } + .sheet(isPresented: $showManageCIUpdates, onDismiss: { + Task { await appState.refreshCIStatuses() } + }) { + CIUpdateManageSheet() + .environment(appState) + } .sheet(isPresented: $showManageDocs, onDismiss: { Task { await appState.refreshDocsStatuses() } }) { DocsManageSheet() .environment(appState) } + .sheet(isPresented: $showManageRelease, onDismiss: { + Task { await appState.refreshReleaseStatuses() } + }) { + ReleaseManageSheet() + .environment(appState) + } + .sheet(isPresented: $showManageAutomation) { + AutomationSettingsSheet() + .environment(appState) + } + .sheet(isPresented: $showManageRepoSetup) { + RepoSetupManageSheet() + .environment(appState) + } .task { await appState.refreshSecretsEnrollment() } } + // MARK: - Automation Section + + private var automationSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Automation Settings") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Control what autopilot does automatically — issue labeling, PR validation and linking, project field population, and more.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + secretsCard { + HStack(spacing: 12) { + Image(systemName: "wand.and.stars") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Automation") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Edit your autopilot automation preferences.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage Settings") { showManageAutomation = true } + .buttonStyle(.borderedProminent) + } + } + } + } + + // MARK: - Repo Setup Section + + private var repoSetupSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Repo Setup") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Create reusable templates of GitHub merge settings and branch rulesets. Your default template is applied to newly created repositories.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + secretsCard { + HStack(spacing: 12) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Setup templates") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Manage merge settings and rulesets applied to new repositories.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage Templates") { showManageRepoSetup = true } + .buttonStyle(.borderedProminent) + } + } + } + } + + // MARK: - Release Section + + private var releaseSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Releases") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Register repositories for semantic-release publishing. Pick which workflow cuts releases, install the RELEASE_TOKEN secret, and trigger releases from RxCode.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + secretsCard { + HStack(spacing: 12) { + Image(systemName: "tag.fill") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Release publishing") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Manage release repositories, select release workflows, and create releases.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage Releases") { showManageRelease = true } + .buttonStyle(.borderedProminent) + } + } + } + } + // MARK: - Docs Section private var docsSection: some View { @@ -82,6 +213,38 @@ struct AutopilotSettingsTab: View { } } + // MARK: - CI Auto-Update Section + + private var ciUpdatesSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("CI Auto-Update") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Keep your repositories' GitHub Actions up to date automatically. Autopilot scans `.github/workflows` on a schedule and opens a PR when actions are outdated.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + secretsCard { + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("CI auto-update scan") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Watch repositories, set scan schedules, and review scan history and pull requests.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage") { showManageCIUpdates = true } + .buttonStyle(.borderedProminent) + } + } + } + } + // MARK: - Secrets Section private var secretsSection: some View { diff --git a/RxCode/Views/Sidebar/BriefingPRStatusView.swift b/RxCode/Views/Sidebar/BriefingPRStatusView.swift new file mode 100644 index 0000000..ee99dad --- /dev/null +++ b/RxCode/Views/Sidebar/BriefingPRStatusView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import RxCodeCore + +/// Per-branch pull-request status shown on a briefing card: a chip reflecting the +/// branch's PR state (merged / open / closed) that links to the PR, or a +/// "Create PR" button when no PR exists yet. Status is read from +/// `AppState.ciStatusByBranchKey`, which the CI poller maintains for every +/// briefing branch (not just current branches). +struct BriefingPRStatusView: View { + @Environment(AppState.self) private var appState + + let projectId: UUID + let branch: String + let project: Project? + + @State private var inFlight = false + @State private var prError: PRErrorAlert? + + private struct PRErrorAlert: Identifiable { + let id = UUID() + let message: String + } + + var body: some View { + let _ = appState.ciStatusRevision + let status = appState.ciStatus(forProjectId: projectId, branch: branch) + Group { + if let prState = status?.pullRequestState { + prChip(state: prState, status: status) + } else if let project, project.gitHubRepo != nil { + createPRButton(project: project) + } + } + .alert(item: $prError) { error in + Alert( + title: Text("Couldn't Create Pull Request"), + message: Text(error.message), + dismissButton: .default(Text("OK")) + ) + } + } + + private func prChip(state: PRState, status: ProjectCIStatus?) -> some View { + let text: String + let icon: String + let color: Color + switch state { + case .merged: + text = "Merged" + icon = "arrow.triangle.merge" + color = .purple + case .open: + text = status?.prNumber.map { "PR #\($0)" } ?? "PR open" + icon = "arrow.triangle.pull" + color = .green + case .closed: + text = status?.prNumber.map { "PR #\($0) closed" } ?? "PR closed" + icon = "xmark" + color = .red + } + + return Group { + if let url = prURL(for: status) { + Link(destination: url) { prChipLabel(text: text, icon: icon, color: color) } + .buttonStyle(.plain) + .help("Open pull request on GitHub") + } else { + prChipLabel(text: text, icon: icon, color: color) + } + } + } + + private func prChipLabel(text: String, icon: String, color: Color) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9, weight: .semibold)) + Text(text) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(color) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Capsule(style: .continuous).fill(color.opacity(0.12))) + .overlay(Capsule(style: .continuous).strokeBorder(color.opacity(0.25), lineWidth: 0.5)) + } + + private func createPRButton(project: Project) -> some View { + Button { + startCreatePR(project: project) + } label: { + HStack(spacing: 4) { + if inFlight { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.mini) + .frame(width: 10, height: 10) + } else { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 9, weight: .semibold)) + } + Text(inFlight ? "Creating…" : "Create PR") + .font(.system(size: 10.5, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule(style: .continuous).fill(ClaudeTheme.accent)) + } + .buttonStyle(.plain) + .disabled(inFlight) + .help("Push the branch and open a pull request from this briefing") + } + + /// Destination for a PR chip: the synced PR URL, falling back to a + /// constructed pull URL from owner/repo/number. + private func prURL(for status: ProjectCIStatus?) -> URL? { + guard let status else { return nil } + if let urlString = status.prUrl, let url = URL(string: urlString) { return url } + if let number = status.prNumber { + return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(number)") + } + return nil + } + + private func startCreatePR(project: Project) { + guard !inFlight else { return } + inFlight = true + Task { @MainActor in + defer { inFlight = false } + do { + let url = try await appState.createPullRequestForBranch( + project: project, + branch: branch + ) + NSWorkspace.shared.open(url) + } catch { + prError = PRErrorAlert(message: error.localizedDescription) + } + } + } +} diff --git a/RxCode/Views/Sidebar/BriefingThreadRow.swift b/RxCode/Views/Sidebar/BriefingThreadRow.swift new file mode 100644 index 0000000..be821b7 --- /dev/null +++ b/RxCode/Views/Sidebar/BriefingThreadRow.swift @@ -0,0 +1,108 @@ +import SwiftUI +import Foundation +import RxCodeCore + +// MARK: - Briefing Thread Row + +struct BriefingThreadRow: View { + let item: ThreadSummaryItem + let isInProgress: Bool + let todoProgress: ChatTodoProgress? + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "bubble.left.and.text.bubble.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 14) + + Text(item.title) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + if isInProgress { + BriefingThreadProgressBadge(progress: todoProgress) + } else { + Text(Self.compactDate(item.updatedAt)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary.opacity(0.4)) + ) + } + .buttonStyle(.plain) + .help(isInProgress ? progressHelpText : "Open thread") + .accessibilityLabel(accessibilityLabel) + } + + private var progressHelpText: String { + guard let todoProgress, todoProgress.total > 0 else { + return String(localized: "Response in progress") + } + return String(localized: "Response in progress. Todos \(todoProgress.done)/\(todoProgress.total)") + } + + private var accessibilityLabel: String { + if isInProgress { + return String(localized: "\(item.title), in progress") + } + return item.title + } + + static func compactDate(_ date: Date, relativeTo now: Date = .now) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: now) + } +} + +private struct BriefingThreadProgressBadge: View { + let progress: ChatTodoProgress? + + private var fraction: Double? { + guard let progress, progress.total > 0 else { return nil } + return min(1, max(0, Double(progress.done) / Double(progress.total))) + } + + var body: some View { + HStack(spacing: 4) { + Group { + if let fraction { + ProgressView(value: fraction, total: 1) + } else { + ProgressView() + } + } + .progressViewStyle(.circular) + .controlSize(.mini) + .frame(width: 10, height: 10) + + Text("In progress") + .font(.system(size: 10.5, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.12)) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(ClaudeTheme.accent.opacity(0.25), lineWidth: 0.5) + ) + } +} diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 0150eb5..fb553b5 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -29,6 +29,15 @@ struct BriefingView: View { /// Container width tracked from the scroll content; drives the waterfall column count. @State private var availableWidth: CGFloat = 800 + /// Non-nil while the "Create Release" sheet is presented for a project. + @State private var createReleaseProject: Project? + + /// Presents the account-level autopilot automation settings form. + @State private var showAutomationSettings = false + + /// Presents the account-level repo-setup template manager. + @State private var showRepoSetup = false + private struct BriefingGroup: Identifiable { let projectId: UUID let branch: String @@ -148,6 +157,23 @@ struct BriefingView: View { .onAppear { AnalyticsService.shared.log(.briefingListOpened) } + .sheet(item: $createReleaseProject) { project in + ReleaseCreateSheet( + repoId: project.gitHubRepo ?? "", + repoFullName: project.gitHubRepo ?? project.name, + currentVersion: appState.projectLatestReleaseVersion(project), + projectPath: project.path + ) + .environment(appState) + } + .sheet(isPresented: $showAutomationSettings) { + AutomationSettingsSheet() + .environment(appState) + } + .sheet(isPresented: $showRepoSetup) { + RepoSetupManageSheet() + .environment(appState) + } } /// True when there is at least one briefing or thread summary persisted, regardless @@ -255,10 +281,57 @@ struct BriefingView: View { } Spacer(minLength: 0) + + if appState.isSignedIn { + autopilotMenu + } } } } + /// Account-level autopilot entry points. Automation settings and repo-setup + /// templates are user-scoped (not per-project), so they live in the briefing + /// hero rather than on individual cards — mirroring the Autopilot settings tab. + private var autopilotMenu: some View { + Menu { + Button { + showAutomationSettings = true + } label: { + Label("Automation Settings", systemImage: "wand.and.stars") + } + Button { + showRepoSetup = true + } label: { + Label("Repo Setup Templates", systemImage: "slider.horizontal.3") + } + } label: { + HStack(spacing: 6) { + Image(systemName: "wand.and.stars") + .font(.system(size: 11, weight: .semibold)) + Text("Autopilot") + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(ClaudeTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Manage autopilot automation settings and repo-setup templates.") + } + private var heroSubtitle: String { let count = groups.count let branches = count == 1 ? "branch" : "branches" @@ -435,38 +508,50 @@ struct BriefingView: View { } private func groupCardHeader(_ group: BriefingGroup, project: Project?) -> some View { - HStack(alignment: .center, spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(ClaudeTheme.accent.opacity(0.12)) - Image(systemName: "folder.fill") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - } - .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.12)) + Image(systemName: "folder.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + } + .frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 2) { Text(project?.name ?? "Unknown project") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(ClaudeTheme.textPrimary) .lineLimit(1) .truncationMode(.middle) - - chip(icon: "arrow.triangle.branch", text: group.branch, accented: true) - ciChip(for: group) + Text("Updated \(Self.compactDate(group.updatedAt))") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) } - Text("Updated \(Self.compactDate(group.updatedAt))") - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - } - Spacer(minLength: 0) + Spacer(minLength: 0) - copyButton(for: group) + copyButton(for: group) - if let project { - cardMenu(for: group, project: project) + if let project { + cardMenu(for: group, project: project) + } + } + + // Status chips wrap onto multiple lines so a narrow card never + // truncates the branch / CI / release / PR indicators. + FlowLayout(spacing: 6, lineSpacing: 6) { + chip(icon: "arrow.triangle.branch", text: group.branch, accented: true) + ciChip(for: group) + if let project, let version = appState.projectLatestReleaseVersion(project) { + chip(icon: "tag.fill", text: version) + } + BriefingPRStatusView( + projectId: group.projectId, + branch: group.branch, + project: project + ) } } } @@ -573,6 +658,15 @@ struct BriefingView: View { Label("Open Project", systemImage: "folder") } + if appState.projectHasReleaseWorkflow(project) { + Divider() + Button { + createReleaseProject = project + } label: { + Label("Create Release", systemImage: "tag.fill") + } + } + if let url = gitHubURL(for: group, project: project) { Divider() Link(destination: url) { @@ -776,109 +870,5 @@ struct BriefingView: View { } } -// MARK: - Briefing Thread Row - -struct BriefingThreadRow: View { - let item: ThreadSummaryItem - let isInProgress: Bool - let todoProgress: ChatTodoProgress? - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "bubble.left.and.text.bubble.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - .frame(width: 14) - - Text(item.title) - .font(.system(size: 12.5, weight: .medium)) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - - if isInProgress { - BriefingThreadProgressBadge(progress: todoProgress) - } else { - Text(Self.compactDate(item.updatedAt)) - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - } - .padding(.horizontal, 6) - .padding(.vertical, 5) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(ClaudeTheme.surfaceSecondary.opacity(0.4)) - ) - } - .buttonStyle(.plain) - .help(isInProgress ? progressHelpText : "Open thread") - .accessibilityLabel(accessibilityLabel) - } - - private var progressHelpText: String { - guard let todoProgress, todoProgress.total > 0 else { - return String(localized: "Response in progress") - } - return String(localized: "Response in progress. Todos \(todoProgress.done)/\(todoProgress.total)") - } - - private var accessibilityLabel: String { - if isInProgress { - return String(localized: "\(item.title), in progress") - } - return item.title - } - - static func compactDate(_ date: Date, relativeTo now: Date = .now) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter.localizedString(for: date, relativeTo: now) - } -} - -private struct BriefingThreadProgressBadge: View { - let progress: ChatTodoProgress? - - private var fraction: Double? { - guard let progress, progress.total > 0 else { return nil } - return min(1, max(0, Double(progress.done) / Double(progress.total))) - } - - var body: some View { - HStack(spacing: 4) { - Group { - if let fraction { - ProgressView(value: fraction, total: 1) - } else { - ProgressView() - } - } - .progressViewStyle(.circular) - .controlSize(.mini) - .frame(width: 10, height: 10) - - Text("In progress") - .font(.system(size: 10.5, weight: .semibold)) - .lineLimit(1) - } - .foregroundStyle(ClaudeTheme.accent) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule(style: .continuous) - .fill(ClaudeTheme.accent.opacity(0.12)) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(ClaudeTheme.accent.opacity(0.25), lineWidth: 0.5) - ) - } -} - +// `BriefingThreadRow` / `BriefingThreadProgressBadge` live in `BriefingThreadRow.swift`. // `BriefingMarkdownView` lives in `BriefingMarkdownView.swift`. diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index f3142a6..ecdc3ef 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -25,6 +25,7 @@ struct ProjectTreeView: View { @State private var showAllChatsSheet = false @State private var downloadSecretProject: Project? = nil + @State private var createReleaseProject: Project? = nil var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -131,6 +132,15 @@ struct ProjectTreeView: View { SecretsDownloadSheet(project: project) .environment(appState) } + .sheet(item: $createReleaseProject) { project in + ReleaseCreateSheet( + repoId: project.gitHubRepo ?? "", + repoFullName: project.gitHubRepo ?? project.name, + currentVersion: appState.projectLatestReleaseVersion(project), + projectPath: project.path + ) + .environment(appState) + } .onChange(of: windowState.selectedProject?.id) { _, newId in if let newId { expandedProjectIds.insert(newId) } } @@ -141,6 +151,7 @@ struct ProjectTreeView: View { } .task(id: appState.projects.map { $0.gitHubRepo ?? "" }.joined(separator: ",")) { await appState.refreshSecretsStatuses() + await appState.refreshReleaseStatuses() } } @@ -270,7 +281,9 @@ private struct SummarySidebarSection: View { let url = DocsDeepLink.setupURL(repo: repo) else { return } openURL(url) }, - canSetupDocsSearch: project.gitHubRepo != nil + canSetupDocsSearch: project.gitHubRepo != nil, + hasReleaseWorkflow: appState.projectHasReleaseWorkflow(project), + onCreateRelease: { createReleaseProject = project } ) if expandedProjectIds.contains(project.id) { @@ -318,6 +331,8 @@ private struct ProjectTreeRow: View { let hasSecrets: Bool let onSetupDocsSearch: () -> Void let canSetupDocsSearch: Bool + let hasReleaseWorkflow: Bool + let onCreateRelease: () -> Void @State private var isHovered = false @State private var showLocationPopover = false @@ -450,7 +465,12 @@ private struct ProjectTreeRow: View { Label("Download Secret", systemImage: "key.fill") } } - if canSetupDocsSearch || hasSecrets { + if hasReleaseWorkflow { + Button { onCreateRelease() } label: { + Label("Create Release", systemImage: "tag.fill") + } + } + if canSetupDocsSearch || hasSecrets || hasReleaseWorkflow { Divider() } Button { onRename() } label: {