diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index 8e4678a..18c3ff6 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -34,9 +34,16 @@ public struct ChatMessageListView: View { extension View { func chatMessageListRowStyle() -> some View { + #if os(macOS) + padding(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) + .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + #else listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 24, trailing: 20)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) + #endif } } diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 9f38490..c8eae38 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -20,6 +20,7 @@ struct MarkdownContentView: View { var body: some View { StructuredText(markdown: renderedMarkdown) + .id(renderedMarkdown) .font(.system(size: 15)) .tint(ClaudeTheme.accent) .textual.inlineStyle( diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index 2c8e989..e1fb981 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -69,6 +69,7 @@ struct MessageBubble: View { } else { // Assistant message: render blocks in order let renderBlocks = assistantRenderBlocks() + let cursorBlockId = streamingCursorBlockId(in: renderBlocks) // While the model is paused on an undecided ExitPlanMode in this // same message, sibling tools without results are effectively // suspended — not running. Drop the streaming flag for those so @@ -81,7 +82,11 @@ struct MessageBubble: View { switch block { case .text(let textBlock): if let text = textBlock.text, !text.isEmpty { - assistantTextBubble(text: text, blockId: textBlock.id) + assistantTextBubble( + text: text, + blockId: textBlock.id, + showsCursor: textBlock.id == cursorBlockId + ) } case .tool(let toolCall): if toolCall.name == "AskUserQuestion" { @@ -290,12 +295,8 @@ struct MessageBubble: View { // MARK: - Assistant Text Bubble - private func assistantTextBubble(text: String, blockId: String) -> some View { - let isLastBlock = message.blocks.last?.isText == true - && message.blocks.last?.text == text - let showsCursor = message.isStreaming && isLastBlock - - return MarkdownContentView( + private func assistantTextBubble(text: String, blockId: String, showsCursor: Bool) -> some View { + MarkdownContentView( text: text, showsTrailingCursor: showsCursor, isCursorVisible: cursorVisible @@ -321,6 +322,21 @@ struct MessageBubble: View { .accessibilityLabel("Assistant: \(text)") } + private func streamingCursorBlockId(in renderBlocks: [AssistantRenderBlock]) -> String? { + guard message.isStreaming, + latestStreamingAssistantMessageId == message.id, + case .text(let textBlock) = renderBlocks.last, + textBlock.text?.isEmpty == false + else { return nil } + return textBlock.id + } + + private var latestStreamingAssistantMessageId: UUID? { + chatBridge.messages.last { + $0.role == .assistant && $0.isStreaming + }?.id + } + // MARK: - Copy Button @ViewBuilder diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index 21af677..04fd839 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -23,51 +23,52 @@ struct MessageListView: View { var body: some View { ScrollViewReader { proxy in - List { - messageRows(settledItems[...]) - - // Streaming view is outside VStack — text deltas don't affect settled layout - if !windowState.focusMode { - StreamingMessageView { - rebuildSettledItems() - if anchor.isNearBottom { scrollToBottomDebounced(proxy) } + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + messageRows(settledItems[...]) + + // Streaming view is outside the settled rows — text deltas don't + // affect settled layout. + if !windowState.focusMode { + StreamingMessageView { + rebuildSettledItems() + if anchor.isNearBottom { scrollToBottomDebounced(proxy) } + } + // Suppress layout animations when switching sessions so the pulse indicator + // doesn't visually jump as StreamingMessageView changes height. + .animation(.none, value: windowState.currentSessionId) + .chatMessageListRowStyle() } - // Suppress layout animations when switching sessions so the pulse indicator - // doesn't visually jump as StreamingMessageView changes height. - .animation(.none, value: windowState.currentSessionId) - .chatMessageListRowStyle() - } - if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision { - // Hide the spinner/dots while the CLI is paused waiting on the - // user's plan decision — the model isn't actually generating - // tokens, so showing "in progress" is misleading. - HStack(alignment: .top, spacing: 0) { - StreamingIndicatorView( - isThinking: chatBridge.isThinking, - startDate: chatBridge.streamingStartDate, - agentProvider: chatBridge.agentProvider, - outputTokens: chatBridge.liveOutputTokens - ) - Spacer(minLength: 40) + if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision { + // Hide the spinner/dots while the CLI is paused waiting on the + // user's plan decision — the model isn't actually generating + // tokens, so showing "in progress" is misleading. + HStack(alignment: .top, spacing: 0) { + StreamingIndicatorView( + isThinking: chatBridge.isThinking, + startDate: chatBridge.streamingStartDate, + agentProvider: chatBridge.agentProvider, + outputTokens: chatBridge.liveOutputTokens + ) + Spacer(minLength: 40) + } + .chatMessageListRowStyle() + } + + if !chatBridge.isStreaming && !settledItems.isEmpty { + WebPreviewButton(messages: settledItems) + .id("web-preview") + .chatMessageListRowStyle() } - .chatMessageListRowStyle() - } - if !chatBridge.isStreaming && !settledItems.isEmpty { - WebPreviewButton(messages: settledItems) - .id("web-preview") + Color.clear.frame(height: 1) + .id(Self.bottomAnchorID) .chatMessageListRowStyle() } - - Color.clear.frame(height: 1) - .id(Self.bottomAnchorID) - .chatMessageListRowStyle() + .frame(maxWidth: .infinity, alignment: .leading) } - .listStyle(.plain) .contentMargins(.top, 16, for: .scrollContent) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) .opacity(isSessionReady ? 1 : 0) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in diff --git a/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift b/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift index eb046e0..00d36e8 100644 --- a/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift +++ b/Packages/Sources/RxCodeCore/Views/SessionSidebarRow.swift @@ -28,7 +28,7 @@ public struct SessionSidebarRow: View { VStack(alignment: .leading, spacing: 3) { TypewriterTitleText(title: title.prefix(1).uppercased() + title.dropFirst()) .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(.primary.opacity(0.8)) + .foregroundStyle(ClaudeTheme.textPrimary) .lineLimit(1) .contentTransition(.opacity) .animation(.easeInOut(duration: 0.25), value: title) @@ -42,12 +42,12 @@ public struct SessionSidebarRow: View { Text("·") .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(.tertiary) + .foregroundStyle(ClaudeTheme.textTertiary) } Text(Self.compactElapsed(since: updatedAt)) .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.tertiary) + .foregroundStyle(ClaudeTheme.textSecondary) } } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index ea83857..e9df608 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -33,6 +33,10 @@ public enum Payload: Sendable { case planDecision(PlanDecisionPayload) case branchOpRequest(BranchOpRequestPayload) case branchOpResult(BranchOpResultPayload) + case folderTreeRequest(FolderTreeRequestPayload) + case folderTreeResult(FolderTreeResultPayload) + case createProjectRequest(CreateProjectRequestPayload) + case createProjectResult(CreateProjectResultPayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) @@ -311,6 +315,94 @@ public struct BranchOpResultPayload: Codable, Sendable { } } +public struct RemoteFolderNode: Codable, Sendable, Identifiable, Equatable { + public var id: String { path } + + public let name: String + public let path: String + public let isSelectable: Bool + public let children: [RemoteFolderNode] + + public init(name: String, path: String, isSelectable: Bool = true, children: [RemoteFolderNode] = []) { + self.name = name + self.path = path + self.isSelectable = isSelectable + self.children = children + } +} + +public struct FolderTreeRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + /// `nil` asks the desktop for the picker roots. Non-nil asks for that + /// folder's immediate children. + public let path: String? + public let depth: Int + public let includeHidden: Bool + + public init( + clientRequestID: UUID = UUID(), + path: String? = nil, + depth: Int = 1, + includeHidden: Bool = false + ) { + self.clientRequestID = clientRequestID + self.path = path + self.depth = depth + self.includeHidden = includeHidden + } +} + +public struct FolderTreeResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let requestedPath: String? + public let ok: Bool + public let root: RemoteFolderNode? + public let errorMessage: String? + + public init( + clientRequestID: UUID, + requestedPath: String?, + ok: Bool, + root: RemoteFolderNode? = nil, + errorMessage: String? = nil + ) { + self.clientRequestID = clientRequestID + self.requestedPath = requestedPath + self.ok = ok + self.root = root + self.errorMessage = errorMessage + } +} + +public struct CreateProjectRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let path: String + + public init(clientRequestID: UUID = UUID(), path: String) { + self.clientRequestID = clientRequestID + self.path = path + } +} + +public struct CreateProjectResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let project: Project? + public let errorMessage: String? + + public init( + clientRequestID: UUID, + ok: Bool, + project: Project? = nil, + errorMessage: String? = nil + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.project = project + self.errorMessage = errorMessage + } +} + public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { public var id: String { "\(projectId.uuidString)::\(branch)" } @@ -1081,6 +1173,10 @@ extension Payload: Codable { case planDecision = "plan_decision" case branchOpRequest = "branch_op_request" case branchOpResult = "branch_op_result" + case folderTreeRequest = "folder_tree_request" + case folderTreeResult = "folder_tree_result" + case createProjectRequest = "create_project_request" + case createProjectResult = "create_project_result" case ping case pong } @@ -1119,6 +1215,10 @@ extension Payload: Codable { case .planDecision: self = .planDecision(try container.decode(PlanDecisionPayload.self, forKey: .data)) case .branchOpRequest: self = .branchOpRequest(try container.decode(BranchOpRequestPayload.self, forKey: .data)) case .branchOpResult: self = .branchOpResult(try container.decode(BranchOpResultPayload.self, forKey: .data)) + case .folderTreeRequest: self = .folderTreeRequest(try container.decode(FolderTreeRequestPayload.self, forKey: .data)) + case .folderTreeResult: self = .folderTreeResult(try container.decode(FolderTreeResultPayload.self, forKey: .data)) + case .createProjectRequest: self = .createProjectRequest(try container.decode(CreateProjectRequestPayload.self, forKey: .data)) + case .createProjectResult: self = .createProjectResult(try container.decode(CreateProjectResultPayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) case .pong: self = .pong(try container.decode(PongPayload.self, forKey: .data)) } @@ -1153,6 +1253,10 @@ extension Payload: Codable { case .planDecision(let p): try container.encode(TypeKey.planDecision.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpRequest(let p): try container.encode(TypeKey.branchOpRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .branchOpResult(let p): try container.encode(TypeKey.branchOpResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .folderTreeRequest(let p): try container.encode(TypeKey.folderTreeRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .folderTreeResult(let p): try container.encode(TypeKey.folderTreeResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .createProjectRequest(let p): try container.encode(TypeKey.createProjectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .createProjectResult(let p): try container.encode(TypeKey.createProjectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .pong(let p): try container.encode(TypeKey.pong.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unknown(let type): try container.encode(type, forKey: .type) diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index ec65285..fac281b 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -176,4 +176,93 @@ struct PayloadTests { #expect(update.permissionMode == .auto) #expect(update.focusMode == true) } + + @Test("remote folder picker payloads round trip") + func remoteFolderPickerPayloadsRoundTrip() throws { + let requestID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + let request = Payload.folderTreeRequest( + FolderTreeRequestPayload( + clientRequestID: requestID, + path: "/Users/test/Desktop", + depth: 1 + ) + ) + + let requestData = try JSONEncoder().encode(request) + let decodedRequest = try JSONDecoder().decode(Payload.self, from: requestData) + guard case .folderTreeRequest(let folderRequest) = decodedRequest else { + Issue.record("Expected folder tree request") + return + } + #expect(folderRequest.clientRequestID == requestID) + #expect(folderRequest.path == "/Users/test/Desktop") + #expect(folderRequest.depth == 1) + + let result = Payload.folderTreeResult( + FolderTreeResultPayload( + clientRequestID: requestID, + requestedPath: "/Users/test/Desktop", + ok: true, + root: RemoteFolderNode( + name: "Desktop", + path: "/Users/test/Desktop", + children: [ + RemoteFolderNode(name: "RxCode", path: "/Users/test/Desktop/RxCode") + ] + ) + ) + ) + + let resultData = try JSONEncoder().encode(result) + let decodedResult = try JSONDecoder().decode(Payload.self, from: resultData) + guard case .folderTreeResult(let folderResult) = decodedResult else { + Issue.record("Expected folder tree result") + return + } + #expect(folderResult.ok) + #expect(folderResult.root?.children.first?.name == "RxCode") + } + + @Test("mobile create project payloads round trip") + func mobileCreateProjectPayloadsRoundTrip() throws { + let requestID = UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF")! + let projectID = UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-000000000000")! + let request = Payload.createProjectRequest( + CreateProjectRequestPayload( + clientRequestID: requestID, + path: "/Users/test/Desktop/RxCode" + ) + ) + + let requestData = try JSONEncoder().encode(request) + let decodedRequest = try JSONDecoder().decode(Payload.self, from: requestData) + guard case .createProjectRequest(let createRequest) = decodedRequest else { + Issue.record("Expected create project request") + return + } + #expect(createRequest.clientRequestID == requestID) + #expect(createRequest.path == "/Users/test/Desktop/RxCode") + + let result = Payload.createProjectResult( + CreateProjectResultPayload( + clientRequestID: requestID, + ok: true, + project: Project( + id: projectID, + name: "RxCode", + path: "/Users/test/Desktop/RxCode" + ) + ) + ) + + let resultData = try JSONEncoder().encode(result) + let decodedResult = try JSONDecoder().decode(Payload.self, from: resultData) + guard case .createProjectResult(let createResult) = decodedResult else { + Issue.record("Expected create project result") + return + } + #expect(createResult.ok) + #expect(createResult.project?.id == projectID) + #expect(createResult.project?.name == "RxCode") + } } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index cb726db..c59bde2 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1207,6 +1207,34 @@ final class AppState { } mobileSyncObservers.append(branchOpObserver) + let folderTreeObserver = center.addObserver( + forName: .mobileSyncFolderTreeRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? FolderTreeRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileFolderTreeRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(folderTreeObserver) + + let createProjectObserver = center.addObserver( + forName: .mobileSyncCreateProjectRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? CreateProjectRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileCreateProjectRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(createProjectObserver) + let questionAnswerObserver = center.addObserver( forName: .mobileSyncQuestionAnswerReceived, object: nil, @@ -1418,6 +1446,218 @@ final class AppState { return error.localizedDescription } + private func handleMobileFolderTreeRequest(_ request: FolderTreeRequestPayload, fromHex: String) async { + let result: FolderTreeResultPayload + do { + let root = try mobileFolderTreeRoot(for: request) + result = FolderTreeResultPayload( + clientRequestID: request.clientRequestID, + requestedPath: request.path, + ok: true, + root: root + ) + } catch { + result = FolderTreeResultPayload( + clientRequestID: request.clientRequestID, + requestedPath: request.path, + ok: false, + errorMessage: mobileFolderErrorMessage(error) + ) + } + await MobileSyncService.shared.send(.folderTreeResult(result), toHex: fromHex) + } + + private func handleMobileCreateProjectRequest(_ request: CreateProjectRequestPayload, fromHex: String) async { + let trimmedPath = request.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Folder path is required.", + toHex: fromHex + ) + return + } + + let url = URL(fileURLWithPath: trimmedPath).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Folder does not exist on the desktop.", + toHex: fromHex + ) + return + } + + if projects.first(where: { $0.path == url.path }) == nil { + let isGitRepo = FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) + let gitHubRepo = isGitRepo ? detectGitHubOwnerRepo(at: url.path) : nil + await addProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo) + } + + guard let project = projects.first(where: { $0.path == url.path }) else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Failed to add project on the desktop.", + toHex: fromHex + ) + return + } + + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: true, + project: project, + errorMessage: nil, + toHex: fromHex + ) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + scheduleMobileSnapshotBroadcast() + } + + private func replyCreateProjectResult( + requestID: UUID, + ok: Bool, + project: Project?, + errorMessage: String?, + toHex hex: String + ) async { + let result = CreateProjectResultPayload( + clientRequestID: requestID, + ok: ok, + project: project, + errorMessage: errorMessage + ) + await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) + } + + private func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { + let depth = max(0, min(request.depth, 2)) + guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty + else { + return RemoteFolderNode( + name: Host.current().localizedName ?? "Mac", + path: "", + isSelectable: false, + children: mobileFolderPickerRoots(depth: 1, includeHidden: request.includeHidden) + ) + } + + return try mobileFolderNode( + for: URL(fileURLWithPath: path).standardizedFileURL, + depth: depth, + includeHidden: request.includeHidden + ) + } + + private func mobileFolderPickerRoots(depth: Int, includeHidden: Bool) -> [RemoteFolderNode] { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL + var candidates = [ + home, + home.appendingPathComponent("Desktop", isDirectory: true), + home.appendingPathComponent("Documents", isDirectory: true), + home.appendingPathComponent("Downloads", isDirectory: true), + home.appendingPathComponent("Developer", isDirectory: true) + ] + + let projectParents = projects + .map { URL(fileURLWithPath: $0.path).deletingLastPathComponent().standardizedFileURL } + candidates.append(contentsOf: projectParents) + + var seen: Set = [] + return candidates.compactMap { url in + let path = url.path + guard seen.insert(path).inserted else { return nil } + return try? mobileFolderNode(for: url, depth: depth, includeHidden: includeHidden) + } + } + + private func mobileFolderNode( + for url: URL, + depth: Int, + includeHidden: Bool + ) throws -> RemoteFolderNode { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw MobileFolderPickerError.notFolder + } + + let name = url == fm.homeDirectoryForCurrentUser.standardizedFileURL + ? "Home" + : url.lastPathComponent + guard depth > 0 else { + return RemoteFolderNode(name: name, path: url.path, children: []) + } + + var options: FileManager.DirectoryEnumerationOptions = [] + if !includeHidden { options.insert(.skipsHiddenFiles) } + let contents = (try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey], + options: options + )) ?? [] + + let children = contents + .filter { child in + guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]), + values.isDirectory == true + else { return false } + if !includeHidden && (values.isHidden == true || child.lastPathComponent.hasPrefix(".")) { + return false + } + return !Self.mobileFolderIgnoredNames.contains(child.lastPathComponent) + } + .sorted { lhs, rhs in + lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending + } + .prefix(Self.mobileFolderMaxChildren) + .compactMap { child in + try? mobileFolderNode( + for: child.standardizedFileURL, + depth: depth - 1, + includeHidden: includeHidden + ) + } + + return RemoteFolderNode(name: name, path: url.path, children: Array(children)) + } + + private func mobileFolderErrorMessage(_ error: Error) -> String { + if let folderError = error as? MobileFolderPickerError { + return folderError.localizedDescription + } + return error.localizedDescription + } + + private static let mobileFolderMaxChildren = 250 + private static let mobileFolderIgnoredNames: Set = [ + ".git", ".build", ".swiftpm", "DerivedData", + "node_modules", ".DS_Store", "Pods", "xcuserdata" + ] + + private enum MobileFolderPickerError: LocalizedError { + case notFolder + + var errorDescription: String? { + switch self { + case .notFolder: + return "Folder does not exist on the desktop." + } + } + } + private func handleMobileCancelStream(_ cancel: CancelStreamPayload) async { // The mobile may hold a session id the CLI has since advanced // (pending-→real swap, or a compaction boundary). Follow the redirect diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 91f0e68..6792233 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -543,6 +543,20 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .folderTreeRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "folder_tree_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncFolderTreeRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .createProjectRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "create_project_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncCreateProjectRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .ping: guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -700,4 +714,6 @@ extension Notification.Name { static let mobileSyncQuestionAnswerReceived = Notification.Name("mobileSync.questionAnswerReceived") static let mobileSyncPlanDecisionReceived = Notification.Name("mobileSync.planDecisionReceived") static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") + static let mobileSyncFolderTreeRequested = Notification.Name("mobileSync.folderTreeRequested") + static let mobileSyncCreateProjectRequested = Notification.Name("mobileSync.createProjectRequested") } diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 7930041..e6363aa 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -127,7 +127,7 @@ struct ProjectChatRow: View { } else { Text(Self.compactElapsedTime(since: summary.updatedAt)) .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) + .foregroundStyle(ClaudeTheme.textSecondary) .monospacedDigit() .frame(width: 28, alignment: .trailing) } diff --git a/RxCode/Views/Sidebar/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index b80ea14..aabfce0 100644 --- a/RxCode/Views/Sidebar/ProjectListView.swift +++ b/RxCode/Views/Sidebar/ProjectListView.swift @@ -69,6 +69,7 @@ struct ProjectListView: View { HStack { Text("Projects") .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) Spacer() @@ -92,20 +93,23 @@ struct ProjectListView: View { // MARK: - Project Row private func projectRow(_ project: Project) -> some View { - HStack(spacing: 8) { + let isSelected = windowState.selectedProject?.id == project.id + + return HStack(spacing: 8) { Image(systemName: "folder.fill") - .foregroundStyle(.secondary) + .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textTertiary) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(project.name) .font(.body) + .foregroundStyle(ClaudeTheme.textPrimary) .lineLimit(1) } Text(truncatedPath(project.path)) .font(.caption) - .foregroundStyle(.tertiary) + .foregroundStyle(ClaudeTheme.textSecondary) .lineLimit(1) } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 0e6c447..81202c2 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -99,6 +99,15 @@ final class MobileAppState: ObservableObject { private var pendingSearchID: UUID? private var searchDebounceTask: Task? + @Published var remoteFolderRoot: RemoteFolderNode? + @Published var remoteFolderIsLoading = false + @Published var remoteFolderError: String? + @Published var remoteProjectCreateInFlight = false + @Published var remoteProjectCreateError: String? + @Published var lastCreatedProjectID: UUID? + private var pendingFolderTreeRequestID: UUID? + private var pendingCreateProjectRequestID: UUID? + private var identity: DeviceIdentity private var client: SyncClient private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") @@ -396,6 +405,38 @@ final class MobileAppState: ObservableObject { try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) } + func requestRemoteFolder(path: String? = nil) async { + guard isPaired else { return } + let request = FolderTreeRequestPayload(path: path, depth: 1) + pendingFolderTreeRequestID = request.clientRequestID + remoteFolderIsLoading = true + remoteFolderError = nil + do { + try await client.send(.folderTreeRequest(request), toHex: pairedDesktopPubkey) + } catch { + pendingFolderTreeRequestID = nil + remoteFolderIsLoading = false + remoteFolderError = error.localizedDescription + } + } + + func createProjectFromRemoteFolder(path: String) async { + guard isPaired else { return } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let request = CreateProjectRequestPayload(path: trimmed) + pendingCreateProjectRequestID = request.clientRequestID + remoteProjectCreateInFlight = true + remoteProjectCreateError = nil + do { + try await client.send(.createProjectRequest(request), toHex: pairedDesktopPubkey) + } catch { + pendingCreateProjectRequestID = nil + remoteProjectCreateInFlight = false + remoteProjectCreateError = error.localizedDescription + } + } + // MARK: - Plan mode /// Plan cards for a session, derived live from the synced messages using the @@ -777,6 +818,14 @@ final class MobileAppState: ObservableObject { sessionsWithMoreMessages = [] loadingMoreSessions = [] pendingLoadMoreRequests = [:] + remoteFolderRoot = nil + remoteFolderIsLoading = false + remoteFolderError = nil + remoteProjectCreateInFlight = false + remoteProjectCreateError = nil + pendingFolderTreeRequestID = nil + pendingCreateProjectRequestID = nil + lastCreatedProjectID = nil activeSessionID = nil pendingPermission = nil pendingQuestions = [] @@ -896,6 +945,32 @@ final class MobileAppState: ObservableObject { if !result.ok { lastBranchOpError = result.errorMessage ?? "Branch operation failed." } + case .folderTreeResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "folder_tree_result") else { return } + guard pendingFolderTreeRequestID == result.clientRequestID else { return } + pendingFolderTreeRequestID = nil + remoteFolderIsLoading = false + if result.ok, let root = result.root { + remoteFolderRoot = root + remoteFolderError = nil + } else { + remoteFolderError = result.errorMessage ?? "Failed to load folders." + } + case .createProjectResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "create_project_result") else { return } + guard pendingCreateProjectRequestID == result.clientRequestID else { return } + pendingCreateProjectRequestID = nil + remoteProjectCreateInFlight = false + if result.ok, let project = result.project { + if !projects.contains(where: { $0.id == project.id }) { + projects.append(project) + } + lastCreatedProjectID = project.id + remoteProjectCreateError = nil + Task { await self.requestSnapshot() } + } else { + remoteProjectCreateError = result.errorMessage ?? "Failed to add project." + } case .ping: guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 030c094..e13fd0c 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -632,14 +632,7 @@ struct MobileChatView: View { } .padding(.horizontal, 14) .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(ClaudeTheme.accentSubtle) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(ClaudeTheme.accent.opacity(0.35), lineWidth: 1) - ) + .glassEffect(.regular.tint(ClaudeTheme.accent).interactive(), in: .rect(cornerRadius: 14)) } .buttonStyle(.plain) .accessibilityLabel("\(planBannerText). Tap to review.") diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 9e18bf9..7ddcfbb 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -9,11 +9,26 @@ struct ProjectsSidebar: View { var showsBriefingItem = true var usesSelection = true @State private var searchText = "" + @State private var showingRemoteFolderPicker = false var body: some View { list .navigationTitle("Projects") .listStyle(.sidebar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingRemoteFolderPicker = true + } label: { + Image(systemName: "folder.badge.plus") + } + .accessibilityLabel("Add Project") + } + } + .sheet(isPresented: $showingRemoteFolderPicker) { + RemoteFolderPickerView() + .environmentObject(state) + } .refreshable { await state.refreshSnapshot() } @@ -30,6 +45,11 @@ struct ProjectsSidebar: View { .onDisappear { state.updateSearchQuery("") } + .onChange(of: state.lastCreatedProjectID) { _, newValue in + guard let newValue else { return } + selected = newValue + showingBriefing = false + } } @ViewBuilder diff --git a/RxCodeMobile/Views/RemoteFolderPickerView.swift b/RxCodeMobile/Views/RemoteFolderPickerView.swift new file mode 100644 index 0000000..9e173bb --- /dev/null +++ b/RxCodeMobile/Views/RemoteFolderPickerView.swift @@ -0,0 +1,211 @@ +import SwiftUI +import RxCodeSync + +struct RemoteFolderPickerView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var navigationPath: [FolderLocation] = [] + + private var currentNode: RemoteFolderNode? { + state.remoteFolderRoot + } + + private var canAddCurrentFolder: Bool { + guard let currentNode else { return false } + return currentNode.isSelectable && !currentNode.path.isEmpty + } + + var body: some View { + NavigationStack(path: $navigationPath) { + folderList + .navigationDestination(for: FolderLocation.self) { _ in + folderList + } + } + .task { + if state.remoteFolderRoot == nil { + await state.requestRemoteFolder() + } + } + .onChange(of: navigationPath) { _, newValue in + Task { await state.requestRemoteFolder(path: newValue.last?.path) } + } + .alert("Unable to Load Folder", isPresented: folderErrorBinding) { + Button("OK", role: .cancel) { state.remoteFolderError = nil } + } message: { + Text(state.remoteFolderError ?? "Unknown error.") + } + .alert("Unable to Add Project", isPresented: createErrorBinding) { + Button("OK", role: .cancel) { state.remoteProjectCreateError = nil } + } message: { + Text(state.remoteProjectCreateError ?? "Unknown error.") + } + .onChange(of: state.lastCreatedProjectID) { _, newValue in + if newValue != nil { + dismiss() + } + } + } + + private var folderList: some View { + List { + if let currentNode { + Section { + currentFolderRow(currentNode) + } + + Section("Folders") { + if currentNode.children.isEmpty, !state.remoteFolderIsLoading { + ContentUnavailableView( + "No Folders", + systemImage: "folder", + description: Text("This location has no visible folders.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(currentNode.children) { child in + Button { + open(child) + } label: { + folderRow(child) + } + .buttonStyle(.plain) + .disabled(state.remoteFolderIsLoading) + } + } + } + } else if state.remoteFolderIsLoading { + loadingRow + } else { + ContentUnavailableView( + "Folders Unavailable", + systemImage: "wifi.exclamationmark", + description: Text(state.remoteFolderError ?? "Connect to your Mac and try again.") + ) + .listRowBackground(Color.clear) + } + } + .navigationTitle("Add Project") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if state.remoteFolderIsLoading { + ProgressView() + .controlSize(.large) + } + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + addCurrentFolder() + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Project") + .disabled(!canAddCurrentFolder || state.remoteProjectCreateInFlight) + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + + ToolbarItem(placement: .bottomBar) { + if state.remoteProjectCreateInFlight { + ProgressView() + } + } + } + } + + private var loadingRow: some View { + HStack(spacing: 10) { + ProgressView() + Text("Loading folders") + .foregroundStyle(.secondary) + } + } + + private func currentFolderRow(_ node: RemoteFolderNode) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(node.name, systemImage: node.path.isEmpty ? "macbook" : "folder.fill") + .font(.headline) + + if !node.path.isEmpty { + Text(node.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + private func folderRow(_ node: RemoteFolderNode) -> some View { + HStack(spacing: 10) { + Image(systemName: "folder.fill") + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(node.name) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(node.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + + private var folderErrorBinding: Binding { + Binding( + get: { state.remoteFolderError != nil }, + set: { if !$0 { state.remoteFolderError = nil } } + ) + } + + private var createErrorBinding: Binding { + Binding( + get: { state.remoteProjectCreateError != nil }, + set: { if !$0 { state.remoteProjectCreateError = nil } } + ) + } + + private func open(_ node: RemoteFolderNode) { + navigationPath = folderNavigationPath(for: node.path) + } + + private func addCurrentFolder() { + guard let currentNode, canAddCurrentFolder else { return } + Task { await state.createProjectFromRemoteFolder(path: currentNode.path) } + } + + private func folderNavigationPath(for path: String) -> [FolderLocation] { + var locations: [FolderLocation] = [] + var url = URL(fileURLWithPath: path).standardizedFileURL + + while true { + locations.append(FolderLocation(path: url.path)) + + let parent = url.deletingLastPathComponent().standardizedFileURL + guard parent.path != url.path else { break } + url = parent + } + + return locations.reversed() + } +} + +private struct FolderLocation: Hashable { + let path: String +}