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()) } }