From 962ac0085a7cbf7007f4cbbb4128768a9e16662d Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:16:57 +0800 Subject: [PATCH 1/5] feat: add add remote repo support on mobile --- .../Protocol/Payload+Autopilot.swift | 12 + RxCode.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 2 +- RxCode/App/AppState+MobileAutopilot.swift | 26 ++ RxCode/App/AppState+Project.swift | 23 +- RxCode/Resources/Localizable.xcstrings | 7 + RxCode/Services/ClaudeService+Summaries.swift | 8 +- .../Services/CodexAppServer+Summaries.swift | 9 +- .../FoundationModelSummarizationService.swift | 7 +- RxCode/Services/GeneratedTextSanitizer.swift | 74 ++++ .../Services/OpenAISummarizationService.swift | 10 +- RxCode/Views/MainView.swift | 27 +- .../Views/Sidebar/BriefingMarkdownView.swift | 2 +- .../.kotlin/errors/errors-1780574988119.log | 48 +++ .../app/rxlab/rxcode/proto/AutopilotModels.kt | 9 + .../rxlab/rxcode/proto/AutopilotPayloads.kt | 2 + .../java/app/rxlab/rxcode/proto/Payload.kt | 61 +++ .../rxlab/rxcode/state/AutopilotService.kt | 17 + .../app/rxlab/rxcode/state/MobileAppState.kt | 126 +++++++ .../app/rxlab/rxcode/state/MobileState.kt | 8 + .../ui/autopilot/AutopilotRepoPicker.kt | 41 +- .../rxcode/ui/projects/ProjectsScreen.kt | 113 +++++- .../ui/projects/RemoteFolderPickerSheet.kt | 353 ++++++++++++++++++ .../rxcode/proto/RemoteProjectPayloadTest.kt | 88 +++++ RxCodeMobile/Resources/Localizable.xcstrings | 12 + RxCodeMobile/RxCodeMobileApp.swift | 16 + .../State/MobileAppState+Autopilot.swift | 21 ++ .../Autopilot/MobileAutopilotRepoPicker.swift | 36 +- RxCodeMobile/Views/ProjectsSidebar.swift | 74 +++- RxCodeTests/GeneratedTextSanitizerTests.swift | 70 ++++ 30 files changed, 1235 insertions(+), 71 deletions(-) create mode 100644 RxCode/Services/GeneratedTextSanitizer.swift create mode 100644 RxCodeAndroid/.kotlin/errors/errors-1780574988119.log create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/RemoteFolderPickerSheet.kt create mode 100644 RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/proto/RemoteProjectPayloadTest.kt create mode 100644 RxCodeTests/GeneratedTextSanitizerTests.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index a5d8069d..0b22c692 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -40,6 +40,8 @@ public enum AutopilotOp: String, Codable, Sendable { // by every "add repository" flow — mirrors the desktop add sheets which all // source `secrets.listManagedRepositories`). case listManagedRepos + case cloneManagedRepo + case installGitHubAppUrl // Automation settings (schema-driven form) case automationSchema @@ -208,6 +210,16 @@ public struct AutopilotRepoIDBody: Codable, Sendable { public init(repoId: String) { self.repoId = repoId } } +public struct AutopilotCloneRepoBody: Codable, Sendable { + public let fullName: String + public init(fullName: String) { self.fullName = fullName } +} + +public struct AutopilotCloneRepoResult: Codable, Sendable { + public let project: Project + public init(project: Project) { self.project = project } +} + // MARK: - Repo setup bodies public struct AutopilotRepoSetupUpdateBody: Codable, Sendable { diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 56011fa2..f04adfb5 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; }; 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */; }; 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */; }; + 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */; }; 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */; }; 7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0001000000000000A001 /* MockAgentBackend.swift */; }; 7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */; }; @@ -129,6 +130,7 @@ 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; }; 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BriefingThreadRowTests.swift; sourceTree = ""; }; 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThreadStoreThreadSummaryTests.swift; sourceTree = ""; }; + 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeneratedTextSanitizerTests.swift; sourceTree = ""; }; 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; }; 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITestplan.xctestplan; sourceTree = ""; }; @@ -340,6 +342,7 @@ DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */, 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */, 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */, + 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */, E62000002FCB000100000001 /* MemoryIntentTests.swift */, ); path = RxCodeTests; @@ -792,6 +795,7 @@ DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */, 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */, 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */, + 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */, E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index efe23aba..cfa3af20 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "037027ac785e88eafec21a5d2ded918dff49adb8ca86008f1fff4211c09b1c39", + "originHash" : "4e57c3e72b3783f86f3921d55aadf023d63038ca5bc9265e34900dc7724ab193", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 7c43c6c1..5eb0fb07 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -83,6 +83,32 @@ extension AppState { let q = optionalAutopilotBody(request, as: AutopilotReposQuery.self) let page = try await secrets.listManagedRepositories(search: q?.search, cursor: q?.cursor) return try encoder.encode(page) + case .cloneManagedRepo: + let body = try decodeAutopilotBody(request, as: AutopilotCloneRepoBody.self) + let target = body.fullName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !target.isEmpty else { + throw MobileRemoteConfigError.invalidRequest("Repository name is required.") + } + guard isSignedIn else { + throw MobileRemoteConfigError.invalidRequest("Sign in with rxlab on your Mac before importing GitHub repositories.") + } + if let existing = projects.first(where: { $0.gitHubRepo?.lowercased() == target }) { + return try encoder.encode(AutopilotCloneRepoResult(project: existing)) + } + let page = try await autopilot.listRepositories(search: body.fullName, cursor: nil) + guard let repo = page.items.first(where: { $0.fullName.lowercased() == target }) else { + throw MobileRemoteConfigError.invalidRequest("Repository is not available from the signed-in rxlab account.") + } + guard let project = try await cloneAndAddProjectForMobile(repo) else { + throw MobileRemoteConfigError.invalidRequest("Failed to add cloned repository.") + } + scheduleMobileSnapshotBroadcast() + return try encoder.encode(AutopilotCloneRepoResult(project: project)) + case .installGitHubAppUrl: + guard isSignedIn else { + throw MobileRemoteConfigError.invalidRequest("Sign in with rxlab on your Mac before installing the GitHub App.") + } + return try encoder.encode(try await autopilot.installUrl()) // MARK: Automation settings case .automationSchema: diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 25b18fb5..bf9296ba 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -385,7 +385,20 @@ extension AppState { } } + @discardableResult + func cloneAndAddProjectForMobile(_ repo: AutopilotRepo) async throws -> Project? { + if let existing = projects.first(where: { $0.gitHubRepo?.lowercased() == repo.fullName.lowercased() }) { + return existing + } + return try await cloneAndRegisterProject(repo, selectingIn: nil) + } + func cloneAndAddProject(_ repo: AutopilotRepo, in window: WindowState) async throws { + _ = try await cloneAndRegisterProject(repo, selectingIn: window) + } + + @discardableResult + private func cloneAndRegisterProject(_ repo: AutopilotRepo, selectingIn window: WindowState?) async throws -> Project? { let home = FileManager.default.homeDirectoryForCurrentUser.path let clonePath = "\(home)/RxCode/\(repo.name)" let parentDir = "\(home)/RxCode" @@ -399,12 +412,16 @@ extension AppState { // auto-download run for clones too) and the more specific // `onRepositoryCloned`. No built-in hook handles both, so there is no // double-processing. - let project = await addAndSelectProject( - name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window - ) + let project: Project? + if let window { + project = await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) + } else { + project = await addProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName) + } if let project { await hookManager.dispatchRepositoryCloned(RepositoryPayload(project: project, wasCloned: true)) } + return project } private func gitClone(from url: String, to path: String) async throws { diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 8d7d9d0a..c80ee6ce 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1369,6 +1369,12 @@ } } } + }, + "Add project from remote repositories" : { + + }, + "Add project locally" : { + }, "Add Relay Server" : { "localizations" : { @@ -13594,6 +13600,7 @@ } }, "Sign in to rxlab" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift index 62d0e748..f36f8f51 100644 --- a/RxCode/Services/ClaudeService+Summaries.swift +++ b/RxCode/Services/ClaudeService+Summaries.swift @@ -187,14 +187,8 @@ extension ClaudeCodeServer { } func cleanGeneratedSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } /// Write a one-off MCP config file (with no servers) used by the title-generation diff --git a/RxCode/Services/CodexAppServer+Summaries.swift b/RxCode/Services/CodexAppServer+Summaries.swift index 54501dec..f2b135ea 100644 --- a/RxCode/Services/CodexAppServer+Summaries.swift +++ b/RxCode/Services/CodexAppServer+Summaries.swift @@ -265,14 +265,7 @@ extension CodexAppServer { } func cleanSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } } diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index 515fb47b..de5fa84f 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -324,11 +324,6 @@ actor FoundationModelSummarizationService { } private func cleanSummary(_ raw: String?, limit: Int) -> String? { - guard let raw else { return nil } - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - return String(cleaned.prefix(limit)) + GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: []) } } diff --git a/RxCode/Services/GeneratedTextSanitizer.swift b/RxCode/Services/GeneratedTextSanitizer.swift new file mode 100644 index 00000000..5d943113 --- /dev/null +++ b/RxCode/Services/GeneratedTextSanitizer.swift @@ -0,0 +1,74 @@ +import Foundation + +enum GeneratedTextSanitizer { + static func cleanSummary(_ raw: String?, limit: Int, errorPrefixes: [String]) -> String? { + guard let raw else { return nil } + let cleaned = cleanMarkdownDocument(raw) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } + + static func cleanMarkdownDocument(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + text = stripSurroundingQuotes(text, includingBackticks: false) + text = unwrapWholeDocumentFence(text) + text = stripSurroundingQuotes(text, includingBackticks: true) + text = unwrapWholeDocumentFence(text) + text = dropLeadingMarkdownLanguageLabel(text) + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func unwrapWholeDocumentFence(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let fence = openingFence(in: trimmed) else { return trimmed } + + var lines = trimmed.components(separatedBy: "\n") + guard !lines.isEmpty else { return trimmed } + lines.removeFirst() + + if let last = lines.last, + last.trimmingCharacters(in: .whitespaces).hasPrefix(fence) { + lines.removeLast() + } + + return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func openingFence(in text: String) -> String? { + let firstLine = text.components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + if firstLine.hasPrefix("```") { return "```" } + if firstLine.hasPrefix("~~~") { return "~~~" } + return nil + } + + private static func stripSurroundingQuotes(_ raw: String, includingBackticks: Bool) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let wrappers = includingBackticks ? "\"'`" : "\"'" + if let first = text.first, let last = text.last, + wrappers.contains(first), first == last, text.count > 1 { + text = String(text.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + private static func dropLeadingMarkdownLanguageLabel(_ raw: String) -> String { + var lines = raw.components(separatedBy: "\n") + while let first = lines.first { + let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + lines.removeFirst() + continue + } + if trimmed.lowercased() == "markdown" { + lines.removeFirst() + } + break + } + return lines.joined(separator: "\n") + } +} diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 70aafcb5..751f284e 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -445,15 +445,7 @@ actor OpenAISummarizationService { } private func cleanSummary(_ raw: String?, limit: Int) -> String? { - guard let raw else { return nil } - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "openai error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index d127b8e6..aa69914e 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -196,18 +196,19 @@ struct MainView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { if columnVisibility != .detailOnly { - ToolbarItem(placement: .navigation) { - Button { - showGitHubSheet = true - } label: { - Image(systemName: "square.and.arrow.down") - } - .help(appState.isSignedIn ? "Import Repository" : "Sign in to rxlab") - } + ToolbarItemGroup(placement: .navigation) { + Menu { + Button { + showFilePicker = true + } label: { + Label("Add project locally", systemImage: "folder.badge.plus") + } - ToolbarItem(placement: .navigation) { - Button { - showFilePicker = true + Button { + showGitHubSheet = true + } label: { + Label("Add project from remote repositories", systemImage: "square.and.arrow.down") + } } label: { Image(systemName: "plus") } @@ -219,9 +220,7 @@ struct MainView: View { ) { result in handleFolderSelection(result) } - } - ToolbarItem(placement: .navigation) { Button { windowState.showGlobalSearch = true } label: { @@ -229,9 +228,7 @@ struct MainView: View { } .help(String(localized: "Search Threads and Docs (⌘K)")) .popoverTip(RxCodeTips.GlobalSearchTip(), arrowEdge: .top) - } - ToolbarItem(placement: .navigation) { ThreadTitlePopoverButton(title: navigationTitleText) } } diff --git a/RxCode/Views/Sidebar/BriefingMarkdownView.swift b/RxCode/Views/Sidebar/BriefingMarkdownView.swift index 6a9e1524..91917b36 100644 --- a/RxCode/Views/Sidebar/BriefingMarkdownView.swift +++ b/RxCode/Views/Sidebar/BriefingMarkdownView.swift @@ -10,7 +10,7 @@ struct BriefingMarkdownView: View { var fontSize: CGFloat = 13.5 private var blocks: [Block] { - Self.parse(text) + Self.parse(GeneratedTextSanitizer.cleanMarkdownDocument(text)) } var body: some View { diff --git a/RxCodeAndroid/.kotlin/errors/errors-1780574988119.log b/RxCodeAndroid/.kotlin/errors/errors-1780574988119.log new file mode 100644 index 00000000..33822be3 --- /dev/null +++ b/RxCodeAndroid/.kotlin/errors/errors-1780574988119.log @@ -0,0 +1,48 @@ +kotlin version: 2.0.21 +error message: java.nio.file.NoSuchFileException: /Users/qiweili/Desktop/rxlab/RxCode/RxCodeAndroid/app/build/generated/ksp/debug/java/byRounds/1/dagger/hilt/internal/aggregatedroot/codegen/_app_rxlab_rxcode_RxCodeApplication.java + at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111) + at java.base/sun.nio.fs.UnixCopyFile.copy(UnixCopyFile.java:548) + at java.base/sun.nio.fs.UnixFileSystemProvider.copy(UnixFileSystemProvider.java:257) + at java.base/java.nio.file.Files.copy(Files.java:1305) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:73) + at com.google.devtools.ksp.common.IncrementalContextBase.updateOutputs(IncrementalContextBase.kt:349) + at com.google.devtools.ksp.common.IncrementalContextBase.access$updateOutputs(IncrementalContextBase.kt:62) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:484) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.common.IncrementalContextBase.closeFilesOnException(IncrementalContextBase.kt:408) + at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:376) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373) + at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555) + at jdk.internal.reflect.GeneratedMethodAccessor90.invoke(Unknown Source) + at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.base/java.lang.reflect.Method.invoke(Method.java:569) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200) + at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:712) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:587) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:828) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:705) + at java.base/java.security.AccessController.doPrivileged(AccessController.java:399) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:704) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) + at java.base/java.lang.Thread.run(Thread.java:840) + + 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 a2224f5b..205b044f 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 @@ -136,6 +136,15 @@ data class SecretsManagedRepoPage( val pagination: Pagination = Pagination(), ) +@Serializable +data class AutopilotCloneRepoBody(val fullName: String) + +@Serializable +data class AutopilotCloneRepoResult(val project: Project) + +@Serializable +data class AutopilotInstallUrlResponse(val url: String) + // MARK: - Secrets @Serializable 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 70f04c7d..b0fed8fa 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 @@ -37,6 +37,8 @@ enum class AutopilotOp(val wire: String) { // Account ACCOUNT_STATUS("accountStatus"), LIST_MANAGED_REPOS("listManagedRepos"), + CLONE_MANAGED_REPO("cloneManagedRepo"), + INSTALL_GITHUB_APP_URL("installGitHubAppUrl"), // Automation settings (schema-driven form) AUTOMATION_SCHEMA("automationSchema"), 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 486fef8e..b83c56ba 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,18 @@ sealed class Payload { data class BranchOpResult(val data: BranchOpResultPayload) : Payload() { override val type = "branch_op_result" } + data class FolderTreeRequest(val data: FolderTreeRequestPayload) : Payload() { + override val type = "folder_tree_request" + } + data class FolderTreeResult(val data: FolderTreeResultPayload) : Payload() { + override val type = "folder_tree_result" + } + data class CreateProjectRequest(val data: CreateProjectRequestPayload) : Payload() { + override val type = "create_project_request" + } + data class CreateProjectResult(val data: CreateProjectResultPayload) : Payload() { + override val type = "create_project_result" + } data class DeleteProjectRequest(val data: DeleteProjectRequestPayload) : Payload() { override val type = "delete_project_request" } @@ -390,6 +402,47 @@ data class BranchOpResultPayload( val errorMessage: String? = null, ) +@Serializable +data class RemoteFolderNode( + val name: String, + val path: String, + val isSelectable: Boolean = true, + val isDirectory: Boolean = true, + val children: List = emptyList(), +) + +@Serializable +data class FolderTreeRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val path: String? = null, + val depth: Int = 1, + val includeHidden: Boolean = false, + val includeFiles: Boolean = false, +) + +@Serializable +data class FolderTreeResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + val requestedPath: String? = null, + val ok: Boolean, + val root: RemoteFolderNode? = null, + val errorMessage: String? = null, +) + +@Serializable +data class CreateProjectRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val path: String, +) + +@Serializable +data class CreateProjectResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + val ok: Boolean, + val project: Project? = null, + val errorMessage: String? = null, +) + /** * Mobile → desktop request to upsert or delete a [RunProfile]. The desktop * replies with [RunProfileResultPayload]; on success it also broadcasts a @@ -563,6 +616,10 @@ object PayloadSerializer : KSerializer { "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)) + "folder_tree_request" -> Payload.FolderTreeRequest(json.decodeFromJsonElement(FolderTreeRequestPayload.serializer(), data)) + "folder_tree_result" -> Payload.FolderTreeResult(json.decodeFromJsonElement(FolderTreeResultPayload.serializer(), data)) + "create_project_request" -> Payload.CreateProjectRequest(json.decodeFromJsonElement(CreateProjectRequestPayload.serializer(), data)) + "create_project_result" -> Payload.CreateProjectResult(json.decodeFromJsonElement(CreateProjectResultPayload.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)) @@ -620,6 +677,10 @@ object PayloadSerializer : KSerializer { 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.FolderTreeRequest -> value.type to json.encodeToJsonElement(FolderTreeRequestPayload.serializer(), value.data) + is Payload.FolderTreeResult -> value.type to json.encodeToJsonElement(FolderTreeResultPayload.serializer(), value.data) + is Payload.CreateProjectRequest -> value.type to json.encodeToJsonElement(CreateProjectRequestPayload.serializer(), value.data) + is Payload.CreateProjectResult -> value.type to json.encodeToJsonElement(CreateProjectResultPayload.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) 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 6937e915..8bb58b75 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 @@ -7,6 +7,8 @@ import app.rxlab.rxcode.proto.AutopilotAccountStatus import app.rxlab.rxcode.proto.AutopilotCIClosePRBody import app.rxlab.rxcode.proto.AutopilotCIFrequencyBody import app.rxlab.rxcode.proto.AutopilotCIHistoryBody +import app.rxlab.rxcode.proto.AutopilotCloneRepoBody +import app.rxlab.rxcode.proto.AutopilotCloneRepoResult import app.rxlab.rxcode.proto.AutopilotCursorQuery import app.rxlab.rxcode.proto.AutopilotDocsCreateTokenBody import app.rxlab.rxcode.proto.AutopilotDocsDocBody @@ -21,6 +23,7 @@ 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.AutopilotInstallUrlResponse import app.rxlab.rxcode.proto.AutopilotSearchBody import app.rxlab.rxcode.proto.AutopilotSearchResult import app.rxlab.rxcode.proto.AutopilotReleaseDispatchBody @@ -53,6 +56,7 @@ import app.rxlab.rxcode.proto.MobileReleaseWorkflow import app.rxlab.rxcode.proto.MobileReleaseWorkflowList import app.rxlab.rxcode.proto.Payload import app.rxlab.rxcode.proto.PreferencesValues +import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ReleaseDispatchRequest import app.rxlab.rxcode.proto.ReleaseDispatchResult import app.rxlab.rxcode.proto.ReleaseGithubSecretResult @@ -167,6 +171,19 @@ class AutopilotService( ) ) + suspend fun cloneManagedRepo(fullName: String): Project = + decodeResult( + rawCall( + AutopilotDomain.ACCOUNT, AutopilotOp.CLONE_MANAGED_REPO, + encodeBody(AutopilotCloneRepoBody(fullName)), + ) + ).project + + suspend fun githubAppInstallUrl(): String = + decodeResult( + rawCall(AutopilotDomain.ACCOUNT, AutopilotOp.INSTALL_GITHUB_APP_URL, null) + ).url + // MARK: - Automation suspend fun automationSchema(): SchemaEnvelope = 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 d0b5e03b..6f0e1c8d 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 @@ -11,7 +11,9 @@ 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.CreateProjectRequestPayload import app.rxlab.rxcode.proto.DeleteProjectRequestPayload +import app.rxlab.rxcode.proto.FolderTreeRequestPayload import app.rxlab.rxcode.proto.LoadMoreMessagesRequestPayload import app.rxlab.rxcode.proto.MobileRunTaskSnapshot import app.rxlab.rxcode.proto.NewSessionRequestPayload @@ -27,6 +29,7 @@ import app.rxlab.rxcode.proto.RunProfile import app.rxlab.rxcode.proto.RunProfileMutationRequestPayload import app.rxlab.rxcode.proto.RunProfileRunRequestPayload import app.rxlab.rxcode.proto.RunProfileStopRequestPayload +import app.rxlab.rxcode.proto.SecretsManagedRepo import app.rxlab.rxcode.proto.SessionUpdatePayload import app.rxlab.rxcode.proto.SubscribeSessionPayload import app.rxlab.rxcode.proto.ThreadActionRequestPayload @@ -73,6 +76,8 @@ class MobileAppState @Inject constructor( private var started = false private var pairingTimeout: Job? = null + private var pendingFolderTreeRequestId: UUID? = null + private var pendingCreateProjectId: UUID? = null private var pendingThreadChangesId: UUID? = null private var pendingDeleteProjectId: UUID? = null private var searchJob: Job? = null @@ -212,6 +217,52 @@ 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.FolderTreeResult -> { + if (!isActiveDesktop(fromHex)) return + if (payload.data.clientRequestID != pendingFolderTreeRequestId) return + pendingFolderTreeRequestId = null + _state.update { + if (payload.data.ok && payload.data.root != null) { + it.copy( + remoteFolderRoot = payload.data.root, + remoteFolderIsLoading = false, + remoteFolderError = null, + ) + } else { + it.copy( + remoteFolderIsLoading = false, + remoteFolderError = payload.data.errorMessage ?: "Failed to load folders.", + ) + } + } + } + is Payload.CreateProjectResult -> { + if (!isActiveDesktop(fromHex)) return + if (payload.data.clientRequestID != pendingCreateProjectId) return + pendingCreateProjectId = null + _state.update { current -> + if (payload.data.ok && payload.data.project != null) { + val project = payload.data.project + val projects = if (current.projects.any { it.id == project.id }) { + current.projects + } else { + current.projects + project + } + current.copy( + projects = projects, + remoteProjectCreateInFlight = false, + remoteProjectCreateError = null, + lastCreatedProjectID = project.id, + ) + } else { + current.copy( + remoteProjectCreateInFlight = false, + remoteProjectCreateError = payload.data.errorMessage ?: "Failed to add project.", + ) + } + } + if (payload.data.ok) requestSnapshot("create_project") + } is Payload.DeleteProjectResult -> { if (!isActiveDesktop(fromHex)) return if (payload.data.clientRequestID != pendingDeleteProjectId) return @@ -681,6 +732,81 @@ class MobileAppState @Inject constructor( // MARK: - Project context-menu actions + fun requestRemoteFolder(path: String? = null, includeFiles: Boolean = false) { + val hex = _state.value.activeDesktopPubkey + if (hex.isEmpty()) return + val request = FolderTreeRequestPayload(path = path, depth = 1, includeFiles = includeFiles) + pendingFolderTreeRequestId = request.clientRequestID + _state.update { + it.copy( + remoteFolderIsLoading = true, + remoteFolderError = null, + ) + } + viewModelScope.launch { + val sent = client.send(Payload.FolderTreeRequest(request), hex) + if (!sent && pendingFolderTreeRequestId == request.clientRequestID) { + pendingFolderTreeRequestId = null + _state.update { + it.copy( + remoteFolderIsLoading = false, + remoteFolderError = "Couldn't reach your Mac to load folders.", + ) + } + } + } + } + + fun clearRemoteFolderError() { + _state.update { it.copy(remoteFolderError = null) } + } + + fun createProjectFromRemoteFolder(path: String) { + val hex = _state.value.activeDesktopPubkey + if (hex.isEmpty()) return + val trimmed = path.trim() + if (trimmed.isEmpty()) return + val request = CreateProjectRequestPayload(path = trimmed) + pendingCreateProjectId = request.clientRequestID + _state.update { + it.copy( + remoteProjectCreateInFlight = true, + remoteProjectCreateError = null, + lastCreatedProjectID = null, + ) + } + viewModelScope.launch { + val sent = client.send(Payload.CreateProjectRequest(request), hex) + if (!sent && pendingCreateProjectId == request.clientRequestID) { + pendingCreateProjectId = null + _state.update { + it.copy( + remoteProjectCreateInFlight = false, + remoteProjectCreateError = "Couldn't reach your Mac to add the project.", + ) + } + } + } + } + + fun clearRemoteProjectCreateError() { + _state.update { it.copy(remoteProjectCreateError = null) } + } + + suspend fun cloneAutopilotRepo(repo: SecretsManagedRepo): app.rxlab.rxcode.proto.Project { + val project = autopilot.cloneManagedRepo(repo.fullName) + _state.update { current -> + val projects = if (current.projects.any { it.id == project.id }) { + current.projects + } else { + current.projects + project + } + current.copy(projects = projects, lastCreatedProjectID = project.id) + } + requestSnapshot("clone_managed_repo") + return project + } + /** * Cascade-delete a project on the desktop. Optimistically drops it (and its * sessions) from local state so the UI updates immediately; the desktop 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 1af3d3ed..d124a613 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 @@ -16,6 +16,7 @@ import app.rxlab.rxcode.proto.PermissionRequestPayload import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ProjectBranchInfo import app.rxlab.rxcode.proto.ProjectCIStatus +import app.rxlab.rxcode.proto.RemoteFolderNode import app.rxlab.rxcode.proto.RunProfile import app.rxlab.rxcode.proto.SearchHit import app.rxlab.rxcode.proto.SessionSummary @@ -84,6 +85,13 @@ data class MobileState( val hasReceivedInitialSnapshot: Boolean = false, val lastError: String? = null, + val remoteFolderRoot: RemoteFolderNode? = null, + val remoteFolderIsLoading: Boolean = false, + val remoteFolderError: String? = null, + val remoteProjectCreateInFlight: Boolean = false, + val remoteProjectCreateError: String? = null, + val lastCreatedProjectID: UUID? = null, + /** * Set by [MobileAppState.openThreadFromNotification] when an FCM * notification tap should route the UI to a specific thread. Consumed by diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt index bf6194c1..64ee1806 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt @@ -12,12 +12,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.MenuBook import androidx.compose.material3.CircularProgressIndicator 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.OutlinedTextField @@ -33,6 +34,7 @@ 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.SecretsManagedRepo import app.rxlab.rxcode.state.AutopilotException @@ -56,12 +58,14 @@ fun AutopilotRepoPicker( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current var search by remember { mutableStateOf("") } var repos by remember { mutableStateOf>(emptyList()) } var cursor by remember { mutableStateOf(null) } var hasMore by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) } + var isOpeningInstallUrl by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } var addingFullName by remember { mutableStateOf(null) } @@ -87,7 +91,38 @@ fun AutopilotRepoPicker( ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + IconButton( + enabled = !isOpeningInstallUrl, + onClick = { + scope.launch { + isOpeningInstallUrl = true + errorMessage = null + try { + val url = service.githubAppInstallUrl() + if (url.isBlank()) throw AutopilotException("The Mac returned an invalid GitHub App install URL.") + uriHandler.openUri(url) + } catch (e: Exception) { + errorMessage = e.message ?: "Failed to open the GitHub App install URL." + } finally { + isOpeningInstallUrl = false + } + } + }, + ) { + if (isOpeningInstallUrl) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.AddCircleOutline, contentDescription = "Install GitHub App") + } + } + } OutlinedTextField( value = search, onValueChange = { search = it }, @@ -169,7 +204,7 @@ private fun RepoPickerRow( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( - if (repo.isPrivate) Icons.Outlined.Lock else Icons.Outlined.MenuBook, + if (repo.isPrivate) Icons.Outlined.Lock else Icons.AutoMirrored.Outlined.MenuBook, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), 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 ce8ebe89..3b2b4ad9 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 @@ -16,12 +16,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add 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.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -32,6 +36,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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 @@ -44,9 +49,13 @@ import androidx.compose.material.icons.outlined.AccountTree import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import app.rxlab.rxcode.proto.AutopilotAccountStatus import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.relay.RelayClient +import app.rxlab.rxcode.state.AutopilotException import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.autopilot.AutopilotRepoPicker import app.rxlab.rxcode.ui.util.HapticEvent import app.rxlab.rxcode.ui.util.rememberHaptics import kotlinx.coroutines.delay @@ -65,12 +74,57 @@ fun ProjectsScreen( val haptics = rememberHaptics() val scope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } + var showingAddProjectMenu by remember { mutableStateOf(false) } + var showingLocalProjectPicker by remember { mutableStateOf(false) } + var showingRemoteProjectPicker by remember { mutableStateOf(false) } + var autopilotAccount by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel, state.activeDesktopPubkey, state.connectionState) { + if (viewModel == null || state.connectionState != RelayClient.ConnectionState.CONNECTED) { + autopilotAccount = null + return@LaunchedEffect + } + try { + autopilotAccount = viewModel.autopilot.accountStatus() + } catch (_: AutopilotException) { + autopilotAccount = null + } + } + + val canAddFromRemoteRepositories = viewModel != null && + state.isPaired && + state.connectionState == RelayClient.ConnectionState.CONNECTED && + autopilotAccount?.isSignedIn == true + val existingGitHubRepoNames = remember(state.projects) { + state.projects.mapNotNull { it.gitHubRepo?.lowercase() }.toSet() + } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { CenterAlignedTopAppBar( title = { Text("Projects") }, actions = { + if (viewModel != null) { + Box { + IconButton(onClick = { showingAddProjectMenu = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add Project") + } + AddProjectDropdownMenu( + expanded = showingAddProjectMenu, + canAddFromRemoteRepositories = canAddFromRemoteRepositories, + onDismiss = { showingAddProjectMenu = false }, + onAddLocal = { + showingAddProjectMenu = false + showingLocalProjectPicker = true + }, + onAddRemote = { + showingAddProjectMenu = false + showingRemoteProjectPicker = true + }, + ) + } + } IconButton(onClick = onOpenSearch) { Icon(Icons.Outlined.Search, contentDescription = "Search") } @@ -96,7 +150,11 @@ fun ProjectsScreen( Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center, ) { - EmptyProjects() + EmptyProjects( + onAddProject = viewModel?.let { + { showingAddProjectMenu = true } + }, + ) } else -> PullToRefreshBox( @@ -224,10 +282,52 @@ fun ProjectsScreen( } } } + if (showingLocalProjectPicker && viewModel != null) { + RemoteFolderPickerSheet( + state = state, + viewModel = viewModel, + onDismiss = { showingLocalProjectPicker = false }, + ) + } + if (showingRemoteProjectPicker && viewModel != null) { + AutopilotRepoPicker( + service = viewModel.autopilot, + title = "Add Project", + existingFullNames = existingGitHubRepoNames, + onSelect = { repo -> + viewModel.cloneAutopilotRepo(repo) + }, + onDismiss = { showingRemoteProjectPicker = false }, + ) + } +} + +@Composable +private fun AddProjectDropdownMenu( + expanded: Boolean, + canAddFromRemoteRepositories: Boolean, + onDismiss: () -> Unit, + onAddLocal: () -> Unit, + onAddRemote: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text("Add project locally") }, + leadingIcon = { Icon(Icons.Outlined.Folder, contentDescription = null) }, + onClick = onAddLocal, + ) + if (canAddFromRemoteRepositories) { + DropdownMenuItem( + text = { Text("Add project from remote repositories") }, + leadingIcon = { Icon(Icons.Outlined.Add, contentDescription = null) }, + onClick = onAddRemote, + ) + } + } } @Composable -private fun EmptyProjects() { +private fun EmptyProjects(onAddProject: (() -> Unit)? = null) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { Surface( shape = CircleShape, @@ -240,6 +340,13 @@ private fun EmptyProjects() { } } Text("No projects yet", style = MaterialTheme.typography.titleMedium) - Text("Add a project on macOS.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Add a project from your Mac.", color = MaterialTheme.colorScheme.onSurfaceVariant) + if (onAddProject != null) { + Button(onClick = onAddProject) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Add Project") + } + } } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/RemoteFolderPickerSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/RemoteFolderPickerSheet.kt new file mode 100644 index 00000000..aee637fb --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/RemoteFolderPickerSheet.kt @@ -0,0 +1,353 @@ +package app.rxlab.rxcode.ui.projects + +import androidx.activity.compose.BackHandler +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +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.Text +import androidx.compose.material3.TextButton +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.RemoteFolderNode +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteFolderPickerSheet( + state: MobileState, + viewModel: MobileAppState, + onDismiss: () -> Unit, +) { + val haptics = rememberHaptics() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var navigationPath by remember { mutableStateOf>(emptyList()) } + var dismissedAfterCreateId by remember { mutableStateOf(state.lastCreatedProjectID) } + + val currentPath = navigationPath.lastOrNull() + val currentNode = state.remoteFolderRoot + val canAdd = currentNode != null && + currentNode.path.isNotBlank() && + currentNode.isSelectable && + !state.remoteProjectCreateInFlight + + LaunchedEffect(currentPath) { + viewModel.requestRemoteFolder(path = currentPath) + } + + LaunchedEffect(state.lastCreatedProjectID) { + val createdId = state.lastCreatedProjectID ?: return@LaunchedEffect + if (dismissedAfterCreateId != createdId) { + dismissedAfterCreateId = createdId + onDismiss() + } + } + + BackHandler(enabled = navigationPath.isNotEmpty()) { + navigationPath = navigationPath.dropLast(1) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon(Icons.Outlined.Close, contentDescription = "Close") + } + Text( + "Add Project", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + IconButton( + enabled = canAdd, + onClick = { + val path = currentNode?.path ?: return@IconButton + haptics.play(HapticEvent.LightTap) + viewModel.createProjectFromRemoteFolder(path) + }, + ) { + if (state.remoteProjectCreateInFlight) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Add, contentDescription = "Add Project") + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 240.dp, max = 560.dp), + ) { + when { + currentNode != null -> FolderList( + node = currentNode, + isLoading = state.remoteFolderIsLoading, + onOpen = { child -> + haptics.play(HapticEvent.Selection) + navigationPath = folderNavigationPath(child.path) + }, + ) + + state.remoteFolderIsLoading -> LoadingFolders() + + else -> FolderUnavailable(state.remoteFolderError) + } + + if (state.remoteFolderIsLoading && currentNode != null) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + } + } + + state.remoteFolderError?.let { message -> + AlertDialog( + onDismissRequest = { viewModel.clearRemoteFolderError() }, + confirmButton = { + TextButton(onClick = { viewModel.requestRemoteFolder(path = currentPath) }) { + Text("Retry") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.clearRemoteFolderError() }) { + Text("OK") + } + }, + title = { Text("Unable to Load Folder") }, + text = { Text(message) }, + ) + } + + state.remoteProjectCreateError?.let { message -> + AlertDialog( + onDismissRequest = { viewModel.clearRemoteProjectCreateError() }, + confirmButton = { + TextButton( + enabled = !currentNode?.path.isNullOrBlank(), + onClick = { viewModel.createProjectFromRemoteFolder(currentNode?.path.orEmpty()) }, + ) { + Text("Retry") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.clearRemoteProjectCreateError() }) { + Text("OK") + } + }, + title = { Text("Unable to Add Project") }, + text = { Text(message) }, + ) + } +} + +@Composable +private fun FolderList( + node: RemoteFolderNode, + isLoading: Boolean, + onOpen: (RemoteFolderNode) -> Unit, +) { + LazyColumn(Modifier.fillMaxWidth()) { + item { + CurrentFolderRow(node) + } + if (node.children.isEmpty() && !isLoading) { + item { + EmptyFolderRow() + } + } else { + items(node.children, key = { it.path }) { child -> + if (child.isDirectory) { + FolderRow(child = child, onClick = { onOpen(child) }) + } else { + FileRow(child) + } + } + } + } +} + +@Composable +private fun CurrentFolderRow(node: RemoteFolderNode) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(node.name, style = MaterialTheme.typography.titleSmall) + if (node.path.isNotBlank()) { + Text( + node.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun FolderRow(child: RemoteFolderNode, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(child.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + child.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun FileRow(child: RemoteFolderNode) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.AutoMirrored.Outlined.InsertDriveFile, contentDescription = null, tint = MaterialTheme.colorScheme.outline) + Text( + child.name, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun EmptyFolderRow() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.outline) + Text("No folders", style = MaterialTheme.typography.titleSmall) + Text("This location has no visible folders.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun LoadingFolders() { + Column( + modifier = Modifier + .fillMaxWidth() + .height(240.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(12.dp)) + Text("Loading folders", color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun FolderUnavailable(message: String?) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.outline) + Text("Folders unavailable", style = MaterialTheme.typography.titleSmall) + Text( + message ?: "Connect to your Mac and try again.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun folderNavigationPath(path: String): List { + val clean = path.trim().trimEnd('/') + if (clean.isBlank()) return emptyList() + if (clean == "/") return listOf("/") + val components = clean.trim('/').split('/').filter { it.isNotBlank() } + val paths = mutableListOf("/") + var cursor = "" + components.forEach { component -> + cursor += "/$component" + paths.add(cursor) + } + return paths +} diff --git a/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/proto/RemoteProjectPayloadTest.kt b/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/proto/RemoteProjectPayloadTest.kt new file mode 100644 index 00000000..b338c303 --- /dev/null +++ b/RxCodeAndroid/app/src/test/java/app/rxlab/rxcode/proto/RemoteProjectPayloadTest.kt @@ -0,0 +1,88 @@ +package app.rxlab.rxcode.proto + +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteProjectPayloadTest { + + @Test + fun folderTreePayloadsRoundTrip() { + val request = Payload.FolderTreeRequest( + FolderTreeRequestPayload( + path = "/Users/alex/Code", + depth = 1, + includeFiles = false, + ) + ) + + val decodedRequest = RxJson.decodeFromString( + RxJson.encodeToString(Payload.serializer(), request) + ) + + assertTrue(decodedRequest is Payload.FolderTreeRequest) + assertEquals("/Users/alex/Code", (decodedRequest as Payload.FolderTreeRequest).data.path) + + val result = Payload.FolderTreeResult( + FolderTreeResultPayload( + clientRequestID = request.data.clientRequestID, + requestedPath = "/Users/alex/Code", + ok = true, + root = RemoteFolderNode( + name = "Code", + path = "/Users/alex/Code", + children = listOf( + RemoteFolderNode(name = "RxCode", path = "/Users/alex/Code/RxCode"), + ), + ), + ) + ) + + val decodedResult = RxJson.decodeFromString( + RxJson.encodeToString(Payload.serializer(), result) + ) + + assertTrue(decodedResult is Payload.FolderTreeResult) + val data = (decodedResult as Payload.FolderTreeResult).data + assertEquals(request.data.clientRequestID, data.clientRequestID) + assertEquals("RxCode", data.root?.children?.firstOrNull()?.name) + } + + @Test + fun createProjectPayloadsRoundTrip() { + val projectId = UUID.fromString("11111111-2222-3333-4444-555555555555") + val request = Payload.CreateProjectRequest( + CreateProjectRequestPayload(path = "/Users/alex/Code/RxCode") + ) + + val decodedRequest = RxJson.decodeFromString( + RxJson.encodeToString(Payload.serializer(), request) + ) + + assertTrue(decodedRequest is Payload.CreateProjectRequest) + assertEquals("/Users/alex/Code/RxCode", (decodedRequest as Payload.CreateProjectRequest).data.path) + + val result = Payload.CreateProjectResult( + CreateProjectResultPayload( + clientRequestID = request.data.clientRequestID, + ok = true, + project = Project( + id = projectId, + name = "RxCode", + path = "/Users/alex/Code/RxCode", + ), + ) + ) + + val decodedResult = RxJson.decodeFromString( + RxJson.encodeToString(Payload.serializer(), result) + ) + + assertTrue(decodedResult is Payload.CreateProjectResult) + val data = (decodedResult as Payload.CreateProjectResult).data + assertEquals(request.data.clientRequestID, data.clientRequestID) + assertEquals(projectId, data.project?.id) + assertEquals("RxCode", data.project?.name) + } +} diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index d4fd0c3a..8d0d0b7a 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -693,6 +693,12 @@ } } } + }, + "Add project from remote repositories" : { + + }, + "Add project locally" : { + }, "Agent Clients" : { "localizations" : { @@ -4635,6 +4641,9 @@ } } } + }, + "Install GitHub App" : { + }, "Install RELEASE_TOKEN" : { "localizations" : { @@ -9125,6 +9134,9 @@ } } } + }, + "The Mac returned an invalid GitHub App install URL." : { + }, "The Mac returned an invalid pull-request URL." : { "localizations" : { diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index c090a8b9..433e01b9 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import RxCodeSync import TipKit @main @@ -46,9 +47,24 @@ struct RxCodeMobileApp: App { } private func handlePairingURL(_ url: URL) { + guard Self.isPairingURL(url) else { return } MobileHaptics.buttonTap() Task { await state.pair(from: url, displayName: UIDevice.current.name) } } + + private static func isPairingURL(_ url: URL) -> Bool { + if url.absoluteString.hasPrefix(PairingToken.legacySchemePrefix) { + return true + } + guard url.scheme == "https", + url.host?.lowercased() == PairingToken.deeplinkHost, + url.path == PairingToken.deeplinkPath, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.queryItems?.contains(where: { $0.name == "token" && !($0.value ?? "").isEmpty }) == true else { + return false + } + return true + } } diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 69734f0e..9fce1890 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -130,6 +130,27 @@ extension MobileAppState { as: SecretsManagedRepoPage.self) } + @discardableResult + func cloneAutopilotRepo(_ repo: SecretsManagedRepo) async throws -> Project { + let result = try await autopilotSend(.account, .cloneManagedRepo, + body: AutopilotCloneRepoBody(fullName: repo.fullName), + as: AutopilotCloneRepoResult.self) + if !projects.contains(where: { $0.id == result.project.id }) { + projects.append(result.project) + } + lastCreatedProjectID = result.project.id + Task { await requestSnapshot() } + return result.project + } + + func githubAppInstallURL() async throws -> URL { + let response = try await autopilotSend(.account, .installGitHubAppUrl, as: AutopilotInstallUrlResponse.self) + guard let url = URL(string: response.url) else { + throw AutopilotRemoteError.server(String(localized: "The Mac returned an invalid GitHub App install URL.")) + } + return url + } + // MARK: - Automation settings func automationSchema() async throws -> SchemaEnvelope { diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift index 462dcdb3..787c7e8e 100644 --- a/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift @@ -10,17 +10,19 @@ import SwiftUI struct MobileAutopilotRepoPicker: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL let title: String /// Full names already registered, hidden from the picker. let existingRepoFullNames: Set - let onSelect: (SecretsManagedRepo) async -> Void + let onSelect: (SecretsManagedRepo) async throws -> Void @State private var repos: [SecretsManagedRepo] = [] @State private var search = "" @State private var nextCursor: String? @State private var hasMore = false @State private var isLoading = false + @State private var isOpeningInstallURL = false @State private var addingFullName: String? @State private var errorMessage: String? @State private var searchTask: Task? @@ -90,6 +92,19 @@ struct MobileAutopilotRepoPicker: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await openGitHubAppInstallURL() } + } label: { + if isOpeningInstallURL { + ProgressView() + } else { + Image(systemName: "app.badge.checkmark") + } + } + .accessibilityLabel("Install GitHub App") + .disabled(isOpeningInstallURL) + } } .task { await reload() } } @@ -128,7 +143,22 @@ struct MobileAutopilotRepoPicker: View { private func select(_ repo: SecretsManagedRepo) async { addingFullName = repo.fullName defer { addingFullName = nil } - await onSelect(repo) - dismiss() + do { + try await onSelect(repo) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + + private func openGitHubAppInstallURL() async { + isOpeningInstallURL = true + errorMessage = nil + defer { isOpeningInstallURL = false } + do { + openURL(try await state.githubAppInstallURL()) + } catch { + errorMessage = error.localizedDescription + } } } diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index a83500f1..145a82e1 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -13,6 +13,7 @@ struct ProjectsSidebar: View { var usesSelection = true @State private var searchText = "" @State private var showingRemoteFolderPicker = false + @State private var showingGitHubRepoPicker = false @Namespace private var glassNamespace var body: some View { @@ -20,12 +21,7 @@ struct ProjectsSidebar: View { .navigationTitle("Projects") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - showingRemoteFolderPicker = true - } label: { - Image(systemName: "folder.badge.plus") - } - .accessibilityLabel("Add Project") + addProjectMenu(iconOnly: true) .popoverTip(MobileTips.RemoteProjectTip(), arrowEdge: .top) } } @@ -34,9 +30,25 @@ struct ProjectsSidebar: View { .environmentObject(state) .mobileSheetPresentation() } + .sheet(isPresented: $showingGitHubRepoPicker) { + MobileAutopilotRepoPicker( + title: "Add Project", + existingRepoFullNames: existingGitHubRepoNames + ) { repo in + try await state.cloneAutopilotRepo(repo) + } + .environmentObject(state) + .mobileSheetPresentation() + } .refreshable { await state.refreshSnapshot() } + .task { + await loadAutopilotAccountIfNeeded() + } + .onChange(of: state.connectionState) { _, _ in + Task { await loadAutopilotAccountIfNeeded(force: true) } + } .modifier(ProjectSidebarSearchModifier(isEnabled: showsSearch, searchText: $searchText)) .modifier(ProjectSidebarSearchTipModifier(isEnabled: showsSearch)) .autocorrectionDisabled(true) @@ -230,12 +242,8 @@ struct ProjectsSidebar: View { .multilineTextAlignment(.center) .padding(.horizontal, 32) - Button { - showingRemoteFolderPicker = true - } label: { - Label("Add Project", systemImage: "plus") - .font(.subheadline.weight(.medium)) - } + addProjectMenu(iconOnly: false) + .font(.subheadline.weight(.medium)) .buttonStyle(.glass) .padding(.top, 8) } @@ -250,6 +258,48 @@ struct ProjectsSidebar: View { false } + private var canAddFromRemoteRepositories: Bool { + state.isPaired + && state.connectionState == .connected + && state.autopilotAccount?.isSignedIn == true + } + + private var existingGitHubRepoNames: Set { + Set(state.projects.compactMap { $0.gitHubRepo?.lowercased() }) + } + + @ViewBuilder + private func addProjectMenu(iconOnly: Bool) -> some View { + Menu { + Button { + showingRemoteFolderPicker = true + } label: { + Label("Add project locally", systemImage: "folder.badge.plus") + } + + if canAddFromRemoteRepositories { + Button { + showingGitHubRepoPicker = true + } label: { + Label("Add project from remote repositories", systemImage: "square.and.arrow.down") + } + } + } label: { + if iconOnly { + Image(systemName: "plus") + } else { + Label("Add Project", systemImage: "plus") + } + } + .accessibilityLabel("Add Project") + } + + private func loadAutopilotAccountIfNeeded(force: Bool = false) async { + guard state.isPaired, state.connectionState == .connected else { return } + if !force, state.autopilotAccount != nil { return } + _ = try? await state.loadAutopilotAccount() + } + private func threadCount(for projectID: UUID) -> Int { state.sessions.filter { $0.projectId == projectID && !$0.isArchived }.count } diff --git a/RxCodeTests/GeneratedTextSanitizerTests.swift b/RxCodeTests/GeneratedTextSanitizerTests.swift new file mode 100644 index 00000000..8fd05af7 --- /dev/null +++ b/RxCodeTests/GeneratedTextSanitizerTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import RxCode + +final class GeneratedTextSanitizerTests: XCTestCase { + func testCleanMarkdownDocumentUnwrapsFencedMarkdown() { + let raw = """ + ```markdown + ### Features + - Added branch briefing cleanup. + ``` + """ + + XCTAssertEqual( + GeneratedTextSanitizer.cleanMarkdownDocument(raw), + """ + ### Features + - Added branch briefing cleanup. + """ + ) + } + + func testCleanMarkdownDocumentDropsPersistedLanguageLabel() { + let raw = """ + markdown + ### Fixes + - Hid the stray language marker. + """ + + XCTAssertEqual( + GeneratedTextSanitizer.cleanMarkdownDocument(raw), + """ + ### Fixes + - Hid the stray language marker. + """ + ) + } + + func testCleanMarkdownDocumentUnwrapsQuotedFencedMarkdown() { + let raw = """ + "```markdown + ### Improvements + - Preserved headings while removing wrappers. + ```" + """ + + XCTAssertEqual( + GeneratedTextSanitizer.cleanMarkdownDocument(raw), + """ + ### Improvements + - Preserved headings while removing wrappers. + """ + ) + } + + func testCleanSummaryRejectsProviderErrorsAfterFenceCleanup() { + let raw = """ + ```text + Error: model unavailable + ``` + """ + + XCTAssertNil( + GeneratedTextSanitizer.cleanSummary( + raw, + limit: 180, + errorPrefixes: ["error:"] + ) + ) + } +} From 87efb6b80b0e080a9068faf1e3ebd43a7de902c2 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:47:50 +0800 Subject: [PATCH 2/5] feat: improve the hook ui design --- .../RxCodeCore/Models/HookProfile.swift | 46 +- .../RxCodeCore/Models/RunProfile.swift | 14 +- .../RunProfile/RunTaskExecutor.swift | 2 +- RxCode/Resources/Localizable.xcstrings | 27 +- RxCode/Services/Hooks/HookService.swift | 16 +- .../Views/Hooks/HookConfigurationsView.swift | 12 +- .../Views/Hooks/HookProfileDetailForm.swift | 55 +- .../RunProfile/ProfileCommandSections.swift | 493 ++++++++++++++++++ .../RunProfile/RunConfigurationsView.swift | 10 +- .../RunProfileDetailForm+Environments.swift | 22 - .../RunProfileDetailForm+Steps.swift | 11 - .../RunProfile/RunProfileDetailForm.swift | 406 +-------------- .../Views/MobileRunProfileEditorView.swift | 10 +- .../Views/MobileRunProfilesView.swift | 4 +- 14 files changed, 614 insertions(+), 514 deletions(-) create mode 100644 RxCode/Views/RunProfile/ProfileCommandSections.swift diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift index 2d485404..71a22fd2 100644 --- a/Packages/Sources/RxCodeCore/Models/HookProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -21,22 +21,26 @@ public enum HookTrigger: String, Codable, Sendable, CaseIterable, Hashable { } } -/// What a hook runs. Bash-only today; typed so other kinds can be added later. -public enum HookType: String, Codable, Sendable, CaseIterable, Hashable { - case bash -} - /// A single project-scoped automation that runs a command at a session -/// lifecycle point. Modeled on `RunProfile`; reuses `BashRunConfig` for the -/// command, working directory, and environment presets. +/// lifecycle point. Mirrors `RunProfile` one-to-one: it carries the same +/// `RunProfileType` and per-type config structs so a hook can run a bash +/// command, an `xcodebuild` invocation, a `make` target, or a package script. +/// The only hook-specific fields are `enabled` and the lifecycle `trigger`. public struct HookProfile: Identifiable, Codable, Sendable, Hashable { public var id: UUID public var projectId: UUID public var name: String public var enabled: Bool public var trigger: HookTrigger - public var type: HookType + public var type: RunProfileType public var bash: BashRunConfig + /// Populated when `type == .xcode`. Optional so hooks written before the + /// typed configs existed (bash-only) decode cleanly. + public var xcode: XcodeRunConfig? + /// Populated when `type == .make`. Optional for backward-compatible decode. + public var make: MakeRunConfig? + /// Populated when `type == .packageScript`. Optional for backward-compatible decode. + public var package: PackageRunConfig? public var createdAt: Date public var updatedAt: Date @@ -46,8 +50,11 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { name: String, enabled: Bool = true, trigger: HookTrigger = .beforeSessionStart, - type: HookType = .bash, + type: RunProfileType = .bash, bash: BashRunConfig = BashRunConfig(), + xcode: XcodeRunConfig? = nil, + make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil, createdAt: Date = Date(), updatedAt: Date = Date() ) { @@ -58,7 +65,28 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { self.trigger = trigger self.type = type self.bash = bash + self.xcode = xcode + self.make = make + self.package = package self.createdAt = createdAt self.updatedAt = updatedAt } + + /// Bridge to `RunProfile` so a hook can reuse the run-profile command + /// synthesis (`RunTaskExecutor.mainCommandLines`). Hooks have no + /// before/after steps, so those are left empty. + public var asRunProfile: RunProfile { + RunProfile( + id: id, + projectId: projectId, + name: name, + type: type, + bash: bash, + xcode: xcode, + make: make, + package: package, + createdAt: createdAt, + updatedAt: updatedAt + ) + } } diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift index 9cef8dc1..bc58d67f 100644 --- a/Packages/Sources/RxCodeCore/Models/RunProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -6,8 +6,20 @@ public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { case make /// Runs a `package.json` / `deno.json` script through a selected package /// manager. Named `packageScript` to avoid the `package` access-control - /// keyword; the rawValue stays `"package"` so it renders as "Package". + /// keyword; the rawValue stays `"package"` for backward-compatible + /// persistence and mobile/Android sync. User-facing label is "Node.js". case packageScript = "package" + + /// User-facing label. `packageScript` shows as "Node.js" (the old + /// "Package" label was too broad); the rest are their capitalized names. + public var displayName: String { + switch self { + case .bash: return "Bash" + case .xcode: return "Xcode" + case .make: return "Make" + case .packageScript: return "Node.js" + } + } } public struct RunProfile: Identifiable, Codable, Sendable, Hashable { diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index ce7fd457..546f01ca 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -136,7 +136,7 @@ public enum RunTaskExecutor { /// appropriate `xcodebuild` invocation (and, for `.run`, locate the built /// `.app` from build settings and `open` it); for `.make` we synthesize /// a `make` invocation against the configured Makefile and target. - static func mainCommandLines(for profile: RunProfile, projectPath: String) -> [String] { + public static func mainCommandLines(for profile: RunProfile, projectPath: String) -> [String] { switch profile.type { case .bash: let cmd = profile.bash.command.trimmingCharacters(in: .whitespaces) diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index c80ee6ce..42107996 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -5427,8 +5427,12 @@ } } } + }, + "Empty Node.js Configuration" : { + }, "Empty Package Configuration" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6585,46 +6589,46 @@ } } }, - "hook.secrets.checking" : { + "hook.pullRequest.creating" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking for secrets…" + "value" : "Creating pull request…" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "시크릿 확인 중…" + "value" : "풀 리퀘스트 생성 중…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在检查密钥…" + "value" : "正在创建拉取请求…" } } } }, - "hook.pullRequest.creating" : { + "hook.secrets.checking" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Creating pull request…" + "value" : "Checking for secrets…" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "풀 리퀘스트 생성 중…" + "value" : "시크릿 확인 중…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在创建拉取请求…" + "value" : "正在检查密钥…" } } } @@ -9474,6 +9478,9 @@ } } } + }, + "Node.js" : { + }, "Non-zero → agent continues" : { "extractionState" : "stale", @@ -10197,6 +10204,7 @@ } }, "Package" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -12103,6 +12111,7 @@ } }, "Runs with `/bin/zsh -lc`. Absolute or project-relative working directory; leave empty to use the project root." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15534,4 +15543,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCode/Services/Hooks/HookService.swift b/RxCode/Services/Hooks/HookService.swift index b6875a57..b5fdebaf 100644 --- a/RxCode/Services/Hooks/HookService.swift +++ b/RxCode/Services/Hooks/HookService.swift @@ -21,12 +21,18 @@ enum HookService { /// store or the injected system prompt. private static let maxOutputBytes = 64 * 1024 - /// Run a bash hook. Resolves working directory and environment with the same - /// helpers `RunService` uses, then executes the command under a login zsh so - /// the user's PATH (bun, pnpm, pyenv, …) is available — matching how - /// `RunTaskExecutor` and the interactive terminal source PATH. + /// Run a hook. Synthesizes the command from the hook's type with the same + /// builder run profiles use (`RunTaskExecutor.mainCommandLines`), so a hook + /// can be bash, `xcodebuild`, `make`, or a package script. Resolves working + /// directory and environment with the same helpers `RunService` uses, then + /// executes under a login zsh so the user's PATH (bun, pnpm, pyenv, …) is + /// available — matching how `RunTaskExecutor` and the interactive terminal + /// source PATH. static func run(_ profile: HookProfile, project: Project) async -> HookRunResult { - let command = profile.bash.command.trimmingCharacters(in: .whitespacesAndNewlines) + let command = RunTaskExecutor + .mainCommandLines(for: profile.asRunProfile, projectPath: project.path) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) guard !command.isEmpty else { return HookRunResult(output: "", exitCode: 0) } diff --git a/RxCode/Views/Hooks/HookConfigurationsView.swift b/RxCode/Views/Hooks/HookConfigurationsView.swift index 4f5db9a2..c18ef65e 100644 --- a/RxCode/Views/Hooks/HookConfigurationsView.swift +++ b/RxCode/Views/Hooks/HookConfigurationsView.swift @@ -129,7 +129,7 @@ struct HookConfigurationsView: View { @ViewBuilder private func hookRow(_ hook: HookProfile) -> some View { HStack { - Image(systemName: "bolt.horizontal.circle") + Image(systemName: iconName(for: hook.type)) .foregroundStyle(hook.enabled ? ClaudeTheme.accent : ClaudeTheme.textTertiary) Text(hook.name.isEmpty ? "Untitled" : hook.name) .foregroundStyle(hook.enabled ? ClaudeTheme.textPrimary : ClaudeTheme.textTertiary) @@ -137,6 +137,16 @@ struct HookConfigurationsView: View { .tag(hook.id) } + /// SF Symbol per type — matches `RunConfigurationsView.iconName(for:)`. + private func iconName(for type: RunProfileType) -> String { + switch type { + case .xcode: return "hammer.fill" + case .make: return "wrench.and.screwdriver.fill" + case .bash: return "terminal" + case .packageScript: return "shippingbox.fill" + } + } + private var addMenu: some View { Menu { ForEach(HookTrigger.allCases, id: \.self) { trigger in diff --git a/RxCode/Views/Hooks/HookProfileDetailForm.swift b/RxCode/Views/Hooks/HookProfileDetailForm.swift index 20c438b5..157f109d 100644 --- a/RxCode/Views/Hooks/HookProfileDetailForm.swift +++ b/RxCode/Views/Hooks/HookProfileDetailForm.swift @@ -33,33 +33,15 @@ struct HookProfileDetailForm: View { Text("Lifecycle") } - Section { - TextEditor(text: $hook.bash.command) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) - ) - HStack { - TextField("Working Directory", text: $hook.bash.workingDirectory, prompt: Text(project.path)) - Button("Browse…") { - pickDirectory { picked in - hook.bash.workingDirectory = picked - } - } - Button { - hook.bash.workingDirectory = "" - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Reset to project root") - } - } header: { - Text("Command") - } footer: { - Text("Runs with `/bin/zsh -lc`. Absolute or project-relative working directory; leave empty to use the project root.") - } + ProfileCommandSections( + profileID: hook.id, + project: project, + type: $hook.type, + bash: $hook.bash, + xcode: $hook.xcode, + make: $hook.make, + package: $hook.package + ) BashEnvironmentEditor(bash: $hook.bash, projectPath: project.path) } @@ -77,25 +59,6 @@ struct HookProfileDetailForm: View { } } - // MARK: - Directory picker - - private func pickDirectory(onPick: @escaping (String) -> Void) { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: project.path) - guard panel.runModal() == .OK, let url = panel.url else { return } - let root = (project.path as NSString).standardizingPath - let std = (url.path as NSString).standardizingPath - if std.hasPrefix(root + "/") { - onPick(String(std.dropFirst(root.count + 1))) - } else if std == root { - onPick("") - } else { - onPick(std) - } - } } // MARK: - Lifecycle diagram diff --git a/RxCode/Views/RunProfile/ProfileCommandSections.swift b/RxCode/Views/RunProfile/ProfileCommandSections.swift new file mode 100644 index 00000000..bda4b33f --- /dev/null +++ b/RxCode/Views/RunProfile/ProfileCommandSections.swift @@ -0,0 +1,493 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Shared command-configuration form sections — the Type picker plus the +/// per-type command editors (bash / xcode / make / package) with their +/// auto-detection. Reused by both `RunProfileDetailForm` and +/// `HookProfileDetailForm` so the typed editors live in exactly one place. +/// +/// Operates on bindings to the individual config structs rather than a whole +/// `RunProfile`/`HookProfile`, so it is agnostic to which model it edits. +struct ProfileCommandSections: View { + /// Used only to scope detection cache keys to the edited profile/hook. + let profileID: UUID + let project: Project + @Binding var type: RunProfileType + @Binding var bash: BashRunConfig + @Binding var xcode: XcodeRunConfig? + @Binding var make: MakeRunConfig? + @Binding var package: PackageRunConfig? + + @State private var detectedMakeTargets: [String] = [] + @State private var detectedPackageScripts: [String] = [] + @State private var installedPackageManagers: [PackageManager] = [] + static let customTargetSentinel = "__rxcode_custom_target__" + static let customScriptSentinel = "__rxcode_custom_script__" + + var body: some View { + Group { + typeSection + + switch type { + case .bash: + bashCommandSection + case .xcode: + xcodeCommandSection + case .make: + makeCommandSection + case .packageScript: + packageCommandSection + } + } + } + + // MARK: - Type + + @ViewBuilder + private var typeSection: some View { + Section { + Picker("Type", selection: Binding( + get: { type }, + set: { newValue in + type = newValue + if newValue == .xcode, xcode == nil { + xcode = XcodeRunConfig() + } + if newValue == .make, make == nil { + make = MakeRunConfig() + } + if newValue == .packageScript, package == nil { + package = PackageRunConfig() + } + } + )) { + ForEach(RunProfileType.allCases, id: \.self) { t in + Text(t.displayName).tag(t) + } + } + } header: { + Text("Type") + } + } + + // MARK: - Command sections + + @ViewBuilder + var bashCommandSection: some View { + Section { + TextEditor(text: $bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Command") + } footer: { + Text("Absolute or project-relative path. Leave empty to use the project root.") + } + } + + @ViewBuilder + var xcodeCommandSection: some View { + Section { + let xcode = Binding( + get: { self.xcode ?? XcodeRunConfig() }, + set: { self.xcode = $0 } + ) + HStack { + TextField( + "Project / Workspace", + text: xcode.container, + prompt: Text("RxCode.xcodeproj") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickXcodeContainer { name, isWorkspace in + var c = xcode.wrappedValue + c.container = name + c.isWorkspace = isWorkspace + xcode.wrappedValue = c + } + } + } + Toggle("Use Workspace (.xcworkspace)", isOn: xcode.isWorkspace) + TextField("Scheme", text: xcode.scheme, prompt: Text("RxCode")) + .font(.system(.body, design: .monospaced)) + TextField("Configuration", text: xcode.configuration, prompt: Text("Debug")) + .font(.system(.body, design: .monospaced)) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + LabeledContent("Destination") { + HStack(spacing: 6) { + Text(xcode.wrappedValue.selectedDestination?.displayName ?? "Any Mac (default)") + .foregroundStyle(.secondary) + if xcode.wrappedValue.selectedDestination != nil { + Button { + var c = xcode.wrappedValue + c.selectedDestination = nil + xcode.wrappedValue = c + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("Clear destination — pick again from the toolbar") + } + } + } + } header: { + Text("Xcode") + } footer: { + Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar.") + } + } + + @ViewBuilder + var makeCommandSection: some View { + Section { + let make = Binding( + get: { self.make ?? MakeRunConfig() }, + set: { self.make = $0 } + ) + HStack { + TextField( + "Makefile (optional)", + text: make.makefile, + prompt: Text("Makefile") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickFile { picked in + var c = make.wrappedValue + c.makefile = picked + make.wrappedValue = c + } + } + Button { + var c = make.wrappedValue + c.makefile = "" + make.wrappedValue = c + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Use default Makefile lookup") + } + targetField(make: make) + TextField( + "Arguments (optional)", + text: make.arguments, + prompt: Text("VAR=value -j8") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Make") + } footer: { + Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") + } + .task(id: makeDetectionKey) { + detectedMakeTargets = await refreshMakeTargets() + } + } + + /// Cache key for re-parsing the Makefile when its path or the working + /// directory changes. + var makeDetectionKey: String { + "\(profileID.uuidString)|\(make?.makefile ?? "")|\(bash.workingDirectory)" + } + + func refreshMakeTargets() async -> [String] { + let projectRoot = project.path + let makefile = make?.makefile ?? "" + let workingDirRaw = bash.workingDirectory + return await Task.detached { () -> [String] in + let fm = FileManager.default + func resolve(_ p: String, against base: String) -> String { + if p.isEmpty { return base } + if p.hasPrefix("/") { return p } + return (base as NSString).appendingPathComponent(p) + } + let workingDir = workingDirRaw.isEmpty + ? projectRoot + : resolve(workingDirRaw, against: projectRoot) + + if !makefile.isEmpty { + // Picker produces project-relative paths; the executor resolves + // relative to working dir. Try both so the dropdown works + // regardless of which the user typed. + let candidates = [ + resolve(makefile, against: projectRoot), + resolve(makefile, against: workingDir), + ] + for path in candidates where fm.fileExists(atPath: path) { + return RunProfileDetector.makeTargets(atPath: path) + } + return [] + } + if let result = RunProfileDetector.defaultMakeTargets(inDirectory: workingDir) { + return result.targets + } + return RunProfileDetector.defaultMakeTargets(inDirectory: projectRoot)?.targets ?? [] + }.value + } + + @ViewBuilder + func targetField(make: Binding) -> some View { + let current = make.wrappedValue.target + let targets = detectedMakeTargets + let isCustom = !targets.isEmpty && !current.isEmpty && !targets.contains(current) + + if targets.isEmpty { + TextField("Target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } else { + Picker("Target", selection: Binding( + get: { + if current.isEmpty { return "" } + return isCustom ? Self.customTargetSentinel : current + }, + set: { newValue in + var c = make.wrappedValue + if newValue == Self.customTargetSentinel { + if targets.contains(c.target) { c.target = "" } + } else { + c.target = newValue + } + make.wrappedValue = c + } + )) { + if current.isEmpty { + Text("Select a target…").tag("") + } + ForEach(targets, id: \.self) { t in + Text(t).tag(t) + } + Divider() + Text("Custom…").tag(Self.customTargetSentinel) + } + .font(.system(.body, design: .monospaced)) + + if isCustom { + TextField("Custom target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } + } + } + + // MARK: - Package + + @ViewBuilder + var packageCommandSection: some View { + Section { + let pkg = Binding( + get: { self.package ?? PackageRunConfig() }, + set: { self.package = $0 } + ) + Picker("Package Manager", selection: pkg.packageManager) { + ForEach(PackageManager.allCases, id: \.self) { manager in + let installed = installedPackageManagers.isEmpty + || installedPackageManagers.contains(manager) + Text(installed ? manager.displayName : "\(manager.displayName) (not installed)") + .tag(manager) + .disabled(!installed) + } + } + scriptField(pkg: pkg) + TextField( + "Arguments (optional)", + text: pkg.arguments, + prompt: Text("-- --port 3000") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Node.js") + } footer: { + Text("Runs `\(packageCommandPreview)`. Arguments are appended after the script — use `-- ` to forward flags through npm/pnpm.") + } + .task(id: packageDetectionKey) { + detectedPackageScripts = await refreshPackageScripts() + installedPackageManagers = await RunProfileDetector.detectInstalledPackageManagers() + } + } + + /// ` run