Files
CxIDE/Views/SyntaxHighlightingTextView.swift
T
cx-git-agent 6218a6ef28 feat: syntax highlighting, workspace search, git-tree wiring, auto-restore
- NSTextView-based syntax highlighting with regex tokenizer
  - Swift, C/C++, ObjC, JSON keywords, types, strings, comments
  - Theme-aware coloring, debounced re-highlighting
- Workspace-wide search across all source files
  - Grouped results by file with line numbers
  - Scope toggle: current file vs all files
- Git status badges on file tree nodes
  - GitService changes flow to FileNode.gitStatus
- Agent file operations refresh file tree
- Auto-restore last workspace on launch
- All tests passing (0 errors, 0 warnings)
2026-04-21 16:38:42 -05:00

332 lines
14 KiB
Swift

// SyntaxHighlightingTextView.swift
// CxIDE NSViewRepresentable code editor with real-time syntax highlighting.
import SwiftUI
import AppKit
// MARK: - Syntax Highlighting Text View
struct SyntaxHighlightingTextView: NSViewRepresentable {
@Binding var text: String
let language: EditorTab.Language
let theme: IDETheme
let fontSize: CGFloat
var onTextChange: ((String) -> Void)?
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
let textView = NSTextView()
textView.isEditable = true
textView.isSelectable = true
textView.isRichText = false
textView.allowsUndo = true
textView.usesFindPanel = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isContinuousSpellCheckingEnabled = false
textView.smartInsertDeleteEnabled = false
textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
textView.textColor = NSColor(theme.editorForeground)
textView.backgroundColor = NSColor(theme.editorBackground)
textView.insertionPointColor = .white
textView.selectedTextAttributes = [
.backgroundColor: NSColor(theme.selectionColor)
]
textView.textContainerInset = NSSize(width: 4, height: 4)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.minSize = NSSize(width: 0, height: 0)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainer?.widthTracksTextView = true
textView.delegate = context.coordinator
context.coordinator.textView = textView
scrollView.documentView = textView
// Initial content
textView.string = text
context.coordinator.applyHighlighting()
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
let coordinator = context.coordinator
// Update font if changed
let newFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
if textView.font != newFont {
textView.font = newFont
coordinator.applyHighlighting()
}
// Update background color
textView.backgroundColor = NSColor(theme.editorBackground)
textView.insertionPointColor = .white
// Update theme reference
coordinator.parent = self
// Only update text if it changed externally (not from typing)
if textView.string != text && !coordinator.isUpdating {
coordinator.isUpdating = true
let selectedRanges = textView.selectedRanges
textView.string = text
coordinator.applyHighlighting()
textView.selectedRanges = selectedRanges
coordinator.isUpdating = false
}
}
// MARK: - Coordinator
class Coordinator: NSObject, NSTextViewDelegate {
var parent: SyntaxHighlightingTextView
weak var textView: NSTextView?
var isUpdating = false
private var highlightWorkItem: DispatchWorkItem?
init(_ parent: SyntaxHighlightingTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView, !isUpdating else { return }
isUpdating = true
parent.text = textView.string
parent.onTextChange?(textView.string)
isUpdating = false
// Debounce highlighting for performance
highlightWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
DispatchQueue.main.async {
self?.applyHighlighting()
}
}
highlightWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: workItem)
}
// MARK: - Syntax Highlighting
func applyHighlighting() {
guard let textView = textView,
let textStorage = textView.textStorage else { return }
let text = textView.string
guard !text.isEmpty else { return }
let fullRange = NSRange(location: 0, length: (text as NSString).length)
let theme = parent.theme
let font = NSFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
textStorage.beginEditing()
// Reset to default
textStorage.setAttributes([
.foregroundColor: NSColor(theme.editorForeground),
.font: font
], range: fullRange)
let highlighter = SyntaxTokenizer(language: parent.language, theme: theme, font: font)
highlighter.tokenize(text: text, in: textStorage)
textStorage.endEditing()
}
}
}
// MARK: - Syntax Tokenizer
struct SyntaxTokenizer {
let language: EditorTab.Language
let theme: IDETheme
let font: NSFont
func tokenize(text: String, in storage: NSTextStorage) {
let nsText = text as NSString
// 1. Comments (must come first overrides keywords inside comments)
highlightPattern(storage, text: nsText, pattern: "//[^\n]*", color: theme.commentColor)
highlightPattern(storage, text: nsText, pattern: "/\\*[\\s\\S]*?\\*/", color: theme.commentColor)
// 2. Strings
highlightPattern(storage, text: nsText, pattern: "\"\"\"[\\s\\S]*?\"\"\"", color: theme.stringColor)
highlightPattern(storage, text: nsText, pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", color: theme.stringColor)
// 3. Numbers
highlightPattern(storage, text: nsText, pattern: "\\b\\d+(\\.\\d+)?\\b", color: theme.numberColor)
highlightPattern(storage, text: nsText, pattern: "\\b0x[0-9a-fA-F]+\\b", color: theme.numberColor)
// 4. Language-specific keywords
let keywords = keywordsForLanguage()
if !keywords.isEmpty {
let pattern = "\\b(" + keywords.joined(separator: "|") + ")\\b"
highlightPattern(storage, text: nsText, pattern: pattern, color: theme.keywordColor)
}
// 5. Types
let types = typesForLanguage()
if !types.isEmpty {
let pattern = "\\b(" + types.joined(separator: "|") + ")\\b"
highlightPattern(storage, text: nsText, pattern: pattern, color: theme.typeColor)
}
// 6. Preprocessor / annotations
switch language {
case .swift:
highlightPattern(storage, text: nsText, pattern: "@\\w+", color: theme.preprocessorColor)
highlightPattern(storage, text: nsText, pattern: "#\\w+", color: theme.preprocessorColor)
case .c, .cpp, .header, .objc:
highlightPattern(storage, text: nsText, pattern: "#\\w+[^\n]*", color: theme.preprocessorColor)
default:
break
}
// 7. Function calls: word followed by (
highlightPattern(storage, text: nsText, pattern: "\\b([a-zA-Z_]\\w*)\\s*(?=\\()", color: theme.functionColor)
// 8. Type declarations (capitalized identifiers after class/struct/enum/protocol)
highlightPattern(storage, text: nsText, pattern: "(?<=\\b(?:class|struct|enum|protocol|extension|typealias)\\s)\\w+", color: theme.typeColor)
}
private func highlightPattern(_ storage: NSTextStorage, text: NSString, pattern: String, color: Color) {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let fullRange = NSRange(location: 0, length: text.length)
regex.enumerateMatches(in: text as String, options: [], range: fullRange) { match, _, _ in
guard let matchRange = match?.range else { return }
storage.addAttribute(.foregroundColor, value: NSColor(color), range: matchRange)
}
}
private func keywordsForLanguage() -> [String] {
switch language {
case .swift:
return [
"import", "func", "var", "let", "class", "struct", "enum", "protocol",
"extension", "typealias", "init", "deinit", "subscript", "operator",
"if", "else", "guard", "switch", "case", "default", "for", "while",
"repeat", "do", "catch", "throw", "throws", "rethrows", "try",
"return", "break", "continue", "fallthrough", "where", "in",
"as", "is", "nil", "true", "false", "self", "Self", "super",
"async", "await", "actor", "nonisolated", "isolated", "sending",
"public", "private", "internal", "fileprivate", "open",
"static", "final", "override", "mutating", "nonmutating",
"lazy", "weak", "unowned", "inout", "some", "any",
"associatedtype", "convenience", "required", "dynamic",
"willSet", "didSet", "get", "set", "defer", "indirect",
"precedencegroup", "infix", "prefix", "postfix",
"@MainActor", "@Published", "@State", "@Binding", "@ObservedObject",
"@StateObject", "@Environment", "@EnvironmentObject", "@ViewBuilder",
"@escaping", "@autoclosure", "@discardableResult", "@available",
"@objc", "@unchecked", "Sendable", "MainActor"
]
case .c, .header:
return [
"auto", "break", "case", "char", "const", "continue", "default",
"do", "double", "else", "enum", "extern", "float", "for", "goto",
"if", "int", "long", "register", "return", "short", "signed",
"sizeof", "static", "struct", "switch", "typedef", "union",
"unsigned", "void", "volatile", "while", "inline", "restrict",
"_Bool", "_Complex", "_Imaginary",
"NULL", "true", "false"
]
case .cpp:
return [
"auto", "break", "case", "char", "const", "continue", "default",
"do", "double", "else", "enum", "extern", "float", "for", "goto",
"if", "int", "long", "register", "return", "short", "signed",
"sizeof", "static", "struct", "switch", "typedef", "union",
"unsigned", "void", "volatile", "while", "inline", "restrict",
"class", "namespace", "template", "typename", "using", "virtual",
"public", "private", "protected", "friend", "operator", "new",
"delete", "this", "throw", "try", "catch", "const_cast",
"dynamic_cast", "reinterpret_cast", "static_cast", "explicit",
"mutable", "override", "final", "noexcept", "constexpr",
"nullptr", "true", "false", "bool", "wchar_t",
"thread_local", "decltype", "alignas", "alignof",
"static_assert", "concept", "requires", "co_await", "co_return",
"co_yield", "consteval", "constinit"
]
case .objc:
return [
"auto", "break", "case", "char", "const", "continue", "default",
"do", "double", "else", "enum", "extern", "float", "for", "goto",
"if", "int", "long", "register", "return", "short", "signed",
"sizeof", "static", "struct", "switch", "typedef", "union",
"unsigned", "void", "volatile", "while", "inline",
"@interface", "@implementation", "@end", "@protocol", "@property",
"@synthesize", "@dynamic", "@optional", "@required", "@class",
"@selector", "@encode", "@try", "@catch", "@finally", "@throw",
"@autoreleasepool", "@synchronized",
"self", "super", "nil", "NULL", "YES", "NO", "true", "false",
"id", "instancetype", "SEL", "IMP", "Class", "BOOL",
"strong", "weak", "assign", "copy", "retain", "nonatomic", "atomic",
"readonly", "readwrite", "nullable", "nonnull"
]
case .json:
return ["true", "false", "null"]
case .markdown, .plaintext:
return []
}
}
private func typesForLanguage() -> [String] {
switch language {
case .swift:
return [
"Int", "Int8", "Int16", "Int32", "Int64",
"UInt", "UInt8", "UInt16", "UInt32", "UInt64",
"Float", "Double", "Float16", "Float80",
"Bool", "String", "Character", "Void", "Never",
"Optional", "Result", "Array", "Dictionary", "Set",
"AnyObject", "AnyHashable", "AnySequence", "AnyCollection",
"Data", "Date", "URL", "UUID", "Error",
"Codable", "Encodable", "Decodable", "Hashable",
"Equatable", "Comparable", "Identifiable", "CustomStringConvertible",
"Sequence", "Collection", "IteratorProtocol", "RangeReplaceableCollection",
"View", "ObservableObject", "ObservedObject", "Published",
"Color", "Image", "Text", "Button", "VStack", "HStack", "ZStack",
"List", "ForEach", "NavigationView", "NavigationLink",
"CGFloat", "CGPoint", "CGSize", "CGRect", "NSSize",
"DispatchQueue", "Task", "AsyncSequence", "AsyncStream"
]
case .cpp:
return [
"string", "vector", "map", "unordered_map", "set", "unordered_set",
"pair", "tuple", "array", "list", "deque", "queue", "stack",
"shared_ptr", "unique_ptr", "weak_ptr", "optional", "variant",
"size_t", "ptrdiff_t", "int8_t", "int16_t", "int32_t", "int64_t",
"uint8_t", "uint16_t", "uint32_t", "uint64_t",
"iostream", "istream", "ostream", "ifstream", "ofstream",
"stringstream", "istringstream", "ostringstream"
]
default:
return []
}
}
}