From f4cf71acf024a021fdc8c588fb20d321115acfc6 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:19:37 +0800 Subject: [PATCH] fix: cache Claude Code OAuth tokens; add mobile global search Net branch work on top of main (PR #77 already ported iOS autopilot): - cache Claude Code OAuth tokens in memory to stop repeat keychain prompts - mobile global search (searchThreadsAndDocs relay op + MobileSearchView) - briefing chip flow-wrapping; move iPhone search to dedicated tab - localization strings for the above Squashed from: - fix: search on mobile - fix: cache Claude Code OAuth tokens in memory to stop repeat keychain prompts - fix: on-device autopilot menu actions, visible menu button, branch picker - feat: add Create Pull Request action to iOS briefing detail - fix: project-list context menu now appears on iOS - feat: drop docs-search context item; show create-release form on iOS - feat: mirror iOS autopilot settings to Android - fix: claude code todos - feat: mirror autopilot project/briefing context menu to iOS - feat: add iOS autopilot support Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Protocol/Payload+Autopilot.swift | 46 ++ .../Protocol/Payload+Sessions.swift | 13 +- .../Sources/RxCodeSync/Protocol/Payload.swift | 14 +- RxCode/App/AppState+MobileAutopilot.swift | 11 + RxCode/App/AppState+MobileSnapshots.swift | 50 ++- RxCode/App/AppState+MobileSync.swift | 10 +- RxCode/Services/RateLimitService.swift | 40 +- RxCodeMobile/Resources/Localizable.xcstrings | 24 + .../State/MobileAppState+Autopilot.swift | 12 + .../State/MobileAppState+Inbound.swift | 1 + .../State/MobileAppState+Intents.swift | 13 +- RxCodeMobile/State/MobileAppState+Sync.swift | 28 +- RxCodeMobile/State/MobileAppState.swift | 3 + .../Views/MobileBriefingComponents.swift | 32 +- .../Views/MobileBriefingDetailView.swift | 6 +- RxCodeMobile/Views/MobileSearchView.swift | 422 ++++++++++++++++++ RxCodeMobile/Views/NewThreadSheet.swift | 45 +- RxCodeMobile/Views/ProjectsSidebar.swift | 3 +- RxCodeMobile/Views/RootView.swift | 108 +++-- RxCodeMobile/Views/SessionsList.swift | 86 +++- 20 files changed, 857 insertions(+), 110 deletions(-) create mode 100644 RxCodeMobile/Views/MobileSearchView.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index ae7e6e2b..a5d8069d 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -27,6 +27,7 @@ public enum AutopilotDomain: String, Codable, Sendable { case docs case release case project + case search } /// Every autopilot operation, globally unique so the desktop can switch on it @@ -111,6 +112,11 @@ public enum AutopilotOp: String, Codable, Sendable { case projectSecretsDownload case projectSecretsWrite case projectCreatePullRequest + + // Global search — one call returns on-device thread matches AND published + // docs matches for the same query, so mobile gets a single combined result + // instead of stitching two responses together. + case searchThreadsAndDocs } /// Mobile → desktop: a single autopilot operation. `body` is a JSON-encoded @@ -557,3 +563,43 @@ public struct AutopilotProjectSecretsDownloadResult: Codable, Sendable { self.conflicts = conflicts } } + +// MARK: - Global search + +/// Mobile → desktop: a single query to run across both on-device threads and +/// published docs. The desktop owns both corpora (its local thread index and +/// the rxlab docs service), so one round trip returns everything. +public struct AutopilotSearchBody: Codable, Sendable { + public let query: String + /// Max hits per source (threads and docs each). Defaults to a sane page on + /// the desktop when nil. + public let limit: Int? + + public init(query: String, limit: Int? = nil) { + self.query = query + self.limit = limit + } +} + +/// Desktop → mobile: combined search results for one query. Thread hits come +/// from the desktop's on-device index; doc hits from the rxlab docs service. +/// `docHits` is best-effort — a signed-out desktop or a docs-service error +/// yields an empty array rather than failing the whole search. +public struct AutopilotSearchResult: Codable, Sendable { + public let query: String + public let projectIDs: [UUID] + public let threadHits: [SearchHit] + public let docHits: [DocsSearchHit] + + public init( + query: String, + projectIDs: [UUID], + threadHits: [SearchHit], + docHits: [DocsSearchHit] + ) { + self.query = query + self.projectIDs = projectIDs + self.threadHits = threadHits + self.docHits = docHits + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift index 17413e23..95d2f7ee 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift @@ -344,11 +344,22 @@ public struct SearchResultsPayload: Codable, Sendable { public let query: String public let projectIDs: [UUID] public let threadHits: [SearchHit] - public init(clientRequestID: UUID, query: String, projectIDs: [UUID], threadHits: [SearchHit]) { + /// Published-docs matches for the same query. Optional for wire-compatibility + /// with desktops that predate mobile docs search — older builds omit the key + /// and mobile decodes it as `nil` (rendered as no docs results). + public let docHits: [DocsSearchHit]? + public init( + clientRequestID: UUID, + query: String, + projectIDs: [UUID], + threadHits: [SearchHit], + docHits: [DocsSearchHit]? = nil + ) { self.clientRequestID = clientRequestID self.query = query self.projectIDs = projectIDs self.threadHits = threadHits + self.docHits = docHits } } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 97163e7c..f1cea31f 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -496,12 +496,24 @@ public struct BranchOpRequestPayload: Codable, Sendable { public let projectID: UUID public let operation: Operation public let branch: String + /// For `.createNew`: when true (or absent, for back-compat with older + /// clients), create the branch in an isolated Git worktree. When false, + /// create the branch and check it out in the project root instead. + /// Ignored for other operations. + public let useWorktree: Bool? - public init(clientRequestID: UUID = UUID(), projectID: UUID, operation: Operation, branch: String) { + public init( + clientRequestID: UUID = UUID(), + projectID: UUID, + operation: Operation, + branch: String, + useWorktree: Bool? = nil + ) { self.clientRequestID = clientRequestID self.projectID = projectID self.operation = operation self.branch = branch + self.useWorktree = useWorktree } } diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 9abc8ef4..7c43c6c1 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -347,6 +347,17 @@ extension AppState { .filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) } let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite) return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts)) + + // MARK: Global search + case .searchThreadsAndDocs: + let body = try decodeAutopilotBody(request, as: AutopilotSearchBody.self) + let result = await combinedThreadAndDocsSearch(query: body.query, limit: body.limit ?? 25) + return try encoder.encode(AutopilotSearchResult( + query: body.query, + projectIDs: result.projectIDs, + threadHits: result.threadHits, + docHits: result.docHits + )) } } diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 22a3c8e6..68d00fa2 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -34,23 +34,37 @@ extension AppState { } func handleMobileSearchRequest(_ request: SearchRequestPayload, fromHex hex: String) async { - let trimmed = request.query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - let empty = SearchResultsPayload( - clientRequestID: request.clientRequestID, - query: request.query, - projectIDs: [], - threadHits: [] - ) - await MobileSyncService.shared.send(.searchResults(empty), toHex: hex) - return - } + let result = await combinedThreadAndDocsSearch(query: request.query, limit: request.limit) + let payload = SearchResultsPayload( + clientRequestID: request.clientRequestID, + query: request.query, + projectIDs: result.projectIDs, + threadHits: result.threadHits, + docHits: result.docHits + ) + await MobileSyncService.shared.send(.searchResults(payload), toHex: hex) + } + /// Runs one query across both corpora the desktop owns: its on-device thread + /// index (semantic + title match) and the rxlab docs service. Shared by the + /// legacy `searchResults` relay and the `searchThreadsAndDocs` autopilot RPC + /// so both stay in lockstep. Docs are best-effort — a signed-out desktop or a + /// docs-service error collapses to an empty docs list and never fails the + /// thread search. Mirrors the desktop overlay's global docs search + /// (GlobalSearchOverlay). + func combinedThreadAndDocsSearch( + query: String, + limit: Int + ) async -> (projectIDs: [UUID], threadHits: [SearchHit], docHits: [DocsSearchHit]) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return ([], [], []) } + + let cappedLimit = max(limit, 1) let knownProjectIDs = Set(projects.map(\.id)) let summaries = allSessionSummaries.filter { knownProjectIDs.contains($0.projectId) } let summaryByID = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) }) - let semantic = await searchService.search(trimmed, limit: max(request.limit, 1)) + let semantic = await searchService.search(trimmed, limit: cappedLimit) var threadHitByID: [String: SearchHit] = [:] for group in semantic { @@ -88,7 +102,7 @@ extension AppState { if lhs.score != rhs.score { return lhs.score > rhs.score } return lhs.updatedAt > rhs.updatedAt } - .prefix(max(request.limit, 1)) + .prefix(cappedLimit) let projectIDs = projects .filter { project in @@ -97,13 +111,9 @@ extension AppState { } .map(\.id) - let payload = SearchResultsPayload( - clientRequestID: request.clientRequestID, - query: request.query, - projectIDs: projectIDs, - threadHits: Array(threadHits) - ) - await MobileSyncService.shared.send(.searchResults(payload), toHex: hex) + let docHits = (try? await docs.search(query: trimmed, repo: nil, limit: cappedLimit)) ?? [] + + return (projectIDs, Array(threadHits), docHits) } func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 5e5f304e..2dc2e0d6 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -574,7 +574,15 @@ extension AppState { try await attachWorktreeForExistingBranch(trimmed, in: window) updateMobilePendingWorktree(from: window, projectID: project.id) case .createNew: - try await attachWorktree(branch: trimmed, in: window) + // Honor the mobile picker: worktree (default) keeps the main + // checkout untouched; checkout creates the branch in the + // project root and clears any parked worktree so the next + // thread spawns there. + if request.useWorktree ?? true { + try await attachWorktree(branch: trimmed, in: window) + } else { + try await createBranchInPlace(branch: trimmed, in: window) + } updateMobilePendingWorktree(from: window, projectID: project.id) case .initGit: break // handled above diff --git a/RxCode/Services/RateLimitService.swift b/RxCode/Services/RateLimitService.swift index bf156518..58a63cb2 100644 --- a/RxCode/Services/RateLimitService.swift +++ b/RxCode/Services/RateLimitService.swift @@ -20,6 +20,17 @@ actor RateLimitService { private let cacheTTL: TimeInterval = 300 // 5 minutes private var authFailed = false + /// In-memory copy of the Claude Code OAuth tokens. The + /// `Claude Code-credentials` Keychain item is owned by the *Claude Code* + /// app, not RxCode, so every read of it pops the macOS "wants to use + /// confidential information" prompt once RxCode is re-signed (its signature + /// is never on that item's ACL). Reading it once per launch and serving the + /// rest from memory keeps that prompt to a single appearance. The network + /// refresh updates this copy in place (we never write back to the Keychain), + /// and it's cleared only when auth fails — so a credential the user freshly + /// logged into Claude Code with is still picked up on the next poll. + private var cachedTokens: OAuthTokens? + func fetchUsage(forceRefresh: Bool = false) async -> RateLimitUsage? { if !forceRefresh, let c = cached, let at = cachedAt, Date().timeIntervalSince(at) < cacheTTL { return c @@ -55,6 +66,7 @@ actor RateLimitService { } else { logger.debug("[RateLimit] Token refresh failed, cannot fetch usage") authFailed = true + cachedTokens = nil return cached } } else { @@ -78,6 +90,11 @@ actor RateLimitService { // MARK: - Keychain private func readOAuthTokens() async -> OAuthTokens? { + // Serve from memory whenever possible — see `cachedTokens`. Only the + // first miss (or the first poll after an auth failure cleared it) + // touches the foreign Keychain item that triggers the prompt. + if let cachedTokens { return cachedTokens } + guard let raw = await MainActor.run(body: { KeychainHelper.readString(service: "Claude Code-credentials") }) else { return nil } @@ -87,7 +104,9 @@ actor RateLimitService { else { return nil } let refreshToken = oauth["refreshToken"] as? String - return OAuthTokens(accessToken: accessToken, refreshToken: refreshToken, rawOauth: oauth) + let tokens = OAuthTokens(accessToken: accessToken, refreshToken: refreshToken, rawOauth: oauth) + cachedTokens = tokens + return tokens } private func isExpired(_ oauth: [String: Any]) -> Bool { @@ -142,7 +161,23 @@ actor RateLimitService { } logger.info("[RateLimit] Token refreshed successfully") - // Skip Keychain write since account is unknown — use in-memory cache only + // Skip Keychain write since account is unknown — update the + // in-memory copy instead. Refresh the stored `expiresAt` from the + // response's `expires_in` (seconds) so the next poll doesn't treat + // the just-refreshed token as expired and refresh again; drop it if + // the server didn't say, which makes `isExpired` return false. + var updatedOauth = tokens.rawOauth + if let expiresIn = json["expires_in"] as? Double { + updatedOauth["expiresAt"] = (Date().timeIntervalSince1970 + expiresIn) * 1000 + } else { + updatedOauth.removeValue(forKey: "expiresAt") + } + let newRefreshToken = (json["refresh_token"] as? String) ?? tokens.refreshToken + cachedTokens = OAuthTokens( + accessToken: newAccessToken, + refreshToken: newRefreshToken, + rawOauth: updatedOauth + ) return newAccessToken } catch { logger.debug("[RateLimit] Token refresh error: \(error.localizedDescription)") @@ -167,6 +202,7 @@ actor RateLimitService { if code == 401 { logger.debug("[RateLimit] API returned 401 — token invalid") authFailed = true + cachedTokens = nil } else { logger.warning("[RateLimit] API returned status \(code)") } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 76fc6c7c..07f42087 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -778,6 +778,9 @@ } } } + }, + "Branch + checkout" : { + }, "Branch briefings collect recent thread summaries from your Mac into a quick mobile status view." : { "localizations" : { @@ -1662,6 +1665,12 @@ } } } + }, + "Creates an isolated worktree so this thread works without touching the main checkout." : { + + }, + "Creates the branch and checks it out in the project root, changing the main checkout." : { + }, "Creating Pull Request…" : { @@ -3540,6 +3549,9 @@ } } } + }, + "Mode" : { + }, "Model" : { "localizations" : { @@ -3634,6 +3646,9 @@ } } } + }, + "New worktree" : { + }, "Next change" : { "localizations" : { @@ -5258,6 +5273,9 @@ } } } + }, + "Scope" : { + }, "Script" : { "localizations" : { @@ -5284,6 +5302,9 @@ } } } + }, + "Search" : { + }, "Search or enter website name" : { "localizations" : { @@ -5326,6 +5347,9 @@ } } } + }, + "Search threads and docs" : { + }, "Searching…" : { "localizations" : { diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 10bec770..69734f0e 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -327,6 +327,18 @@ extension MobileAppState { try await autopilotSendVoid(.docs, .docsDeleteDocument, body: AutopilotDocsDocBody(repoId: repoId, docId: docId)) } + // MARK: - Global search + + /// One round trip that returns both on-device thread matches and published + /// docs matches for `query`. The desktop owns both corpora, so this single + /// request/response RPC replaces stitching two separate relay messages + /// together. + func searchThreadsAndDocs(query: String, limit: Int = 25) async throws -> AutopilotSearchResult { + try await autopilotSend(.search, .searchThreadsAndDocs, + body: AutopilotSearchBody(query: query, limit: limit), + as: AutopilotSearchResult.self) + } + @discardableResult func createDocsUploadToken(repoId: String, name: String?) async throws -> DocsUploadToken { try await autopilotSend(.docs, .docsCreateUploadToken, body: AutopilotDocsCreateTokenBody(repoId: repoId, name: name), as: DocsUploadToken.self) diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index a4f6a289..03b6b592 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -168,6 +168,7 @@ extension MobileAppState { guard let pending = pendingSearchID, results.clientRequestID == pending else { return } searchProjectIDs = results.projectIDs searchThreadHits = results.threadHits + searchDocHits = results.docHits ?? [] isSearching = false case .threadChangesResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "thread_changes_result") else { return } diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift index f4698b93..ff76101a 100644 --- a/RxCodeMobile/State/MobileAppState+Intents.swift +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -316,15 +316,18 @@ extension MobileAppState { try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) } - /// Tell the desktop to create a new branch + worktree off the current - /// branch. The desktop parks the worktree against the project so the next - /// new-thread request for this project spawns into it. - func createProjectBranch(projectID: UUID, branch: String) async { + /// Tell the desktop to create a new branch off the current branch. When + /// `useWorktree` is true (the default), the desktop creates an isolated Git + /// worktree and parks it against the project so the next new-thread request + /// spawns into it. When false, the desktop creates the branch and checks it + /// out in the project root instead. + func createProjectBranch(projectID: UUID, branch: String, useWorktree: Bool = true) async { guard isPaired else { return } let request = BranchOpRequestPayload( projectID: projectID, operation: .createNew, - branch: branch + branch: branch, + useWorktree: useWorktree ) inFlightBranchOps.insert(request.clientRequestID) try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index 369be11b..d0d9cb86 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -307,9 +307,12 @@ extension MobileAppState { } } - /// Update the search query and dispatch a debounced search request to the - /// paired desktop. Empty queries clear results without hitting the network. - /// Stale requests are discarded by `clientRequestID`. + /// Update the search query and dispatch a debounced combined search to the + /// paired desktop. The desktop runs threads + docs in one round trip + /// (`searchThreadsAndDocs` RPC) and returns both, so a single awaitable call + /// populates every result list. Empty queries clear results without hitting + /// the network; stale responses for superseded queries are discarded by + /// comparing the in-flight `pendingSearchID`. func updateSearchQuery(_ query: String) { searchQuery = query let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) @@ -319,12 +322,14 @@ extension MobileAppState { isSearching = false searchProjectIDs = [] searchThreadHits = [] + searchDocHits = [] return } guard isPaired else { isSearching = false searchProjectIDs = [] searchThreadHits = [] + searchDocHits = [] return } let id = UUID() @@ -334,8 +339,21 @@ extension MobileAppState { try? await Task.sleep(nanoseconds: 200_000_000) guard !Task.isCancelled, let self else { return } guard self.pendingSearchID == id else { return } - let payload = SearchRequestPayload(clientRequestID: id, query: trimmed, limit: 25) - try? await self.client.send(.searchRequest(payload), toHex: self.pairedDesktopPubkey) + do { + let result = try await self.searchThreadsAndDocs(query: trimmed, limit: 25) + // Ignore a response whose query the user has already moved past. + guard self.pendingSearchID == id else { return } + self.searchProjectIDs = result.projectIDs + self.searchThreadHits = result.threadHits + self.searchDocHits = result.docHits + self.isSearching = false + } catch { + guard self.pendingSearchID == id else { return } + self.searchProjectIDs = [] + self.searchThreadHits = [] + self.searchDocHits = [] + self.isSearching = false + } } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index e685ba74..4158a269 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -190,6 +190,9 @@ final class MobileAppState: ObservableObject { @Published var searchQuery: String = "" @Published var searchProjectIDs: [UUID] = [] @Published var searchThreadHits: [SearchHit] = [] + /// Published-docs matches for the current query, mirrored from the desktop. + /// Empty when the query is cleared or the desktop returned no docs. + @Published var searchDocHits: [DocsSearchHit] = [] @Published var isSearching: Bool = false /// Whether the mobile app has received its first snapshot from the desktop /// since launch or pairing. Used to show a loading state instead of diff --git a/RxCodeMobile/Views/MobileBriefingComponents.swift b/RxCodeMobile/Views/MobileBriefingComponents.swift index 24a4c8de..71f230b5 100644 --- a/RxCodeMobile/Views/MobileBriefingComponents.swift +++ b/RxCodeMobile/Views/MobileBriefingComponents.swift @@ -25,26 +25,48 @@ struct BriefingFlowLayout: Layout { } private func layout(sizes: [CGSize], containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { - var offsets: [CGPoint] = [] + // First pass: assign each subview to a line, tracking each line's + // height so we can vertically center items within their row. + struct Item { var x: CGFloat; var line: Int; var height: CGFloat } + var items: [Item] = [] + var lineHeights: [CGFloat] = [] var currentX: CGFloat = 0 - var currentY: CGFloat = 0 + var line = 0 var lineHeight: CGFloat = 0 var maxWidth: CGFloat = 0 for size in sizes { if currentX + size.width > containerWidth && currentX > 0 { + lineHeights.append(lineHeight) + line += 1 currentX = 0 - currentY += lineHeight + spacing lineHeight = 0 } - offsets.append(CGPoint(x: currentX, y: currentY)) + items.append(Item(x: currentX, line: line, height: size.height)) lineHeight = max(lineHeight, size.height) currentX += size.width + spacing maxWidth = max(maxWidth, currentX - spacing) } + lineHeights.append(lineHeight) + + // Second pass: resolve each line's Y origin, then center items vertically. + var lineY: [CGFloat] = [] + var y: CGFloat = 0 + for height in lineHeights { + lineY.append(y) + y += height + spacing + } + + let offsets = items.map { item in + CGPoint( + x: item.x, + y: lineY[item.line] + (lineHeights[item.line] - item.height) / 2 + ) + } - return (offsets, CGSize(width: maxWidth, height: currentY + lineHeight)) + let totalHeight = lineY.last.map { $0 + lineHeights[lineHeights.count - 1] } ?? 0 + return (offsets, CGSize(width: maxWidth, height: totalHeight)) } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index b2bc3cc2..cd401965 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -228,7 +228,9 @@ struct MobileBriefingDetailView: View { .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 12) { + // Chips wrap to the next line when the branch name is long + // instead of overflowing the card width. + BriefingFlowLayout(spacing: 12) { // Branch chip — falls back to an Init Git action when the // desktop hasn't initialized a repo (branch is "unknown"). if isUnknownBranch { @@ -258,6 +260,8 @@ struct MobileBriefingDetailView: View { .font(.system(size: 10, weight: .medium)) Text(groupKey.branch) .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) } .foregroundStyle(.secondary) .padding(.horizontal, 8) diff --git a/RxCodeMobile/Views/MobileSearchView.swift b/RxCodeMobile/Views/MobileSearchView.swift new file mode 100644 index 00000000..1014158f --- /dev/null +++ b/RxCodeMobile/Views/MobileSearchView.swift @@ -0,0 +1,422 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Adds a search button to the bottom toolbar that presents a full-screen +/// search view when tapped. The full-screen view provides the native iOS/iPadOS +/// search experience with scope filtering (All / Threads / Docs). +/// +/// Thread hits reuse the existing search relay (`SearchRequestPayload` → +/// desktop → `SearchResultsPayload`) surfaced as `state.searchThreadHits`. +/// Docs hits ride the same response (`state.searchDocHits`) — mobile never +/// calls the docs API directly. Thread hits push the chat via the enclosing +/// `NavigationStack`; docs open their rendered markdown in a sheet, fetched +/// from the paired desktop on demand. +struct GlobalSearchModifier: ViewModifier { + @State private var showSearchView = false + + func body(content: Content) -> some View { + content + .safeAreaInset(edge: .bottom, spacing: 0) { + Button { + showSearchView = true + } label: { + HStack { + Image(systemName: "magnifyingglass") + Text("Search threads and docs") + .font(.subheadline) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.bar) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .fullScreenCover(isPresented: $showSearchView) { + FullScreenSearchView(isPresented: $showSearchView) + } + } +} + +/// Full-screen search view presented when the search button is tapped. +/// Contains a search field, scope picker, and results list with a close button. +struct FullScreenSearchView: View { + @EnvironmentObject private var state: MobileAppState + @Binding var isPresented: Bool + @State private var searchText = "" + @State private var scope: MobileSearchScope = .all + @FocusState private var isSearchFocused: Bool + + private var isActive: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Scope picker + Picker("Scope", selection: $scope) { + ForEach(MobileSearchScope.allCases) { item in + Text(scopeLabel(item)).tag(item) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + // Results + if isActive { + MobileSearchResultsView(searchText: searchText, scope: scope) + } else { + ContentUnavailableView { + Label("Search", systemImage: "magnifyingglass") + } description: { + Text("Search threads and docs") + } + .frame(maxHeight: .infinity) + } + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search threads and docs") + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + isPresented = false + } + } + } + .onChange(of: searchText) { _, newValue in + state.updateSearchQuery(newValue) + } + .onDisappear { + state.updateSearchQuery("") + } + } + } + + /// Scope-bar label. Appends the live match count once a search has settled + /// so the native segmented control mirrors the desktop's All / Threads / + /// Docs counts. + private func scopeLabel(_ item: MobileSearchScope) -> String { + guard isActive, !state.isSearching else { return item.title } + let threads = state.searchThreadHits.count + let docs = state.searchDocHits.count + let count: Int + switch item { + case .all: count = threads + docs + case .threads: count = threads + case .docs: count = docs + } + return "\(item.title) (\(count))" + } +} + +/// Search content view for the search tab. Displays scope picker and results. +/// The searchable modifier is applied by the parent view. +struct MobileSearchContentView: View { + @EnvironmentObject private var state: MobileAppState + @Binding var searchText: String + @State private var scope: MobileSearchScope = .all + + private var isActive: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + // Scope picker + Picker("Scope", selection: $scope) { + ForEach(MobileSearchScope.allCases) { item in + Text(scopeLabel(item)).tag(item) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + // Results + if isActive { + MobileSearchResultsView(searchText: searchText, scope: scope) + } else { + ContentUnavailableView { + Label("Search", systemImage: "magnifyingglass") + } description: { + Text("Search threads and docs") + } + .frame(maxHeight: .infinity) + } + } + .background(Color(.systemGroupedBackground)) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, newValue in + state.updateSearchQuery(newValue) + } + .onDisappear { + state.updateSearchQuery("") + } + } + + private func scopeLabel(_ item: MobileSearchScope) -> String { + guard isActive, !state.isSearching else { return item.title } + let threads = state.searchThreadHits.count + let docs = state.searchDocHits.count + let count: Int + switch item { + case .all: count = threads + docs + case .threads: count = threads + case .docs: count = docs + } + return "\(item.title) (\(count))" + } +} + +/// The native search-scope categories shared by the `.searchable` scope bar and +/// the inline results view. +enum MobileSearchScope: String, CaseIterable, Identifiable { + case all + case threads + case docs + + var id: String { rawValue } + var title: String { + switch self { + case .all: return "All" + case .threads: return "Threads" + case .docs: return "Docs" + } + } +} + +extension View { + /// Attach the global thread + docs search (minimized search button + + /// inline results) to this view. See `GlobalSearchModifier`. + func globalThreadDocSearch() -> some View { + modifier(GlobalSearchModifier()) + } +} + +/// Inline search results for the active scope. Reads the live results mirrored +/// on `MobileAppState`; the enclosing `GlobalSearchModifier` owns the query +/// field and the native All / Threads / Docs scope bar. +struct MobileSearchResultsView: View { + @EnvironmentObject private var state: MobileAppState + let searchText: String + /// Active category from the native `.searchScopes` scope bar, owned by the + /// enclosing `GlobalSearchModifier`. + let scope: MobileSearchScope + @State private var presentedDoc: DocReference? + @Namespace private var glassNamespace + + /// Identifies a docs hit to open, driving `.sheet(item:)`. `repo` is the + /// `owner/repo` the desktop's docs service resolves the document under. + private struct DocReference: Identifiable { + let repo: String + let docId: String + var id: String { "\(repo):\(docId)" } + } + + var body: some View { + results + .sheet(item: $presentedDoc) { doc in + MobileDocumentView(repoId: doc.repo, docId: doc.docId) + .environmentObject(state) + .mobileSheetPresentation() + } + } + + // MARK: - Results + + private var showThreads: Bool { scope == .all || scope == .threads } + private var showDocs: Bool { scope == .all || scope == .docs } + + private var threadHits: [SearchHit] { state.searchThreadHits } + private var docHits: [DocsSearchHit] { state.searchDocHits } + + private var projectsByID: [UUID: Project] { + Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) + } + + private var visibleIsEmpty: Bool { + let threadsEmpty = !showThreads || threadHits.isEmpty + let docsEmpty = !showDocs || docHits.isEmpty + return threadsEmpty && docsEmpty + } + + @ViewBuilder + private var results: some View { + if state.isSearching && visibleIsEmpty { + VStack(spacing: 10) { + ProgressView() + Text("Searching…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if visibleIsEmpty { + ContentUnavailableView.search(text: searchText) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if showThreads, !threadHits.isEmpty { + threadsSection + } + if showDocs, !docHits.isEmpty { + docsSection + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .scrollDismissesKeyboard(.interactively) + } + } + + private var threadsSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader("Threads", systemImage: "bubble.left.and.bubble.right", count: threadHits.count) + GlassEffectContainer(spacing: 10) { + ForEach(threadHits) { hit in + SearchThreadHitCard( + hit: hit, + project: projectsByID[hit.projectID], + namespace: glassNamespace + ) + } + } + } + } + + private var docsSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader("Docs", systemImage: "doc.text", count: docHits.count) + GlassEffectContainer(spacing: 10) { + ForEach(docHits) { hit in + SearchDocHitCard( + hit: hit, + isEnabled: hit.repositoryFullName != nil, + namespace: glassNamespace + ) { + guard let repo = hit.repositoryFullName else { return } + presentedDoc = DocReference(repo: repo, docId: hit.docId) + } + } + } + } + } + + private func sectionHeader(_ title: String, systemImage: String, count: Int) -> some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + Text("\(count)") + .font(.caption.weight(.medium).monospacedDigit()) + .foregroundStyle(.tertiary) + } + .padding(.leading, 4) + } +} + +// MARK: - Doc result card + +/// One published-docs match. Mirrors `SearchThreadHitCard` but, since docs are +/// not navigable threads on device, taps invoke `onOpen` (which presents the +/// document's rendered markdown in a sheet) rather than pushing the stack. +struct SearchDocHitCard: View { + let hit: DocsSearchHit + var isEnabled: Bool = true + let namespace: Namespace.ID + let onOpen: () -> Void + + private var title: String { + hit.docId.isEmpty ? (hit.repositoryFullName ?? "Document") : hit.docId + } + + private var hasSnippet: Bool { + guard let snippet = hit.snippet else { return false } + return !snippet.isEmpty && snippet != title + } + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 12) { + Image(systemName: "doc.text.fill") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + if hasSnippet, let snippet = hit.snippet { + Text(snippet) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let repo = hit.repositoryFullName { + Text(repo) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(GlassDocCardButtonStyle(isEnabled: isEnabled)) + .glassEffectID("search-doc-\(hit.id)", in: namespace) + .disabled(!isEnabled) + .accessibilityIdentifier("search-doc-\(hit.id)") + } +} +// MARK: - Glass Doc Card Button Style + +private struct GlassDocCardButtonStyle: ButtonStyle { + let isEnabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(backgroundColor(isPressed: configuration.isPressed)) + } + .glassEffect( + Glass.regular.interactive(), + in: .rect(cornerRadius: 16) + ) + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.spring(duration: 0.2), value: configuration.isPressed) + .opacity(isEnabled ? 1 : 0.6) + } + + private func backgroundColor(isPressed: Bool) -> Color { + if isPressed { + return Color.primary.opacity(0.05) + } + return .clear + } +} + diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 350ea0e6..f3509348 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -555,12 +555,40 @@ struct NewThreadConfigStrip: View { // MARK: - Create Branch Sheet struct CreateBranchSheet: View { + /// How a new branch gets materialized on the desktop. Mirrors the desktop's + /// `CreateBranchSheet.BranchMode`. + enum BranchMode: String, CaseIterable, Identifiable { + /// `git worktree add -b` — isolated checkout in a sibling directory. + case worktree + /// `git checkout -b` in the project root — mutates the main repo. + case checkout + + var id: String { rawValue } + + var label: LocalizedStringResource { + switch self { + case .worktree: "New worktree" + case .checkout: "Branch + checkout" + } + } + + var hint: LocalizedStringResource { + switch self { + case .worktree: + "Creates an isolated worktree so this thread works without touching the main checkout." + case .checkout: + "Creates the branch and checks it out in the project root, changing the main checkout." + } + } + } + @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss let projectID: UUID let baseBranch: String? @State private var branchText: String = "rxcode/" + @State private var mode: BranchMode = .worktree @State private var isSubmitting = false @FocusState private var isFocused: Bool @@ -600,6 +628,20 @@ struct CreateBranchSheet: View { if let error = validationError, !trimmed.isEmpty, trimmed != "rxcode/" { Section { Text(error).foregroundStyle(.secondary) } } + + Section { + Picker("Mode", selection: $mode) { + ForEach(BranchMode.allCases) { m in + Text(m.label).tag(m) + } + } + .pickerStyle(.menu) + .disabled(isSubmitting) + } header: { + Text("Mode") + } footer: { + Text(mode.hint) + } } .navigationTitle("Create branch") .navigationBarTitleDisplayMode(.inline) @@ -633,8 +675,9 @@ struct CreateBranchSheet: View { guard validationError == nil else { return } isSubmitting = true let name = trimmed + let useWorktree = mode == .worktree Task { - await state.createProjectBranch(projectID: projectID, branch: name) + await state.createProjectBranch(projectID: projectID, branch: name, useWorktree: useWorktree) isSubmitting = false dismiss() } diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 55d54b97..a83500f1 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -246,7 +246,8 @@ struct ProjectsSidebar: View { // MARK: - Helpers private var showsSearch: Bool { - !usesSelection + // Search is now handled by the dedicated search tab on iPhone + false } private func threadCount(for projectID: UUID) -> Int { diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index e38c4efe..381e0878 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -1,7 +1,7 @@ -import SwiftUI +import os.log import RxCodeCore import RxCodeSync -import os.log +import SwiftUI private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "RootView") @@ -9,6 +9,7 @@ private enum MobileRootTab: Hashable { case briefing case projects case settings + case search } /// Mobile app root. iPad / wide screens use NavigationSplitView; iPhone uses @@ -62,7 +63,7 @@ struct RootView: View { /// Whether the loading splash should be dismissed (data loaded AND minimum time elapsed, but NOT timed out) private var shouldShowContent: Bool { let result = state.hasReceivedInitialSnapshot && minimumLoadingTimeElapsed && !connectionTimedOut - logger.debug("shouldShowContent: \(result) (hasSnapshot: \(self.state.hasReceivedInitialSnapshot), minTimeElapsed: \(self.minimumLoadingTimeElapsed), timedOut: \(self.connectionTimedOut))") + logger.debug("shouldShowContent: \(result) (hasSnapshot: \(state.hasReceivedInitialSnapshot), minTimeElapsed: \(minimumLoadingTimeElapsed), timedOut: \(connectionTimedOut))") return result } @@ -124,18 +125,18 @@ struct RootView: View { /// Performs initial load with timeout handling private func initialLoad() async { - logger.info("initialLoad started, current hasReceivedInitialSnapshot: \(self.state.hasReceivedInitialSnapshot)") - + logger.info("initialLoad started, current hasReceivedInitialSnapshot: \(state.hasReceivedInitialSnapshot)") + // Send the snapshot request (returns immediately, snapshot arrives async) consumePendingDeepLink() await state.refreshSnapshot() logger.info("Snapshot request sent") - + // Wait for either snapshot to arrive or timeout let timeoutSeconds = 15 let pollIntervalMs: UInt64 = 100 let maxPolls = (timeoutSeconds * 1000) / Int(pollIntervalMs) - + var pollCount = 0 while !state.hasReceivedInitialSnapshot && pollCount < maxPolls { try? await Task.sleep(for: .milliseconds(pollIntervalMs)) @@ -144,17 +145,17 @@ struct RootView: View { logger.debug("Still waiting for snapshot... polls=\(pollCount)/\(maxPolls)") } } - + let hasSnapshot = state.hasReceivedInitialSnapshot logger.info("Wait completed: hasSnapshot=\(hasSnapshot), polls=\(pollCount)/\(maxPolls)") - + // Ensure minimum 2 second display time for smooth UX if pollCount < 20 { // Less than 2 seconds elapsed let remainingMs = (20 - pollCount) * Int(pollIntervalMs) logger.debug("Waiting additional \(remainingMs)ms for minimum display time") try? await Task.sleep(for: .milliseconds(remainingMs)) } - + if hasSnapshot { logger.info("Connection successful - showing content") withAnimation { @@ -185,47 +186,51 @@ struct RootView: View { } } + @State private var searchText = "" + private var phoneTabs: some View { TabView(selection: $selectedTab) { - NavigationStack(path: $briefingDetailPath) { - MobileBriefingView( - onCloseChat: { closeBriefingChat() }, - onOpenSession: { briefingDetailPath.append($0) } - ) - } - .tabItem { - Label("Briefing", systemImage: "doc.text") + Tab("Briefing", systemImage: "doc.text", value: MobileRootTab.briefing) { + NavigationStack(path: $briefingDetailPath) { + MobileBriefingView( + onCloseChat: { closeBriefingChat() }, + onOpenSession: { briefingDetailPath.append($0) } + ) + } } - .tag(MobileRootTab.briefing) - - NavigationStack(path: $projectsPath) { - ProjectsSidebar( - selected: $selectedProject, - showingBriefing: $showingBriefing, - showsBriefingItem: false, - usesSelection: false - ) - .navigationDestination(for: UUID.self) { projectID in - SessionsList( - projectID: projectID, - selected: $selectedSession, + + Tab("Projects", systemImage: "folder", value: MobileRootTab.projects) { + NavigationStack(path: $projectsPath) { + ProjectsSidebar( + selected: $selectedProject, + showingBriefing: $showingBriefing, + showsBriefingItem: false, usesSelection: false ) - } - .navigationDestination(for: String.self) { sessionID in - chatDestination(sessionID) + .navigationDestination(for: UUID.self) { projectID in + SessionsList( + projectID: projectID, + selected: $selectedSession, + usesSelection: false + ) + } + .navigationDestination(for: String.self) { sessionID in + chatDestination(sessionID) + } } } - .tabItem { - Label("Projects", systemImage: "folder") + + Tab("Settings", systemImage: "gear", value: MobileRootTab.settings) { + MobileSettingsView(showsDoneButton: false) } - .tag(MobileRootTab.projects) - MobileSettingsView(showsDoneButton: false) - .tabItem { - Label("Settings", systemImage: "gear") + Tab(value: MobileRootTab.search, role: .search) { + NavigationStack { + MobileSearchContentView(searchText: $searchText) + .navigationTitle("Search") } - .tag(MobileRootTab.settings) + .searchable(text: $searchText, prompt: "Search threads and docs") + } } } @@ -255,11 +260,22 @@ struct RootView: View { NavigationSplitView { projectSidebar } content: { - BriefingListView(selectedGroup: $selectedBriefingGroup) - .onChange(of: selectedBriefingGroup) { _, _ in - // Clear navigation path when switching briefing groups - briefingDetailPath.removeLast(briefingDetailPath.count) - } + NavigationStack { + BriefingListView(selectedGroup: $selectedBriefingGroup) + .navigationDestination(for: String.self) { sessionID in + MobileChatView(sessionID: sessionID, onClose: {}) + .id(sessionID) + .task(id: sessionID) { + if !MobileDraftSessionID.isDraft(sessionID) { + await state.subscribe(to: sessionID) + } + } + } + .onChange(of: selectedBriefingGroup) { _, _ in + // Clear navigation path when switching briefing groups + briefingDetailPath.removeLast(briefingDetailPath.count) + } + } } detail: { NavigationStack(path: $briefingDetailPath) { Group { diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 64e5845c..c9381dd6 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -29,6 +29,7 @@ struct SessionsList: View { @State private var searchText = "" @State private var showingNewThread = false @State private var showingDeleteProjectConfirm = false + @State private var showingSearch = false @Namespace private var glassNamespace // Autopilot actions (1:1 with the desktop project menu), moved here from the @@ -53,6 +54,23 @@ struct SessionsList: View { .navigationTitle("Threads") .toolbar { toolbarContent } .toolbar(usesSelection ? .automatic : .hidden, for: .tabBar) + .toolbar { + // Show search button in bottom bar on iPhone when tab bar is hidden + if !usesSelection { + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + Button { + showingSearch = true + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } + .fullScreenCover(isPresented: $showingSearch) { + FullScreenSearchView(isPresented: $showingSearch) + .environmentObject(state) + } .sheet(isPresented: $showingNewThread) { NewThreadSheet(projectID: projectID) { newSessionID in selected = newSessionID @@ -60,21 +78,13 @@ struct SessionsList: View { .environmentObject(state) .mobileSheetPresentation() } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .automatic), - prompt: "Global search" - ) - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .onChange(of: searchText) { _, newValue in - // Restart paging so search results always begin at the top. - displayLimit = Self.pageSize - state.updateSearchQuery(newValue) - } - .onDisappear { - state.updateSearchQuery("") - } + .modifier(SessionsListSearchModifier( + isEnabled: usesSelection, // Only show search on iPad (usesSelection=true) + searchText: $searchText, + displayLimit: $displayLimit, + pageSize: Self.pageSize, + state: state + )) .overlay { if usesDesktopSearch, !state.isSearching, desktopSearchHits.isEmpty { ContentUnavailableView.search(text: searchText) @@ -682,11 +692,11 @@ extension MobileAppState { static var preview: MobileAppState { let state = MobileAppState() let projectID = UUID() - + state.projects = [ Project(id: projectID, name: "RxCode", path: "/Users/dev/RxCode") ] - + state.sessions = [ SessionSummary( id: "session-1", @@ -769,20 +779,20 @@ extension MobileAppState { hasUncheckedCompletion: false ) ] - + return state } - + /// A preview-ready MobileAppState with no sessions (empty state). static var previewEmpty: MobileAppState { let state = MobileAppState() let projectID = UUID() - + state.projects = [ Project(id: projectID, name: "New Project", path: "/Users/dev/NewProject") ] state.sessions = [] - + return state } } @@ -828,3 +838,37 @@ extension MobileAppState { .environmentObject(state) } #endif + +// MARK: - Search Modifier + +private struct SessionsListSearchModifier: ViewModifier { + let isEnabled: Bool + @Binding var searchText: String + @Binding var displayLimit: Int + let pageSize: Int + let state: MobileAppState + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: "Global search" + ) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, newValue in + // Restart paging so search results always begin at the top. + displayLimit = pageSize + state.updateSearchQuery(newValue) + } + .onDisappear { + state.updateSearchQuery("") + } + } else { + content + } + } +}