diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index e4941b9a..a9e3db9d 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -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. diff --git a/RxCode/Views/Toolbar/ExternalEditorMenu.swift b/RxCode/Views/Toolbar/ExternalEditorMenu.swift index f4764432..02a5dd63 100644 --- a/RxCode/Views/Toolbar/ExternalEditorMenu.swift +++ b/RxCode/Views/Toolbar/ExternalEditorMenu.swift @@ -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() @@ -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 { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt index ec7a8e7e..a2224f5b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt @@ -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 @@ -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 = emptyList(), + val conflicts: List = 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 = emptyList(), + val docHits: List = emptyList(), +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt index 7c1941bc..70f04c7d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt @@ -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. */ @@ -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"), } /** @@ -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, + 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 diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt index c35bf951..e7ff8ef9 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt @@ -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" } @@ -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(), @@ -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)) @@ -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) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt index 655537d0..6937e915 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt @@ -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 @@ -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 diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index 386bca8f..e2ef661b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -1,12 +1,17 @@ package app.rxlab.rxcode.state +import android.content.Context import android.os.Build import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.rxlab.rxcode.BuildConfig +import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult +import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody +import app.rxlab.rxcode.proto.AutopilotProjectStatus import app.rxlab.rxcode.proto.BranchOpRequestPayload import app.rxlab.rxcode.proto.CancelStreamPayload +import app.rxlab.rxcode.proto.DeleteProjectRequestPayload import app.rxlab.rxcode.proto.LoadMoreMessagesRequestPayload import app.rxlab.rxcode.proto.MobileRunTaskSnapshot import app.rxlab.rxcode.proto.NewSessionRequestPayload @@ -69,6 +74,8 @@ class MobileAppState @Inject constructor( private var started = false private var pairingTimeout: Job? = null private var pendingThreadChangesId: UUID? = null + private var pendingDeleteProjectId: UUID? = null + private var searchJob: Job? = null /** * Autopilot remote-management round-trips (mirrors the desktop's Autopilot @@ -182,6 +189,17 @@ class MobileAppState @Inject constructor( if (payload.data.ok) requestSnapshot("branch_op_${payload.data.operation.name}") else Log.w(TAG, "branch op ${payload.data.operation.name} failed: ${payload.data.errorMessage}") } + is Payload.DeleteProjectResult -> { + if (!isActiveDesktop(fromHex)) return + if (payload.data.clientRequestID != pendingDeleteProjectId) return + pendingDeleteProjectId = null + if (!payload.data.ok) { + Log.w(TAG, "delete project failed: ${payload.data.errorMessage}") + // Restore the optimistically-removed project from the desktop. + _state.update { it.copy(lastError = payload.data.errorMessage ?: "Couldn't delete the project.") } + requestSnapshot("delete_project_failed") + } + } is Payload.RunProfileResult -> handleRunProfileResult(fromHex, payload.data) is Payload.RunTaskUpdate -> handleRunTaskUpdate(fromHex, payload.data.task) is Payload.ThreadChangesResult -> handleThreadChangesResult(fromHex, payload.data) @@ -571,6 +589,122 @@ class MobileAppState @Inject constructor( } } + // MARK: - Project context-menu actions + + /** + * Cascade-delete a project on the desktop. Optimistically drops it (and its + * sessions) from local state so the UI updates immediately; the desktop + * confirms via `delete_project_result`. On failure [handleInbound] re-syncs + * to restore the project. + */ + fun deleteProject(projectId: UUID) { + if (!_state.value.isPaired) return + val request = DeleteProjectRequestPayload(projectID = projectId) + pendingDeleteProjectId = request.clientRequestID + _state.update { + it.copy( + projects = it.projects.filterNot { p -> p.id == projectId }, + sessions = it.sessions.filterNot { s -> s.projectId == projectId }, + ) + } + viewModelScope.launch { + val sent = client.send(Payload.DeleteProjectRequest(request), _state.value.activeDesktopPubkey) + if (!sent) { + pendingDeleteProjectId = null + _state.update { it.copy(lastError = "Couldn't reach your Mac to delete the project.") } + requestSnapshot("delete_project_send_failed") + } + } + } + + /** + * Per-project autopilot state (has secrets / docs / release), used by the + * project context menu to choose the same items the desktop menu shows. + */ + suspend fun projectAutopilotStatus(projectId: UUID): AutopilotProjectStatus = + autopilot.projectAutopilotStatus(projectId) + + /** Open a PR for the project's branch on the Mac; returns the PR URL. */ + suspend fun requestProjectCreatePullRequest(projectId: UUID, branch: String): String = + autopilot.requestProjectCreatePullRequest(projectId, branch) + + /** + * Download a secret environment into the project folder: decrypt on-device + * with the passkey-derived KEK, then relay the plaintext for the Mac to write. + * Mirrors iOS `downloadProjectSecrets`. Returns files written + conflicts. + */ + suspend fun downloadProjectSecrets( + context: Context, + projectId: UUID, + repo: String, + envId: String, + overwrite: Boolean, + ): AutopilotProjectSecretsDownloadResult { + val plaintext = secrets.environmentPlaintext(context, repo, envId) + val files = plaintext.map { AutopilotProjectSecretsWriteBody.Plaintext(it.filename, it.content) } + return autopilot.projectSecretsWrite(projectId, files, overwrite) + } + + // MARK: - Global search + + /** + * Run a combined threads + docs search for [query], debounced by 200 ms. + * Mirrors iOS `updateSearchQuery`: a blank query clears results; otherwise one + * `searchThreadsAndDocs` autopilot round trip populates both hit lists. Each + * keystroke cancels the previous in-flight search so only the latest applies. + */ + fun updateSearchQuery(query: String) { + _state.update { it.copy(searchQuery = query) } + searchJob?.cancel() + val trimmed = query.trim() + if (trimmed.isEmpty()) { + _state.update { + it.copy( + isSearching = false, + searchThreadHits = emptyList(), + searchDocHits = emptyList(), + searchProjectIDs = emptyList(), + ) + } + return + } + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + _state.update { it.copy(isSearching = true) } + try { + val result = autopilot.searchThreadsAndDocs(trimmed, limit = SEARCH_LIMIT) + // Ignore stale results whose query no longer matches the field. + if (_state.value.searchQuery.trim() != trimmed) return@launch + _state.update { + it.copy( + isSearching = false, + searchThreadHits = result.threadHits, + searchDocHits = result.docHits, + searchProjectIDs = result.projectIDs, + ) + } + } catch (t: Throwable) { + if (_state.value.searchQuery.trim() != trimmed) return@launch + Log.w(TAG, "search failed: ${t.message}") + _state.update { it.copy(isSearching = false) } + } + } + } + + /** Clear the search field and any results (e.g. when leaving the search UI). */ + fun clearSearch() { + searchJob?.cancel() + _state.update { + it.copy( + searchQuery = "", + isSearching = false, + searchThreadHits = emptyList(), + searchDocHits = emptyList(), + searchProjectIDs = emptyList(), + ) + } + } + fun respondToPermission(allow: Boolean, denyReason: String? = null) { val req = _state.value.pendingPermission ?: return _state.update { it.copy(pendingPermission = null) } @@ -856,6 +990,8 @@ class MobileAppState @Inject constructor( private const val TAG = "MobileAppState" private const val PAIRING_TIMEOUT_MS = 25_000L const val MESSAGE_PAGE_SIZE = 30 + private const val SEARCH_DEBOUNCE_MS = 200L + private const val SEARCH_LIMIT = 25 fun defaultDisplayName(): String = Build.MODEL.ifBlank { "Android" } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt index ecf283a4..d94baa0d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt @@ -1,6 +1,7 @@ package app.rxlab.rxcode.state import app.rxlab.rxcode.proto.ChatMessage +import app.rxlab.rxcode.proto.DocsSearchHit import app.rxlab.rxcode.proto.MobileBranchBriefing import app.rxlab.rxcode.proto.MobileRunTaskSnapshot import app.rxlab.rxcode.proto.MobileThreadSummary @@ -10,6 +11,7 @@ import app.rxlab.rxcode.proto.PermissionRequestPayload import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ProjectBranchInfo import app.rxlab.rxcode.proto.RunProfile +import app.rxlab.rxcode.proto.SearchHit import app.rxlab.rxcode.proto.SessionSummary import app.rxlab.rxcode.proto.ThreadChangesResultPayload import app.rxlab.rxcode.relay.RelayClient @@ -80,6 +82,17 @@ data class MobileState( * then clears it via [MobileAppState.consumePendingNotificationDeepLink]. */ val pendingNotificationSessionID: String? = null, + + /** + * Global search state (threads + published docs), mirroring iOS + * `MobileAppState`. Populated by [MobileAppState.updateSearchQuery], which + * runs one combined `searchThreadsAndDocs` autopilot call per debounced query. + */ + val searchQuery: String = "", + val searchThreadHits: List<SearchHit> = emptyList(), + val searchDocHits: List<DocsSearchHit> = emptyList(), + val searchProjectIDs: List<UUID> = emptyList(), + val isSearching: Boolean = false, ) { val isPaired: Boolean get() = activeDesktopPubkey.isNotEmpty() diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt index 66638afe..ba7d770b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt @@ -1,11 +1,15 @@ package app.rxlab.rxcode.ui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.ui.Modifier import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,6 +21,7 @@ import app.rxlab.rxcode.state.MobileState import app.rxlab.rxcode.ui.briefing.BriefingPaneScreen import app.rxlab.rxcode.ui.onboarding.OnboardingScreen import app.rxlab.rxcode.ui.projects.ProjectsPaneScreen +import app.rxlab.rxcode.ui.search.SearchScreen import app.rxlab.rxcode.ui.settings.SettingsScreen import app.rxlab.rxcode.ui.sync.SyncLoadingView import androidx.compose.runtime.LaunchedEffect @@ -76,6 +81,12 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { var currentTab by rememberSaveable { mutableStateOf(RootTab.Briefing.name) } val tab = RootTab.valueOf(currentTab) + // Global search is presented as a full-screen overlay rather than a tab — + // every list/detail screen surfaces a toolbar search button that flips this + // (mirrors iOS, where search lives in the toolbar, not the tab bar). + var showSearch by rememberSaveable { mutableStateOf(false) } + val openSearch = { showSearch = true } + // FCM notification tap: select the target session and switch to Projects // (where ProjectsPaneScreen reacts to `activeSessionID` and pushes the // chat detail). Buffered in state by MobileAppState so we route the UI @@ -87,45 +98,68 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { viewModel.consumePendingNotificationDeepLink() } - NavigationSuiteScaffold( - navigationSuiteItems = { - RootTab.allTabs.forEach { t -> - item( - selected = t == tab, - onClick = { currentTab = t.name }, - icon = { Icon(t.icon, contentDescription = null) }, - label = { Text(t.label) }, + Box(Modifier.fillMaxSize()) { + NavigationSuiteScaffold( + navigationSuiteItems = { + RootTab.allTabs.forEach { t -> + item( + selected = t == tab, + onClick = { currentTab = t.name }, + icon = { Icon(t.icon, contentDescription = null) }, + label = { Text(t.label) }, + ) + } + } + ) { + when (tab) { + RootTab.Briefing -> BriefingPaneScreen( + state = state, + viewModel = viewModel, + onOpenSession = { sid -> + // ProjectsPaneScreen reacts to `state.activeSessionID` and + // pushes the chat detail itself, so we just have to switch + // tabs after selecting the session. + viewModel.selectSession(sid) + currentTab = RootTab.Projects.name + }, + onNewThread = { pid -> + viewModel.startNewSession(pid, planMode = false) + currentTab = RootTab.Projects.name + }, + onOpenSearch = openSearch, + ) + RootTab.Projects -> ProjectsPaneScreen( + state = state, + viewModel = viewModel, + onSettingsClick = { currentTab = RootTab.Settings.name }, + onOpenSearch = openSearch, + ) + RootTab.Settings -> SettingsScreen( + state = state, + viewModel = viewModel, + onBack = { currentTab = RootTab.Briefing.name }, + onPairNewMac = { showPairingFromSplash = true }, + onOpenSearch = openSearch, ) } } - ) { - when (tab) { - RootTab.Briefing -> BriefingPaneScreen( - state = state, - viewModel = viewModel, - onOpenSession = { sid -> - // ProjectsPaneScreen reacts to `state.activeSessionID` and - // pushes the chat detail itself, so we just have to switch - // tabs after selecting the session. - viewModel.selectSession(sid) - currentTab = RootTab.Projects.name - }, - onNewThread = { pid -> - viewModel.startNewSession(pid, planMode = false) - currentTab = RootTab.Projects.name - }, - ) - RootTab.Projects -> ProjectsPaneScreen( - state = state, - viewModel = viewModel, - onSettingsClick = { currentTab = RootTab.Settings.name }, - ) - RootTab.Settings -> SettingsScreen( - state = state, - viewModel = viewModel, - onBack = { currentTab = RootTab.Briefing.name }, - onPairNewMac = { showPairingFromSplash = true }, - ) + + if (showSearch) { + Surface(Modifier.fillMaxSize()) { + SearchScreen( + state = state, + viewModel = viewModel, + onOpenSession = { sid -> + viewModel.selectSession(sid) + currentTab = RootTab.Projects.name + showSearch = false + }, + onClose = { + showSearch = false + viewModel.clearSearch() + }, + ) + } } } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt new file mode 100644 index 00000000..43acd0fb --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt @@ -0,0 +1,260 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MergeType +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.HourglassEmpty +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.AutopilotProjectStatus +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.ui.sheets.ProjectReleaseCreateSheet +import app.rxlab.rxcode.ui.sheets.ProjectSecretsDownloadSheet +import kotlinx.coroutines.launch + +private const val SECRETS_SETUP_PROMPT = + "Set up encrypted secrets for this repository so I can sync environment files securely." +private const val DOCS_SETUP_PROMPT = + "Set up documentation publishing for this repository so its docs are indexed into RxCode docs search." +private const val RELEASE_SETUP_PROMPT = + "Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow)." + +/** + * One-to-one mobile mirror of the desktop project/briefing autopilot context + * menu (`ProjectAutopilotMenuItems` on iOS), plus the per-screen extras: a + * "Create Pull Request" / "Open on GitHub" pair (briefing detail) and an + * optional destructive "Delete Project" (session list). + * + * Drop into a `TopAppBar`'s `actions = { }`. Autopilot items only appear for + * repo-backed projects, gated on the per-project [AutopilotProjectStatus] this + * loads on first composition. Every item is desktop-mediated except "Download + * Secret", which decrypts on-device and relays plaintext for the Mac to write. + * + * @param branch current branch — enables "Create Pull Request" (hidden when null + * or `"unknown"`) and seeds the release form's branch picker. + * @param onOpenSession navigate to a chat after a setup action creates one. + * @param onProjectDeleted called after confirming "Delete Project" so the host + * can pop back (only reachable when [includeDeleteProject] is true). + */ +@Composable +fun ProjectActionsMenu( + project: Project, + branch: String?, + branchInfo: ProjectBranchInfo?, + viewModel: MobileAppState, + includeDeleteProject: Boolean = false, + onOpenSession: (String) -> Unit = {}, + onProjectDeleted: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val hasRepo = !project.gitHubRepo.isNullOrEmpty() + val canCreatePR = branch != null && !branch.equals("unknown", ignoreCase = true) + + var menuOpen by remember { mutableStateOf(false) } + var status by remember(project.id) { mutableStateOf<AutopilotProjectStatus?>(null) } + var showDownload by remember { mutableStateOf(false) } + var showReleaseCreate by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var isCreatingPR by remember { mutableStateOf(false) } + var info by remember { mutableStateOf<String?>(null) } + + // Only repo-backed projects have autopilot state to load. + androidx.compose.runtime.LaunchedEffect(project.id) { + if (hasRepo) status = runCatching { viewModel.projectAutopilotStatus(project.id) }.getOrNull() + } + + fun startSetupChat(prompt: String) { + menuOpen = false + val sessionId = viewModel.startNewSession(projectId = project.id, initialText = prompt) + onOpenSession(sessionId) + } + + fun createPullRequest() { + if (isCreatingPR || branch == null) return + isCreatingPR = true + menuOpen = false + scope.launch { + try { + val url = viewModel.requestProjectCreatePullRequest(project.id, branch) + viewModel.requestSnapshot("pr_created") + uriHandler.openUri(url) + } catch (t: Throwable) { + info = t.message ?: "Couldn't create the pull request." + } finally { + isCreatingPR = false + } + } + } + + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = "Project actions") + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (hasRepo) { + val loaded = status + if (loaded == null) { + DropdownMenuItem( + text = { Text("Loading Autopilot…") }, + leadingIcon = { Icon(Icons.Outlined.HourglassEmpty, contentDescription = null) }, + enabled = false, + onClick = {}, + ) + } else { + // Secrets: download when present, otherwise offer setup. + if (loaded.hasSecrets) { + DropdownMenuItem( + text = { Text("Download Secret") }, + leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, + onClick = { menuOpen = false; showDownload = true }, + ) + } else { + DropdownMenuItem( + text = { Text("Set Up Secrets") }, + leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, + onClick = { startSetupChat(SECRETS_SETUP_PROMPT) }, + ) + } + // Docs: only offer setup when not yet indexed. + if (!loaded.hasDocs) { + DropdownMenuItem( + text = { Text("Set Up Docs") }, + leadingIcon = { Icon(Icons.Outlined.Book, contentDescription = null) }, + onClick = { startSetupChat(DOCS_SETUP_PROMPT) }, + ) + } + // Release: create when configured, otherwise offer setup. + if (loaded.hasRelease) { + DropdownMenuItem( + text = { Text("Create Release") }, + leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, + onClick = { menuOpen = false; showReleaseCreate = true }, + ) + } else { + DropdownMenuItem( + text = { Text("Set Up Release Workflow") }, + leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, + onClick = { startSetupChat(RELEASE_SETUP_PROMPT) }, + ) + } + if (canCreatePR) { + DropdownMenuItem( + text = { Text("Create Pull Request") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.MergeType, contentDescription = null) }, + enabled = !isCreatingPR, + onClick = { createPullRequest() }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Open on GitHub") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, + onClick = { + menuOpen = false + uriHandler.openUri("https://github.com/${project.gitHubRepo}") + }, + ) + } + } + + if (includeDeleteProject) { + if (hasRepo) HorizontalDivider() + DropdownMenuItem( + text = { Text("Delete Project", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + }, + onClick = { menuOpen = false; showDeleteConfirm = true }, + ) + } + } + + if (showDownload) { + ProjectSecretsDownloadSheet(viewModel = viewModel, project = project, onDismiss = { showDownload = false }) + } + + if (showReleaseCreate) { + ProjectReleaseCreateSheet( + viewModel = viewModel, + project = project, + branchInfo = branchInfo, + onDismiss = { showReleaseCreate = false }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + icon = { Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Delete project?") }, + text = { + Text( + "This permanently removes \"${project.name}\" and all its threads, search index, and memory " + + "from both your Mac and this device.", + ) + }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.deleteProject(project.id) + onProjectDeleted() + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } + + if (isCreatingPR) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text("Creating Pull Request…") }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Text("The Mac is pushing the branch and opening the PR.") + } + }, + ) + } + + info?.let { message -> + AlertDialog( + onDismissRequest = { info = null }, + title = { Text("Couldn't Complete") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { info = null }) { Text("OK") } }, + ) + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index 020489cc..32e4a1e7 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.automirrored.outlined.TextSnippet import androidx.compose.material3.AssistChip @@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.MobileThreadSummary import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent import app.rxlab.rxcode.ui.util.RxMarkdownText @@ -76,6 +78,7 @@ fun BriefingDetailScreen( onOpenSession: (String) -> Unit, onNewThread: (sessionId: String) -> Unit, selectedSessionId: String? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val group by remember(state.branchBriefings, state.threadSummaries, groupKey) { @@ -111,6 +114,22 @@ fun BriefingDetailScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } + // Autopilot / GitHub context menu, 1:1 with iOS + // MobileBriefingDetailView. Repo-gated like the desktop menu. + project?.takeIf { !it.gitHubRepo.isNullOrEmpty() }?.let { repoProject -> + ProjectActionsMenu( + project = repoProject, + branch = groupKey.branch, + branchInfo = branchInfo, + viewModel = viewModel, + onOpenSession = onOpenSession, + ) + } + }, ) }, floatingActionButton = { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt index 5fcb34f8..a3e0665d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt @@ -53,10 +53,12 @@ fun BriefingPaneScreen( viewModel: MobileAppState, onOpenSession: (String) -> Unit, onNewThread: (projectId: java.util.UUID) -> Unit, + onOpenSearch: () -> Unit, ) { BriefingContentSplitPane( state = state, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } @@ -65,6 +67,7 @@ fun BriefingPaneScreen( private fun BriefingContentSplitPane( state: MobileState, viewModel: MobileAppState, + onOpenSearch: () -> Unit, ) { var selectedGroupKey by rememberSaveable(stateSaver = briefingGroupKeySaver) { mutableStateOf<BriefingGroupKey?>(null) @@ -126,6 +129,7 @@ private fun BriefingContentSplitPane( selectedSessionId = null viewModel.selectSession(null) }, + onOpenSearch = onOpenSearch, availableWidth = maxWidth, ) return@BoxWithConstraints @@ -154,6 +158,7 @@ private fun BriefingContentSplitPane( selectedSessionId = null viewModel.selectSession(null) }, + onOpenSearch = onOpenSearch, ) } } @@ -168,6 +173,7 @@ private fun CompactBriefingPane( onSelectSession: (String) -> Unit, onClearGroup: () -> Unit, onClearSession: () -> Unit, + onOpenSearch: () -> Unit, ) { val sid = selectedSessionId val key = selectedGroupKey @@ -191,6 +197,7 @@ private fun CompactBriefingPane( onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } else -> { @@ -199,6 +206,7 @@ private fun CompactBriefingPane( viewModel = viewModel, onOpenGroup = onSelectGroup, selectedGroupKey = selectedGroupKey, + onOpenSearch = onOpenSearch, ) } } @@ -214,6 +222,7 @@ private fun WideBriefingPane( onSelectSession: (String) -> Unit, onClearGroup: () -> Unit, onClearSession: () -> Unit, + onOpenSearch: () -> Unit, availableWidth: Dp, ) { var isDetailExpanded by rememberSaveable { mutableStateOf(false) } @@ -243,6 +252,7 @@ private fun WideBriefingPane( onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } else { BriefingScreen( @@ -250,6 +260,7 @@ private fun WideBriefingPane( viewModel = viewModel, onOpenGroup = onSelectGroup, selectedGroupKey = selectedGroupKey, + onOpenSearch = onOpenSearch, ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt index 6fd0907a..5246be93 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.FilterAltOff import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Schedule @@ -79,6 +80,7 @@ fun BriefingScreen( viewModel: MobileAppState, onOpenGroup: (BriefingGroupKey) -> Unit, selectedGroupKey: BriefingGroupKey? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val scope = rememberCoroutineScope() @@ -134,6 +136,9 @@ fun BriefingScreen( CenterAlignedTopAppBar( title = { Text("Briefing") }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } if (hasAnyData) { Box { IconButton(onClick = { menuOpen = true }) { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt index 6fdfebab..8652bdd4 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt @@ -66,6 +66,7 @@ fun ProjectsPaneScreen( state: MobileState, viewModel: MobileAppState, onSettingsClick: () -> Unit, + onOpenSearch: () -> Unit, ) { var selectedProjectId by rememberSaveable(stateSaver = uuidSaver) { mutableStateOf<UUID?>(null) @@ -85,6 +86,7 @@ fun ProjectsPaneScreen( onSelectProject = { selectedProjectId = it }, onSettingsClick = onSettingsClick, onBackToProjects = { selectedProjectId = null }, + onOpenSearch = onOpenSearch, availableWidth = maxWidth, ) } else { @@ -95,6 +97,7 @@ fun ProjectsPaneScreen( onSelectProject = { selectedProjectId = it }, onSettingsClick = onSettingsClick, onBackToProjects = { selectedProjectId = null }, + onOpenSearch = onOpenSearch, ) } } @@ -109,6 +112,7 @@ private fun CollapsingProjectsPane( onSelectProject: (UUID) -> Unit, onSettingsClick: () -> Unit, onBackToProjects: () -> Unit, + onOpenSearch: () -> Unit, ) { val navigator = rememberListDetailPaneScaffoldNavigator<String>() val scope = rememberCoroutineScope() @@ -152,6 +156,7 @@ private fun CollapsingProjectsPane( onProjectClick = { project -> onSelectProject(project.id) }, onSettingsClick = onSettingsClick, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } else { SessionsScreen( @@ -171,6 +176,7 @@ private fun CollapsingProjectsPane( }, onBack = onBackToProjects, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } } @@ -204,6 +210,7 @@ private fun WideProjectsPane( onSelectProject: (UUID) -> Unit, onSettingsClick: () -> Unit, onBackToProjects: () -> Unit, + onOpenSearch: () -> Unit, availableWidth: Dp, ) { var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) } @@ -251,6 +258,7 @@ private fun WideProjectsPane( }, onSettingsClick = onSettingsClick, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } else { SessionsScreen( @@ -267,6 +275,7 @@ private fun WideProjectsPane( onBack = onBackToProjects, viewModel = viewModel, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt index 4e8bcbab..ce8ebe89 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -59,6 +60,7 @@ fun ProjectsScreen( onSettingsClick: () -> Unit, selectedProjectId: java.util.UUID? = null, viewModel: MobileAppState? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val scope = rememberCoroutineScope() @@ -69,6 +71,9 @@ fun ProjectsScreen( CenterAlignedTopAppBar( title = { Text("Projects") }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } IconButton(onClick = onSettingsClick) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt new file mode 100644 index 00000000..7635eede --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt @@ -0,0 +1,331 @@ +package app.rxlab.rxcode.ui.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Article +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.DocsSearchHit +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.SearchHit +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.util.RxMarkdownText + +private enum class SearchScope(val label: String) { ALL("All"), THREADS("Threads"), DOCS("Docs") } + +/** + * Global search across threads + published docs. Mirrors iOS `MobileSearchView`: + * a search field, an All / Threads / Docs scope selector with live match counts, + * and result cards. Tapping a thread opens its chat; tapping a doc opens an + * on-demand markdown viewer. One debounced `searchThreadsAndDocs` autopilot call + * (see [MobileAppState.updateSearchQuery]) feeds both result lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + state: MobileState, + viewModel: MobileAppState, + onOpenSession: (String) -> Unit, + onClose: () -> Unit, +) { + BackHandler(onBack = onClose) + + var scope by remember { mutableStateOf(SearchScope.ALL) } + var openDoc by remember { mutableStateOf<DocsSearchHit?>(null) } + val projectsById = remember(state.projects) { state.projects.associateBy { it.id } } + val hasQuery = state.searchQuery.trim().isNotEmpty() + val settled = hasQuery && !state.isSearching + + fun count(scopeFor: SearchScope): Int = when (scopeFor) { + SearchScope.ALL -> state.searchThreadHits.size + state.searchDocHits.size + SearchScope.THREADS -> state.searchThreadHits.size + SearchScope.DOCS -> state.searchDocHits.size + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column(Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Close search") + } + TextField( + value = state.searchQuery, + onValueChange = viewModel::updateSearchQuery, + modifier = Modifier.weight(1f), + placeholder = { Text("Search threads and docs") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (hasQuery) { + IconButton(onClick = { viewModel.clearSearch() }) { + Icon(Icons.Outlined.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + ), + shape = MaterialTheme.shapes.large, + ) + } + Row(Modifier.fillMaxWidth().padding(top = 10.dp)) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SearchScope.entries.forEachIndexed { index, item -> + SegmentedButton( + selected = scope == item, + onClick = { scope = item }, + shape = SegmentedButtonDefaults.itemShape(index, SearchScope.entries.size), + ) { + Text(if (settled) "${item.label} (${count(item)})" else item.label) + } + } + } + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + !hasQuery -> CenteredHint("Search your threads and published docs.") + state.isSearching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Text("Searching…", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else -> { + val showThreads = scope != SearchScope.DOCS + val showDocs = scope != SearchScope.THREADS + val threadHits = if (showThreads) state.searchThreadHits else emptyList() + val docHits = if (showDocs) state.searchDocHits else emptyList() + if (threadHits.isEmpty() && docHits.isEmpty()) { + CenteredHint("No results for \"${state.searchQuery.trim()}\".") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (threadHits.isNotEmpty()) { + item("threads-header") { SectionLabel("Threads") } + items(threadHits, key = { "t:${it.sessionID}" }) { hit -> + ThreadHitCard(hit = hit, project = projectsById[hit.projectID], onClick = { onOpenSession(hit.sessionID) }) + } + } + if (docHits.isNotEmpty()) { + item("docs-header") { SectionLabel("Docs") } + items(docHits, key = { "d:${it.stableId}" }) { hit -> + DocHitCard(hit = hit, onClick = { openDoc = hit }) + } + } + } + } + } + } + } + } + + openDoc?.let { hit -> + DocViewerSheet(viewModel = viewModel, hit = hit, onDismiss = { openDoc = null }) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) +} + +@Composable +private fun CenteredHint(text: String) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ThreadHitCard(hit: SearchHit, project: Project?, onClick: () -> Unit) { + ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + hit.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + if (hit.snippet.isNotBlank()) { + Text( + hit.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + project?.name?.let { Badge(it) } + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DocHitCard(hit: DocsSearchHit, onClick: () -> Unit) { + val title = hit.docId.ifBlank { hit.repositoryFullName ?: "Document" } + ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.AutoMirrored.Outlined.Article, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1) + hit.snippet?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + hit.repositoryFullName?.let { Badge(it) } + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun Badge(text: String) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +/** + * On-demand doc viewer. Fetches the rendered markdown for the tapped hit via the + * docs `getDocument` autopilot call and renders it; mirrors iOS `MobileDocumentView`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DocViewerSheet(viewModel: MobileAppState, hit: DocsSearchHit, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var content by remember { mutableStateOf<String?>(null) } + var error by remember { mutableStateOf<String?>(null) } + var isLoading by remember { mutableStateOf(true) } + val repoId = hit.docsRepositoryId ?: hit.repositoryFullName + + LaunchedEffect(hit.stableId) { + if (repoId == null) { + error = "This document has no repository reference." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val detail = viewModel.autopilot.getDocument(repoId, hit.docId) + content = detail.currentVersion?.content ?: "" + } catch (t: Throwable) { + error = t.message ?: "Couldn't load the document." + } + isLoading = false + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + hit.docId.ifBlank { hit.repositoryFullName ?: "Document" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + when { + isLoading -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + error != null -> Text(error!!, color = MaterialTheme.colorScheme.error) + content.isNullOrBlank() -> Text("This document is empty.", color = MaterialTheme.colorScheme.onSurfaceVariant) + else -> RxMarkdownText(markdown = content!!) + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index 3f2ab8c4..79066402 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.outlined.DriveFileRenameOutline import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -60,6 +61,7 @@ import app.rxlab.rxcode.proto.SessionSummary import app.rxlab.rxcode.proto.TodoItem import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.sheets.RenameThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent @@ -86,6 +88,7 @@ fun SessionsScreen( onBack: () -> Unit, viewModel: MobileAppState, selectedSessionId: String? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val project = state.projects.firstOrNull { it.id == projectId } @@ -109,6 +112,25 @@ fun SessionsScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } + // Project-level context menu (autopilot actions + Delete + // Project), 1:1 with iOS SessionsList. The per-thread row + // menu (rename/archive/delete) stays on each card below. + project?.let { proj -> + ProjectActionsMenu( + project = proj, + branch = branchInfo?.currentBranch, + branchInfo = branchInfo, + viewModel = viewModel, + includeDeleteProject = true, + onOpenSession = onNewThread, + onProjectDeleted = onBack, + ) + } + }, ) }, floatingActionButton = { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt index 8945bdac..379446fb 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.outlined.DesktopMac import androidx.compose.material.icons.outlined.Flight import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Router +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults @@ -32,8 +33,10 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -79,6 +82,7 @@ fun SettingsScreen( viewModel: MobileAppState, onBack: () -> Unit, onPairNewMac: (() -> Unit)? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() var unpairTarget by remember { mutableStateOf<PairedDesktop?>(null) } @@ -104,6 +108,11 @@ fun SettingsScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } + }, ) }, ) { padding -> @@ -326,9 +335,9 @@ private fun PairedDesktopCard( Surface( shape = CircleShape, color = if (isActive) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.secondaryContainer, + else MaterialTheme.colorScheme.secondary, contentColor = if (isActive) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSecondaryContainer, + else MaterialTheme.colorScheme.onSecondary, modifier = Modifier.size(44.dp), ) { Box(contentAlignment = Alignment.Center) { @@ -345,23 +354,36 @@ private fun PairedDesktopCard( Text( it, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) } } if (isActive) { - Icon( - Icons.Outlined.CheckCircle, - contentDescription = "Active", - tint = MaterialTheme.colorScheme.primary, - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = "Active", + ) + } + } } - IconButton(onClick = onUnpair) { + FilledTonalIconButton( + onClick = onUnpair, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { Icon( Icons.Outlined.Delete, contentDescription = "Unpair", - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt new file mode 100644 index 00000000..96df4686 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt @@ -0,0 +1,547 @@ +package app.rxlab.rxcode.ui.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.MobileReleaseWorkflow +import app.rxlab.rxcode.proto.MobileReleaseWorkflowInput +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.proto.ReleaseDispatchRequest +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.ui.autopilot.AutopilotErrorRow +import app.rxlab.rxcode.ui.autopilot.AutopilotSuccessRow +import kotlinx.coroutines.launch + +/** + * On-device "Download Secret" form. Mirrors iOS `ProjectSecretsDownloadSheet`: + * pick an environment, decrypt it on-device with the phone's passkey-derived + * KEK, then relay the plaintext for the Mac to write into the project folder. + * Files skipped because they already exist surface a prompt to enable overwrite. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectSecretsDownloadSheet( + viewModel: MobileAppState, + project: Project, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + val repo = project.gitHubRepo + + var environments by remember { mutableStateOf<List<SecretsEnvironment>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf<String?>(null) } + var selectedEnvId by remember { mutableStateOf<String?>(null) } + var overwrite by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } + var actionError by remember { mutableStateOf<String?>(null) } + var conflicts by remember { mutableStateOf<List<String>>(emptyList()) } + + androidx.compose.runtime.LaunchedEffect(project.id) { + if (repo == null) { + loadError = "This project is not linked to a GitHub repo." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val envs = viewModel.autopilot.listSecretEnvironments(repo) + environments = envs + if (envs.size == 1) selectedEnvId = envs.first().id + } catch (t: Throwable) { + loadError = t.message ?: "Couldn't load environments." + } + isLoading = false + } + + fun download() { + val envId = selectedEnvId ?: return + if (repo == null) return + isDownloading = true + actionError = null + conflicts = emptyList() + scope.launch { + try { + val result = viewModel.downloadProjectSecrets(context, project.id, repo, envId, overwrite) + if (result.conflicts.isNotEmpty()) conflicts = result.conflicts else onDismiss() + } catch (t: Throwable) { + actionError = t.message ?: "Couldn't download the secrets." + } finally { + isDownloading = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SheetTopBar( + title = "Download Secret", + actionLabel = "Download", + actionEnabled = selectedEnvId != null && !isDownloading, + actionBusy = isDownloading, + onCancel = onDismiss, + onAction = ::download, + ) + + when { + loadError != null -> Text(loadError!!, color = MaterialTheme.colorScheme.error) + isLoading && environments.isEmpty() -> CenteredSpinner() + environments.isEmpty() -> Text( + "This repository has no secret environments to download.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + Text( + "Environment", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + environments.forEach { env -> + SelectableRow( + label = env.name, + selected = selectedEnvId == env.id, + onClick = { selectedEnvId = env.id }, + ) + } + Text( + "Files are decrypted on this device with your passkey, then written into the project folder on your Mac.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Overwrite existing files", style = MaterialTheme.typography.bodyMedium) + Switch(checked = overwrite, onCheckedChange = { overwrite = it }) + } + if (conflicts.isNotEmpty()) { + AutopilotErrorRow( + "These files already exist and were skipped: ${conflicts.joinToString(", ")}. " + + "Turn on overwrite to replace them.", + ) + } + if (actionError != null) { + Text(actionError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } + Spacer(Modifier.height(4.dp)) + } + } +} + +/** + * On-device "Create Release" form. Mirrors iOS `ProjectReleaseCreateSheet`: + * resolve the project's release workflows, pick a dispatchable one, fill its + * `workflow_dispatch` inputs, and trigger it (the desktop only relays). The next + * version is computed by semantic-release from the commit history. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectReleaseCreateSheet( + viewModel: MobileAppState, + project: Project, + branchInfo: ProjectBranchInfo?, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + val repo = project.gitHubRepo.orEmpty() + val branchOptions = remember(branchInfo) { + val current = branchInfo?.currentBranch + val all = branchInfo?.availableBranches.orEmpty() + when { + all.isNotEmpty() && current != null -> listOf(current) + all.filter { it != current } + all.isNotEmpty() -> all + current != null -> listOf(current) + else -> emptyList() + } + } + + var workflows by remember { mutableStateOf<List<MobileReleaseWorkflow>>(emptyList()) } + var selectedWorkflowId by remember { mutableStateOf<String?>(null) } + var branch by remember { mutableStateOf(branchInfo?.currentBranch ?: branchOptions.firstOrNull() ?: "main") } + var inputValues by remember { mutableStateOf<Map<String, String>>(emptyMap()) } + var isLoading by remember { mutableStateOf(true) } + var isDispatching by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf<String?>(null) } + var dispatchError by remember { mutableStateOf<String?>(null) } + var dispatchedUrl by remember { mutableStateOf<String?>(null) } + + val dispatchable = remember(workflows) { workflows.filter { it.hasWorkflowDispatch } } + val selectedWorkflow = remember(workflows, selectedWorkflowId) { workflows.firstOrNull { it.id == selectedWorkflowId } } + + fun seedDefaults(workflow: MobileReleaseWorkflow?) { + val inputs = workflow?.inputs ?: return + val next = inputValues.toMutableMap() + inputs.forEach { input -> + if (next[input.name] == null) { + next[input.name] = if (input.type == "boolean") { + if (input.defaultBool == true) "true" else "false" + } else { + input.defaultString ?: input.options?.firstOrNull() ?: "" + } + } + } + inputValues = next + } + + androidx.compose.runtime.LaunchedEffect(project.id) { + if (repo.isEmpty()) { + loadError = "This project is not linked to a GitHub repo." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val list = viewModel.autopilot.listReleaseWorkflows(repo) + workflows = list + val preferred = list.firstOrNull { it.isSelected && it.hasWorkflowDispatch } + ?: list.firstOrNull { it.hasWorkflowDispatch } + selectedWorkflowId = preferred?.id + seedDefaults(preferred) + } catch (t: Throwable) { + loadError = t.message ?: "Couldn't load workflows." + } + isLoading = false + } + + fun dispatch() { + val workflow = selectedWorkflow ?: return + val resolvedBranch = branch.trim() + if (resolvedBranch.isEmpty()) return + isDispatching = true + dispatchError = null + scope.launch { + try { + val result = viewModel.autopilot.dispatchRelease( + repo, + ReleaseDispatchRequest(workflowId = workflow.id, branch = resolvedBranch, inputs = inputValues), + ) + when { + result.workflowRunUrl != null -> dispatchedUrl = result.workflowRunUrl + result.success == false -> dispatchError = result.error ?: "Failed to trigger the release workflow." + else -> dispatchedUrl = "https://github.com/$repo/actions" + } + } catch (t: Throwable) { + dispatchError = t.message ?: "Failed to trigger the release workflow." + } finally { + isDispatching = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 16.dp) + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SheetTopBar( + title = "Create Release", + actionLabel = if (dispatchedUrl == null) "Create" else "Done", + actionEnabled = dispatchedUrl != null || + (!isDispatching && selectedWorkflow != null && branch.trim().isNotEmpty()), + actionBusy = isDispatching, + onCancel = onDismiss, + onAction = { if (dispatchedUrl != null) onDismiss() else dispatch() }, + ) + + when { + isLoading -> CenteredSpinner() + loadError != null -> Text(loadError!!, color = MaterialTheme.colorScheme.error) + dispatchable.isEmpty() -> Text( + "No dispatchable release workflow found for $repo. Add a workflow_dispatch release " + + "workflow and rescan, then try again.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + LabeledValue("Repository", repo) + if (dispatchable.size > 1) { + PickerRow( + label = "Workflow", + value = selectedWorkflow?.displayName ?: "Select", + options = dispatchable.map { it.displayName }, + onSelectIndex = { idx -> + val wf = dispatchable[idx] + selectedWorkflowId = wf.id + seedDefaults(wf) + }, + ) + } else { + LabeledValue("Workflow", dispatchable.first().displayName) + } + if (branchOptions.isEmpty()) { + OutlinedTextField( + value = branch, + onValueChange = { branch = it }, + label = { Text("Branch") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } else { + PickerRow( + label = "Branch", + value = branch, + options = branchOptions, + onSelectIndex = { branch = branchOptions[it] }, + ) + } + Text( + "The next version is computed by semantic-release from the commit history.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + selectedWorkflow?.inputs?.takeIf { it.isNotEmpty() }?.let { inputs -> + Text( + "Workflow inputs", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + inputs.forEach { input -> + ReleaseInputField( + input = input, + value = inputValues[input.name], + onValueChange = { inputValues = inputValues + (input.name to it) }, + ) + } + } + + if (dispatchError != null) { + AutopilotErrorRow(dispatchError!!) + } + if (dispatchedUrl != null) { + AutopilotSuccessRow("Release workflow triggered") + TextButton(onClick = { uriHandler.openUri(dispatchedUrl!!) }) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(6.dp)) + Text("View workflow run on GitHub") + } + } + } + } + Spacer(Modifier.height(4.dp)) + } + } +} + +// MARK: - Shared sheet pieces + +@Composable +private fun SheetTopBar( + title: String, + actionLabel: String, + actionEnabled: Boolean, + actionBusy: Boolean, + onCancel: () -> Unit, + onAction: () -> Unit, +) { + Box(Modifier.fillMaxWidth().padding(top = 8.dp)) { + TextButton(onClick = onCancel, modifier = Modifier.align(Alignment.CenterStart)) { Text("Cancel") } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.align(Alignment.Center), + ) + Box(Modifier.align(Alignment.CenterEnd)) { + if (actionBusy) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + } else { + TextButton(onClick = onAction, enabled = actionEnabled) { + Text(actionLabel, fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun CenteredSpinner() { + Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun SelectableRow(label: String, selected: Boolean, onClick: () -> Unit) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + if (selected) Icon(Icons.Outlined.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun LabeledValue(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun PickerRow( + label: String, + value: String, + options: List<String>, + onSelectIndex: (Int) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable { open = true }, + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Icon(Icons.Outlined.ExpandMore, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEachIndexed { idx, option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { open = false; onSelectIndex(idx) }, + ) + } + } + } +} + +@Composable +private fun ReleaseInputField( + input: MobileReleaseWorkflowInput, + value: String?, + onValueChange: (String) -> Unit, +) { + val label = input.name + (if (input.required) " *" else "") + when { + input.type == "boolean" -> { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Switch( + checked = (value ?: if (input.defaultBool == true) "true" else "false") == "true", + onCheckedChange = { onValueChange(if (it) "true" else "false") }, + ) + } + } + input.type == "choice" && input.options != null -> { + val options = input.options!! + PickerRow( + label = label, + value = value ?: input.defaultString ?: options.firstOrNull() ?: "", + options = options, + onSelectIndex = { onValueChange(options[it]) }, + ) + } + else -> { + OutlinedTextField( + value = value ?: input.defaultString ?: "", + onValueChange = onValueChange, + label = { Text(label) }, + supportingText = input.description?.takeIf { it.isNotEmpty() }?.let { { Text(it) } }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 07f42087..0730b4c5 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -5911,6 +5911,9 @@ } } } + }, + "The Mac is pushing the branch and opening the PR." : { + }, "The Mac returned an empty response." : { "localizations" : { @@ -6655,6 +6658,9 @@ } } } + }, + "Working…" : { + }, "Xcode" : { "localizations" : { diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift index d6c37981..ab33acdc 100644 --- a/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift @@ -40,3 +40,57 @@ extension View { .animation(.easeInOut(duration: 0.2), value: isLoading) } } + +/// A centered, alert-style dialog (dimmed scrim + material card) shown while an +/// action runs. Unlike `MobileAutopilotLoadingOverlay`, this keeps the screen +/// visible behind a dimming scrim — use it for *actions* (e.g. creating a PR) +/// rather than the initial data load. +struct MobileAutopilotLoadingDialog: View { + var title: LocalizedStringKey = "Working…" + var message: LocalizedStringKey? + + var body: some View { + ZStack { + Color.black.opacity(0.25) + .ignoresSafeArea() + VStack(spacing: 14) { + ProgressView() + .controlSize(.large) + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + if let message { + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(24) + .frame(maxWidth: 280) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20)) + .shadow(radius: 20) + } + .transition(.opacity) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(title)) + } +} + +extension View { + /// Overlays an alert-style loading dialog (dimmed scrim + centered card) + /// while `isLoading` is true. Use for blocking *actions* like creating a PR. + @ViewBuilder + func mobileAutopilotLoadingDialog( + _ isLoading: Bool, + title: LocalizedStringKey = "Working…", + message: LocalizedStringKey? = nil + ) -> some View { + overlay { + if isLoading { + MobileAutopilotLoadingDialog(title: title, message: message) + } + } + .animation(.easeInOut(duration: 0.2), value: isLoading) + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift index 2250bc3c..8b5d0604 100644 --- a/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift +++ b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift @@ -15,6 +15,7 @@ struct MobileSecretsView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var searchTask: Task<Void, Never>? + @State private var hasAppeared = false var body: some View { List { @@ -75,7 +76,16 @@ struct MobileSecretsView: View { } .mobileAutopilotLoadingOverlay(enrolled == nil && isLoading) .refreshable { await load() } - .task { await load() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await load() + } + .onAppear { + // Always refresh when returning to this screen (the very first + // appear is handled by `.task` above). + if hasAppeared { Task { await load() } } + } } private func load() async { @@ -110,6 +120,9 @@ struct MobileSecretsRepoView: View { @State private var showingAdd = false @State private var newName = "" @State private var pendingDelete: SecretsEnvironment? + @State private var isMutating = false + @State private var mutatingTitle: LocalizedStringKey = "Working…" + @State private var hasAppeared = false var body: some View { List { @@ -164,8 +177,18 @@ struct MobileSecretsRepoView: View { Text("This permanently deletes the environment and its secret files.") } .mobileAutopilotLoadingOverlay(isLoading && environments.isEmpty) + .mobileAutopilotLoadingDialog(isMutating, title: mutatingTitle) .refreshable { await reload() } - .task { await reload() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await reload() + } + .onAppear { + // Always refresh when returning to this screen (e.g. after editing + // files in a child environment changes the file counts). + if hasAppeared { Task { await reload() } } + } } private func reload() async { @@ -182,6 +205,9 @@ struct MobileSecretsRepoView: View { private func create() async { let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } + mutatingTitle = "Creating environment…" + isMutating = true + defer { isMutating = false } do { try await state.createSecretEnvironment(repo: repoFullName, name: name) await reload() @@ -191,6 +217,9 @@ struct MobileSecretsRepoView: View { } private func delete(_ env: SecretsEnvironment) async { + mutatingTitle = "Deleting environment…" + isMutating = true + defer { isMutating = false } do { try await state.deleteSecretEnvironment(repo: repoFullName, envId: env.id) await reload() @@ -213,6 +242,8 @@ struct MobileSecretsEnvironmentView: View { @State private var errorMessage: String? @State private var editingFile: EditingFile? @State private var pendingDelete: SecretsFileMeta? + @State private var isMutating = false + @State private var hasAppeared = false struct EditingFile: Identifiable { let id = UUID() @@ -287,8 +318,17 @@ struct MobileSecretsEnvironmentView: View { } } .mobileAutopilotLoadingOverlay(isLoading && files.isEmpty) + .mobileAutopilotLoadingDialog(isMutating, title: "Deleting file…") .refreshable { await reload() } - .task { await reload() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await reload() + } + .onAppear { + // Always refresh when returning to this screen. + if hasAppeared { Task { await reload() } } + } } private func reload() async { @@ -333,6 +373,8 @@ struct MobileSecretsEnvironmentView: View { } private func delete(_ file: SecretsFileMeta) async { + isMutating = true + defer { isMutating = false } do { try await state.deleteSecretFile(repo: repoFullName, envId: environment.id, fileId: file.id) await reload() @@ -400,6 +442,7 @@ struct MobileSecretFileEditor: View { .disabled(isSaving || filename.trimmingCharacters(in: .whitespaces).isEmpty) } } + .mobileAutopilotLoadingDialog(isSaving, title: file.isNew ? "Creating file…" : "Saving file…") } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index cd401965..1b324cfb 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -119,7 +119,11 @@ struct MobileBriefingDetailView: View { "branch": groupKey.branch, ]) } - .mobileAutopilotLoadingOverlay(isCreatingPR, title: "Creating Pull Request…") + .mobileAutopilotLoadingDialog( + isCreatingPR, + title: "Creating Pull Request…", + message: "The Mac is pushing the branch and opening the PR." + ) } private var group: GroupedBriefing? {