73 lines
4.1 KiB
Swift
73 lines
4.1 KiB
Swift
import SwiftUI
|
|
import Foundation
|
|
|
|
struct FindInFileView: View {
|
|
let content: String
|
|
let onNavigate: (NSRange) -> Void
|
|
@Environment(\.dismiss) var dismiss
|
|
@State private var query = ""; @State private var isRegex = false; @State private var isCaseSensitive = false
|
|
@State private var results: [Result] = []; @State private var selected = 0
|
|
@FocusState private var focused: Bool
|
|
|
|
struct Result: 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("Find…", text: $query).textFieldStyle(.plain).font(.system(.body, design: .monospaced))
|
|
.autocorrectionDisabled().autocapitalization(.none).focused($focused).onSubmit { navigateNext() }
|
|
.onChange(of: query) { _, _ in runSearch() }
|
|
if !results.isEmpty {
|
|
Text("\(selected+1)/\(results.count)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
|
|
Button { navigatePrev() } label: { Image(systemName: "chevron.up") }.buttonStyle(.bordered).controlSize(.small)
|
|
Button { navigateNext() } label: { Image(systemName: "chevron.down") }.buttonStyle(.bordered).controlSize(.small)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 10).background(Color(.secondarySystemBackground))
|
|
Divider()
|
|
if results.isEmpty {
|
|
ContentUnavailableView(query.isEmpty ? "Type to search" : "No Results", systemImage: "magnifyingglass")
|
|
} else {
|
|
List(Array(results.enumerated()), id: \.element.id) { idx, r in
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Text("\(r.lineNumber)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary).frame(width: 36, alignment: .trailing)
|
|
Text(r.lineText).font(.system(.body, design: .monospaced)).lineLimit(1)
|
|
}
|
|
.background(idx == selected ? Color.accentColor.opacity(0.1) : .clear)
|
|
.onTapGesture { selected = idx; onNavigate(r.range); dismiss() }
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle("Find in File").navigationBarTitleDisplayMode(.inline)
|
|
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } }
|
|
.onAppear { focused = true }
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
private func runSearch() {
|
|
guard !query.isEmpty else { results = []; return }
|
|
let lines = content.components(separatedBy: "\n")
|
|
var found: [Result] = []; var offset = 0
|
|
let pattern = isRegex ? query : NSRegularExpression.escapedPattern(for: query)
|
|
let options: NSRegularExpression.Options = isCaseSensitive ? [] : .caseInsensitive
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { results = []; return }
|
|
for (i, line) in lines.enumerated() {
|
|
let r = NSRange(line.startIndex..., in: line)
|
|
for m in regex.matches(in: line, range: r) {
|
|
found.append(Result(range: NSRange(location: offset + m.range.location, length: m.range.length),
|
|
lineNumber: i + 1, lineText: line.trimmingCharacters(in: .whitespaces)))
|
|
}
|
|
offset += line.count + 1
|
|
}
|
|
results = found; if !found.isEmpty { selected = 0 }
|
|
}
|
|
|
|
private func navigateNext() { guard !results.isEmpty else { return }; selected = (selected+1) % results.count; onNavigate(results[selected].range) }
|
|
private func navigatePrev() { guard !results.isEmpty else { return }; selected = (selected-1+results.count) % results.count; onNavigate(results[selected].range) }
|
|
}
|