98 lines
5.1 KiB
Swift
98 lines
5.1 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) }
|
|
var found: [FileResult] = []
|
|
await withTaskGroup(of: FileResult?.self) { group in
|
|
for path in paths {
|
|
group.addTask {
|
|
guard let content = await buildService.fetchFileContent(path: path) else { return nil }
|
|
let matches = searchContent(content)
|
|
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
|
|
}
|
|
|
|
private func searchContent(_ content: 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())
|
|
}
|
|
}
|