Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RxCode/Services/Hooks/AppStateHookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ final class AppStateHookController: HookController {
do {
let envs = try await app.secrets.listEnvironments(repo: repoFullName).items
return envs.map { HookChoice(id: $0.id, label: $0.name) }
} catch let SecretsService.ServiceError.apiError(status, _) where status == 404 {
// A 404 means the repo simply isn't registered for secrets yet — a
// definitive "no environments" answer, not a failed check. Return []
// (not nil) so callers surface the "set up secrets" banner.
logger.debug("secretEnvironments: repo \(repoFullName, privacy: .public) not registered (404) — treating as no environments")
return []
} catch {
// nil (not []) so callers don't mistake a failed/cancelled check for
// a repo that genuinely has no environments and show a stale banner.
Expand Down
35 changes: 29 additions & 6 deletions RxCode/Views/Toolbar/ExternalEditorMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,23 @@ final class ExternalEditorService {
// MARK: - ExternalEditorMenu

struct ExternalEditorMenu: View {
@Environment(AppState.self) private var appState
@Environment(WindowState.self) private var windowState

/// Pull request for the selected project's current branch, when one is known
/// (mirrors the briefing card). Drives the dedicated "Open Pull Request" item.
private func pullRequestURL(for project: Project) -> URL? {
_ = appState.ciStatusRevision
guard let status = appState.ciStatusByProject[project.id] else { return nil }
if let prUrl = status.prUrl, let url = URL(string: prUrl) {
return url
}
if let prNumber = status.prNumber {
return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(prNumber)")
}
return nil
}

var body: some View {
Menu {
let editors = ExternalEditorService.shared.detectedEditors()
Expand All @@ -141,12 +156,20 @@ struct ExternalEditorMenu: View {
}
}
Divider()
if let project = windowState.selectedProject,
let gitHubURL = ExternalEditorService.shared.gitHubURL(for: project) {
Button {
NSWorkspace.shared.open(gitHubURL)
} label: {
Label("Open in GitHub", systemImage: "globe")
if let project = windowState.selectedProject {
if let prURL = pullRequestURL(for: project) {
Button {
NSWorkspace.shared.open(prURL)
} label: {
Label("Open Pull Request", systemImage: "arrow.triangle.pull")
}
}
if let repoURL = ExternalEditorService.shared.gitHubURL(for: project) {
Button {
NSWorkspace.shared.open(repoURL)
} label: {
Label("Open Repository", systemImage: "globe")
}
}
}
Button {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
import java.time.Instant
import java.util.UUID

/**
* Domain models for the autopilot remote-management feature. Mirrors the
Expand Down Expand Up @@ -475,3 +476,66 @@ data class ReleaseGithubSecretResult(
val secretName: String,
val repositoryFullName: String,
)

// MARK: - Project context-menu results

/**
* Per-project autopilot state powering the mobile context menu. Mirrors the
* desktop's hasSecrets / hasDocs / hasRelease checks so the phone picks the
* same menu items (Download vs Set Up, etc.).
*/
@Serializable
data class AutopilotProjectStatus(
val gitHubRepo: String? = null,
val hasSecrets: Boolean = false,
val hasDocs: Boolean = false,
val hasRelease: Boolean = false,
)

/** Result of `projectCreatePullRequest`: the URL of the opened pull request. */
@Serializable
data class AutopilotPullRequestResult(val url: String)

/** Result of `projectSecretsWrite`: files written and any skipped conflicts. */
@Serializable
data class AutopilotProjectSecretsDownloadResult(
val written: List<String> = emptyList(),
val conflicts: List<String> = emptyList(),
)

// MARK: - Global search

/** One thread match from the desktop's on-device index. */
@Serializable
data class SearchHit(
val sessionID: String,
@Serializable(with = UuidSerializer::class) val projectID: UUID,
val title: String,
val snippet: String,
@Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant,
val score: Float = 0f,
)

/** One published-docs match from the rxlab docs service. */
@Serializable
data class DocsSearchHit(
val documentId: String? = null,
val docId: String,
val docsRepositoryId: String? = null,
val repositoryFullName: String? = null,
val version: Int? = null,
val similarity: Double? = null,
val snippet: String? = null,
val originalLink: String? = null,
) {
val stableId: String get() = documentId ?: "${repositoryFullName ?: ""}:$docId"
}

/** Combined search results for one query: thread hits + doc hits. */
@Serializable
data class AutopilotSearchResult(
val query: String,
val projectIDs: List<@Serializable(with = UuidSerializer::class) UUID> = emptyList(),
val threadHits: List<SearchHit> = emptyList(),
val docHits: List<DocsSearchHit> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ enum class AutopilotDomain(val wire: String) {
CI_UPDATES("ciUpdates"),
DOCS("docs"),
RELEASE("release"),
PROJECT("project"),
SEARCH("search"),
}

/** Every autopilot operation, globally unique so the desktop can switch on it. */
Expand Down Expand Up @@ -87,6 +89,23 @@ enum class AutopilotOp(val wire: String) {
RELEASE_LIST_WORKFLOWS("releaseListWorkflows"),
RELEASE_DISPATCH("releaseDispatch"),
RELEASE_INSTALL_TOKEN("releaseInstallToken"),

// Project context-menu actions — desktop-mediated so the Mac performs the
// exact work its own project/briefing context menu does. The `*Setup` ops
// ask the Mac to surface its setup chat/sheet; secrets download decrypts
// on-device then relays plaintext via `projectSecretsWrite`.
PROJECT_AUTOPILOT_STATUS("projectAutopilotStatus"),
PROJECT_SECRETS_SETUP("projectSecretsSetup"),
PROJECT_DOCS_SETUP("projectDocsSetup"),
PROJECT_DOCS_SEARCH("projectDocsSearch"),
PROJECT_RELEASE_SETUP("projectReleaseSetup"),
PROJECT_RELEASE_CREATE("projectReleaseCreate"),
PROJECT_SECRETS_DOWNLOAD("projectSecretsDownload"),
PROJECT_SECRETS_WRITE("projectSecretsWrite"),
PROJECT_CREATE_PULL_REQUEST("projectCreatePullRequest"),

// Global search — one call returns thread matches AND published-docs matches.
SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"),
}

/**
Expand Down Expand Up @@ -199,6 +218,42 @@ data class AutopilotReleaseDispatchBody(val repoId: String, val request: Release
@Serializable
data class AutopilotReleaseTokenBody(val repoId: String, val value: String)

// MARK: - Project context-menu bodies

/** Addresses a project by id (status + the desktop-mediated setup actions). */
@Serializable
data class AutopilotProjectBody(
@Serializable(with = UuidSerializer::class) val projectId: UUID,
)

/** Addresses a project + branch. Used by `projectCreatePullRequest`. */
@Serializable
data class AutopilotProjectBranchBody(
@Serializable(with = UuidSerializer::class) val projectId: UUID,
val branch: String,
)

/**
* Already-decrypted secret files for `projectSecretsWrite`: the phone decrypts
* the chosen environment on-device, then relays plaintext over the E2E channel
* for the Mac to write into the project folder.
*/
@Serializable
data class AutopilotProjectSecretsWriteBody(
@Serializable(with = UuidSerializer::class) val projectId: UUID,
val files: List<Plaintext>,
val overwrite: Boolean,
) {
@Serializable
data class Plaintext(val filename: String, val content: String)
}

// MARK: - Global search body

/** A single query run across both on-device threads and published docs. */
@Serializable
data class AutopilotSearchBody(val query: String, val limit: Int? = null)

// MARK: - Result list wrappers

@Serializable
Expand Down
28 changes: 28 additions & 0 deletions RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ sealed class Payload {
data class BranchOpResult(val data: BranchOpResultPayload) : Payload() {
override val type = "branch_op_result"
}
data class DeleteProjectRequest(val data: DeleteProjectRequestPayload) : Payload() {
override val type = "delete_project_request"
}
data class DeleteProjectResult(val data: DeleteProjectResultPayload) : Payload() {
override val type = "delete_project_result"
}
data class RunProfileMutationRequest(val data: RunProfileMutationRequestPayload) : Payload() {
override val type = "run_profile_mutation_request"
}
Expand Down Expand Up @@ -443,6 +449,24 @@ data class ThreadChangesResultPayload(
val uncommitted: List<SyncGitChange> = emptyList(),
)

/**
* Mobile → desktop: cascade-delete a project (sessions, search index, memory).
* The desktop confirms via [DeleteProjectResultPayload].
*/
@Serializable
data class DeleteProjectRequestPayload(
@Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(),
@Serializable(with = UuidSerializer::class) val projectID: UUID,
)

@Serializable
data class DeleteProjectResultPayload(
@Serializable(with = UuidSerializer::class) val clientRequestID: UUID,
@Serializable(with = UuidSerializer::class) val projectID: UUID,
val ok: Boolean,
val errorMessage: String? = null,
)

@Serializable
data class PingPayload(
@Serializable(with = SwiftDateSerializer::class) val t: Instant = Instant.now(),
Expand Down Expand Up @@ -496,6 +520,8 @@ object PayloadSerializer : KSerializer<Payload> {
"plan_decision" -> Payload.PlanDecision(json.decodeFromJsonElement(PlanDecisionPayload.serializer(), data))
"branch_op_request" -> Payload.BranchOpRequest(json.decodeFromJsonElement(BranchOpRequestPayload.serializer(), data))
"branch_op_result" -> Payload.BranchOpResult(json.decodeFromJsonElement(BranchOpResultPayload.serializer(), data))
"delete_project_request" -> Payload.DeleteProjectRequest(json.decodeFromJsonElement(DeleteProjectRequestPayload.serializer(), data))
"delete_project_result" -> Payload.DeleteProjectResult(json.decodeFromJsonElement(DeleteProjectResultPayload.serializer(), data))
"run_profile_mutation_request" -> Payload.RunProfileMutationRequest(json.decodeFromJsonElement(RunProfileMutationRequestPayload.serializer(), data))
"run_profile_result" -> Payload.RunProfileResult(json.decodeFromJsonElement(RunProfileResultPayload.serializer(), data))
"run_profile_run_request" -> Payload.RunProfileRunRequest(json.decodeFromJsonElement(RunProfileRunRequestPayload.serializer(), data))
Expand Down Expand Up @@ -537,6 +563,8 @@ object PayloadSerializer : KSerializer<Payload> {
is Payload.PlanDecision -> value.type to json.encodeToJsonElement(PlanDecisionPayload.serializer(), value.data)
is Payload.BranchOpRequest -> value.type to json.encodeToJsonElement(BranchOpRequestPayload.serializer(), value.data)
is Payload.BranchOpResult -> value.type to json.encodeToJsonElement(BranchOpResultPayload.serializer(), value.data)
is Payload.DeleteProjectRequest -> value.type to json.encodeToJsonElement(DeleteProjectRequestPayload.serializer(), value.data)
is Payload.DeleteProjectResult -> value.type to json.encodeToJsonElement(DeleteProjectResultPayload.serializer(), value.data)
is Payload.RunProfileMutationRequest -> value.type to json.encodeToJsonElement(RunProfileMutationRequestPayload.serializer(), value.data)
is Payload.RunProfileResult -> value.type to json.encodeToJsonElement(RunProfileResultPayload.serializer(), value.data)
is Payload.RunProfileRunRequest -> value.type to json.encodeToJsonElement(RunProfileRunRequestPayload.serializer(), value.data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import app.rxlab.rxcode.proto.AutopilotDocsUploadBody
import app.rxlab.rxcode.proto.AutopilotDomain
import app.rxlab.rxcode.proto.AutopilotIDBody
import app.rxlab.rxcode.proto.AutopilotOp
import app.rxlab.rxcode.proto.AutopilotProjectBody
import app.rxlab.rxcode.proto.AutopilotProjectBranchBody
import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult
import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody
import app.rxlab.rxcode.proto.AutopilotProjectStatus
import app.rxlab.rxcode.proto.AutopilotPullRequestResult
import app.rxlab.rxcode.proto.AutopilotSearchBody
import app.rxlab.rxcode.proto.AutopilotSearchResult
import app.rxlab.rxcode.proto.AutopilotReleaseDispatchBody
import app.rxlab.rxcode.proto.AutopilotReleaseTokenBody
import app.rxlab.rxcode.proto.AutopilotReposQuery
Expand Down Expand Up @@ -408,6 +416,83 @@ class AutopilotService(
)
)

// MARK: - Project context-menu actions (desktop-mediated, 1:1 with macOS)

/** Per-project autopilot state deciding which context-menu items to show. */
suspend fun projectAutopilotStatus(projectId: UUID): AutopilotProjectStatus =
decodeResult(
rawCall(
AutopilotDomain.PROJECT, AutopilotOp.PROJECT_AUTOPILOT_STATUS,
encodeBody(AutopilotProjectBody(projectId)),
)
)

/** Ask the Mac to surface the secrets-setup flow for the project. */
suspend fun requestProjectSecretsSetup(projectId: UUID) {
rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_SECRETS_SETUP, encodeBody(AutopilotProjectBody(projectId)))
}

/** Ask the Mac to start the docs-setup chat for the project. */
suspend fun requestProjectDocsSetup(projectId: UUID) {
rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_DOCS_SETUP, encodeBody(AutopilotProjectBody(projectId)))
}

/** Ask the Mac to open its docs search overlay. */
suspend fun requestProjectDocsSearch(projectId: UUID) {
rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_DOCS_SEARCH, encodeBody(AutopilotProjectBody(projectId)))
}

/** Ask the Mac to start the release-setup chat for the project. */
suspend fun requestProjectReleaseSetup(projectId: UUID) {
rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_RELEASE_SETUP, encodeBody(AutopilotProjectBody(projectId)))
}

/** Ask the Mac to present its create-release sheet for the project. */
suspend fun requestProjectReleaseCreate(projectId: UUID) {
rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_RELEASE_CREATE, encodeBody(AutopilotProjectBody(projectId)))
}

/**
* Ask the Mac to open a pull request for the project's branch (it pushes the
* branch, drafts the title/body from the briefing, and opens the PR). Returns
* the PR URL so the phone can open it.
*/
suspend fun requestProjectCreatePullRequest(projectId: UUID, branch: String): String =
decodeResult<AutopilotPullRequestResult>(
rawCall(
AutopilotDomain.PROJECT, AutopilotOp.PROJECT_CREATE_PULL_REQUEST,
encodeBody(AutopilotProjectBranchBody(projectId, branch)),
)
).url

/**
* Relay already-decrypted secret files for the Mac to write into the project
* folder. Decryption happens on-device first (see [SecretsManager]); this only
* carries plaintext over the E2E channel. Returns files written + conflicts.
*/
suspend fun projectSecretsWrite(
projectId: UUID,
files: List<AutopilotProjectSecretsWriteBody.Plaintext>,
overwrite: Boolean,
): AutopilotProjectSecretsDownloadResult =
decodeResult(
rawCall(
AutopilotDomain.PROJECT, AutopilotOp.PROJECT_SECRETS_WRITE,
encodeBody(AutopilotProjectSecretsWriteBody(projectId, files, overwrite)),
)
)

// MARK: - Global search

/** One round trip returning thread matches AND published-docs matches. */
suspend fun searchThreadsAndDocs(query: String, limit: Int? = null): AutopilotSearchResult =
decodeResult(
rawCall(
AutopilotDomain.SEARCH, AutopilotOp.SEARCH_THREADS_AND_DOCS,
encodeBody(AutopilotSearchBody(query, limit)),
)
)

companion object {
/** Longer ceiling than config calls: scans/secret minting take a while. */
private const val AUTOPILOT_TIMEOUT_MS = 45_000L
Expand Down
Loading
Loading