PadXcode-iPad/Editor/FindAcrossFilesView.swift
2026-04-12 22:07:35 -07:00

110 lines
5.7 KiB
Swift

import SwiftUI
import Foundation
struct FindAcrossFilesView: View {
let fileTree: FileNode?
let buildService: BuildService
let onOpenFile: (String, NSRange) -> Void
@Environment(\.dismiss) var dismiss
@State private var query = ""; @State private var isSearching = false; @State private var results: [FileResult] = []
@FocusState private var focused: Bool
struct FileResult: Identifiable {
let id = UUID(); let filePath: String; var matches: [Match]
var fileName: String { URL(fileURLWithPath: filePath).lastPathComponent }
struct Match: Identifiable { let id = UUID(); let range: NSRange; let lineNumber: Int; let lineText: String }
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search in project…", text: $query).textFieldStyle(.plain).font(.system(.body, design: .monospaced))
.autocorrectionDisabled().autocapitalization(.none).focused($focused).onSubmit { Task { await runSearch() } }
Button { Task { await runSearch() } } label: { isSearching ? AnyView(ProgressView().scaleEffect(0.7)) : AnyView(Text("Search")) }
.buttonStyle(.borderedProminent).controlSize(.small).disabled(query.isEmpty || isSearching)
}
.padding(.horizontal, 14).padding(.vertical, 10).background(Color(.secondarySystemBackground))
Divider()
if results.isEmpty {
ContentUnavailableView(query.isEmpty ? "Search Project" : "No Results", systemImage: "magnifyingglass")
} else {
List {
ForEach(results) { fr in
Section {
ForEach(fr.matches) { m in
HStack(alignment: .top, spacing: 10) {
Text("\(m.lineNumber)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary).frame(width: 36, alignment: .trailing)
Text(m.lineText).font(.system(.body, design: .monospaced)).lineLimit(1)
}
.onTapGesture { onOpenFile(fr.filePath, m.range); dismiss() }
}
} header: {
HStack { Text(fr.fileName).font(.subheadline.bold()); Spacer()
Text("\(fr.matches.count)").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2).background(.quaternary, in: Capsule()) }
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("Find in Project").navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } }
.onAppear { focused = true }
}
}
private func runSearch() async {
guard !query.isEmpty, let tree = fileTree else { return }
isSearching = true; results = []
let paths = collectPaths(from: tree).filter { isSearchable($0) }
// Step 1: Fetch all file contents on main actor (BuildService is @MainActor).
// Do this serially but efficiently network is the bottleneck anyway.
var contentMap: [String: String] = [:]
for path in paths {
if let content = await buildService.fetchFileContent(path: path) {
contentMap[path] = content
}
}
// Step 2: Run the regex search concurrently across all content strings.
// searchContent() is nonisolated so this is genuinely parallel.
var found: [FileResult] = []
let capturedQuery = query
await withTaskGroup(of: FileResult?.self) { group in
for (path, content) in contentMap {
group.addTask {
let matches = self.searchContent(content, query: capturedQuery)
return matches.isEmpty ? nil : FileResult(filePath: path, matches: matches)
}
}
for await r in group { if let r { found.append(r) } }
}
results = found.sorted { $0.matches.count > $1.matches.count }
isSearching = false
}
nonisolated private func searchContent(_ content: String, query: String) -> [FileResult.Match] {
let lines = content.components(separatedBy: "\n"); var matches: [FileResult.Match] = []; var offset = 0
let pattern = NSRegularExpression.escapedPattern(for: query)
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] }
for (i, line) in lines.enumerated() {
for m in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)) {
matches.append(FileResult.Match(range: NSRange(location: offset+m.range.location, length: m.range.length),
lineNumber: i+1, lineText: line.trimmingCharacters(in: .whitespaces)))
}
offset += line.count + 1
}
return matches
}
private func collectPaths(from node: FileNode) -> [String] {
node.isDirectory ? (node.children ?? []).flatMap { collectPaths(from: $0) } : [node.path]
}
private func isSearchable(_ path: String) -> Bool {
["swift","json","md","txt","yaml","yml","sh","xcconfig"].contains(
(path as NSString).pathExtension.lowercased())
}
}