// // DoFileEditorView.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import AppKit import SwiftUI import UniformTypeIdentifiers import ZQParser /// Do-file editor pane (CLAUDE.md §7, pane 2): NSTextView bridge with ZQL /// syntax highlighting; ⌘R runs the selection (or the whole file) through /// the shared session execution path. struct DoFileEditorPane: View { @Environment(SessionModel.self) private var model @State private var text = "" @State private var selection: NSRange = NSRange(location: 0, length: 0) @State private var showingOpen = false @State private var showingSave = false @State private var fileName = "untitled.zyq" private static let zyqType = UTType(filenameExtension: "zyq", conformingTo: .plainText) ?? .plainText var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { Text(fileName) .font(.caption) .foregroundStyle(.secondary) Spacer() Button("Open…") { showingOpen = true } Button("Save…") { showingSave = true } Button { run() } label: { Label( selection.length > 0 ? "Run Selection" : "Run", systemImage: "play.fill" ) } .keyboardShortcut("r", modifiers: .command) .disabled(model.isRunning || text.isEmpty) } .padding(8) Divider() ZQLTextEditor(text: $text, selection: $selection) } .fileImporter( isPresented: $showingOpen, allowedContentTypes: [Self.zyqType, .plainText] ) { result in guard case .success(let url) = result else { return } let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } if let contents = try? String(contentsOf: url, encoding: .utf8) { text = contents fileName = url.lastPathComponent } } .fileExporter( isPresented: $showingSave, document: DoFileDocument(text: text), contentType: Self.zyqType, defaultFilename: fileName ) { result in if case .success(let url) = result { fileName = url.lastPathComponent } } } private func run() { let script: String if selection.length > 0, let range = Range(selection, in: text) { script = String(text[range]) } else { script = text } Task { await model.runScript(script) } } } /// Plain-text document wrapper for the save panel. struct DoFileDocument: FileDocument { static let readableContentTypes: [UTType] = [.plainText] var text: String init(text: String) { self.text = text } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } text = String(decoding: data, as: UTF8.self) } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: Data(text.utf8)) } } /// NSTextView bridge with lightweight ZQL highlighting: verbs (resolved /// through the real verb table), comments, strings, and numbers. struct ZQLTextEditor: NSViewRepresentable { @Binding var text: String @Binding var selection: NSRange func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let textView = NSTextView() textView.isRichText = false textView.allowsUndo = true textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.autoresizingMask = [.width] textView.delegate = context.coordinator context.coordinator.textView = textView let scroll = NSScrollView() scroll.documentView = textView scroll.hasVerticalScroller = true return scroll } func updateNSView(_ scroll: NSScrollView, context: Context) { context.coordinator.parent = self guard let textView = context.coordinator.textView else { return } if textView.string != text { textView.string = text context.coordinator.highlight(textView) } } @MainActor final class Coordinator: NSObject, NSTextViewDelegate { var parent: ZQLTextEditor weak var textView: NSTextView? private let verbs = ZQVerbTable.builtin init(_ parent: ZQLTextEditor) { self.parent = parent } func textDidChange(_ notification: Notification) { guard let textView else { return } parent.text = textView.string highlight(textView) } func textViewDidChangeSelection(_ notification: Notification) { guard let textView else { return } parent.selection = textView.selectedRange() } func highlight(_ textView: NSTextView) { guard let storage = textView.textStorage else { return } let content = textView.string as NSString let fullRange = NSRange(location: 0, length: content.length) storage.beginEditing() storage.setAttributes([ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), .foregroundColor: NSColor.labelColor, ], range: fullRange) content.enumerateSubstrings( in: fullRange, options: [.byLines, .substringNotRequired] ) { _, lineRange, _, _ in self.highlightLine(content, lineRange, storage) } storage.endEditing() } private func highlightLine( _ content: NSString, _ lineRange: NSRange, _ storage: NSTextStorage ) { let line = content.substring(with: lineRange) let trimmed = line.trimmingCharacters(in: .whitespaces) // Whole-line comments. if trimmed.hasPrefix("//") || trimmed.hasPrefix("*") { storage.addAttribute( .foregroundColor, value: NSColor.systemGray, range: lineRange ) return } // Leading verb, resolved through the real table. if let verbMatch = line.range(of: #"^\s*([A-Za-z_]+)"#, options: .regularExpression) { let word = line[verbMatch].trimmingCharacters(in: .whitespaces) if verbs.resolve(word.lowercased()) != nil { let location = lineRange.location + line.distance(from: line.startIndex, to: verbMatch.lowerBound) storage.addAttributes([ .foregroundColor: NSColor.systemBlue, .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold), ], range: NSRange(location: location, length: word.count)) } } // Strings and trailing comments via regex on the line. applyPattern(#""[^"]*""#, NSColor.systemRed, line, lineRange, storage) applyPattern(#"//.*$"#, NSColor.systemGray, line, lineRange, storage) } private func applyPattern( _ pattern: String, _ color: NSColor, _ line: String, _ lineRange: NSRange, _ storage: NSTextStorage ) { guard let regex = try? NSRegularExpression(pattern: pattern) else { return } let range = NSRange(line.startIndex..