| 1 | import RastrilloApple |
| 2 | import SwiftUI |
| 3 | |
| 4 | @MainActor struct DocsJumpControls: View { |
| 5 | @Binding var presented: Bool |
| 6 | var body: some View { |
| 7 | Button { presented = true } label: { |
| 8 | Label { Text("docs.jumpto.label", bundle: DocsResources.bundle) } icon: { Image(systemName: "magnifyingglass") } |
| 9 | } |
| 10 | .keyboardShortcut("k", modifiers: .command) |
| 11 | .accessibilityIdentifier("docs.jump") |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | private struct DocumentSearchResult: Decodable, Identifiable { |
| 16 | let title: String |
| 17 | let href: String |
| 18 | let when: String |
| 19 | var id: String { href } |
| 20 | var sqid: String? { |
| 21 | guard href.hasPrefix("/d/") else { return nil } |
| 22 | let value = String(href.dropFirst(3)) |
| 23 | return !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) } ? value : nil |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | @MainActor struct DocsJumpSheet: View { |
| 28 | let model: DocsModel |
| 29 | let client: NativeHTTP |
| 30 | @Environment(\.dismiss) private var dismiss |
| 31 | @FocusState private var searchFocused: Bool |
| 32 | @State private var query = "" |
| 33 | @State private var results: [DocumentSearchResult] = [] |
| 34 | @State private var resultsQuery = "" |
| 35 | @State private var selected = 0 |
| 36 | @State private var status: String? |
| 37 | @State private var working = false |
| 38 | var body: some View { |
| 39 | NavigationStack { |
| 40 | VStack(spacing: 0) { |
| 41 | TextField(String(localized: "docs.jumpto.placeholder", bundle: DocsResources.bundle), text: $query) |
| 42 | .textFieldStyle(.roundedBorder) |
| 43 | .focused($searchFocused) |
| 44 | .accessibilityIdentifier("docs.jump.query") |
| 45 | .accessibilityHint(Text("docs.jumpto.hint", bundle: DocsResources.bundle)) |
| 46 | .onSubmit { openSelected() } |
| 47 | .onKeyPress(.return) { openSelected(); return .handled } |
| 48 | .onKeyPress(.upArrow) { move(-1); return .handled } |
| 49 | .onKeyPress(.downArrow) { move(1); return .handled } |
| 50 | .padding() |
| 51 | if let status { Text(LocalizedStringKey(status), bundle: DocsResources.bundle).foregroundStyle(.secondary).padding(.horizontal) } |
| 52 | if working { ProgressView().padding() } |
| 53 | ScrollViewReader { reader in |
| 54 | List(Array(results.enumerated()), id: \.element.id) { index, result in |
| 55 | Button { open(result) } label: { |
| 56 | VStack(alignment: .leading) { |
| 57 | Text(result.title) |
| 58 | Text(result.when).font(.caption).foregroundStyle(.secondary) |
| 59 | }.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) |
| 60 | } |
| 61 | .buttonStyle(.plain) |
| 62 | .accessibilityAddTraits(index == selected ? .isSelected : []) |
| 63 | .listRowBackground(index == selected ? Color.accentColor.opacity(0.12) : Color.clear) |
| 64 | .id(result.id) |
| 65 | } |
| 66 | .onChange(of: selected) { if results.indices.contains(selected) { reader.scrollTo(results[selected].id) } } |
| 67 | } |
| 68 | } |
| 69 | .disabled(working) |
| 70 | .navigationTitle(Text("docs.jumpto.label", bundle: DocsResources.bundle)) |
| 71 | .toolbar { |
| 72 | ToolbarItem(placement: .cancellationAction) { |
| 73 | Button { dismiss() } label: { Text("docs.jumpto.close", bundle: DocsResources.bundle) } |
| 74 | .keyboardShortcut(.cancelAction) |
| 75 | .disabled(working) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | .task { searchFocused = true } |
| 80 | .task(id: query) { await search() } |
| 81 | .interactiveDismissDisabled(working) |
| 82 | #if os(macOS) |
| 83 | .frame(minWidth: 460, minHeight: 400) |
| 84 | #else |
| 85 | .presentationDetents([.medium, .large]) |
| 86 | #endif |
| 87 | } |
| 88 | private func move(_ delta: Int) { |
| 89 | guard !results.isEmpty else { return } |
| 90 | selected = max(0, min(results.count - 1, selected + delta)) |
| 91 | } |
| 92 | private func search() async { |
| 93 | guard !Task.isCancelled else { return } |
| 94 | let searching = query |
| 95 | results = []; selected = 0; status = nil |
| 96 | guard !searching.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } |
| 97 | do { |
| 98 | try await Task.sleep(for: .milliseconds(120)) |
| 99 | var components = URLComponents() |
| 100 | components.queryItems = [URLQueryItem(name: "q", value: searching)] |
| 101 | let encoded = (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") |
| 102 | let response = try await client.responseDetails("/documents/search?\(encoded)") |
| 103 | guard response.status == 200 else { throw CRDTReplica.Failure.rejected } |
| 104 | let rows = try JSONDecoder().decode([DocumentSearchResult].self, from: response.data) |
| 105 | guard !Task.isCancelled, query == searching else { return } |
| 106 | results = rows.filter { $0.sqid != nil } |
| 107 | resultsQuery = searching |
| 108 | status = response.header("Docs-Catalog-Partial") == "1" ? "docs.team.partial" : (results.isEmpty ? "docs.jumpto.empty" : nil) |
| 109 | } catch { |
| 110 | guard !Task.isCancelled, query == searching else { return } |
| 111 | status = "docs.jumpto.failed" |
| 112 | } |
| 113 | } |
| 114 | private func openSelected() { |
| 115 | guard results.indices.contains(selected) else { return } |
| 116 | open(results[selected]) |
| 117 | } |
| 118 | private func open(_ result: DocumentSearchResult) { |
| 119 | guard !working, query == resultsQuery, let sqid = result.sqid else { return } |
| 120 | working = true |
| 121 | Task { |
| 122 | defer { working = false } |
| 123 | guard await model.saveAll() else { status = "native.pending"; return } |
| 124 | await model.open(sqid) |
| 125 | if model.detail?.sqid == sqid { dismiss() } |
| 126 | else { status = "docs.error.generic" } |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |