Files
Peter Steinberger 077e35f3b2 feat(ios): read-only workspace Files browser on the Agents surface (#100767)
* feat(ios): add read-only workspace Files browser to the Agents surface

* fix(ios): keep nav bar visible on pushed workspace file routes

* fix(ios): make workspace file navigation stack-safe

* docs(ios): note workspace file browser

* fix(ios): isolate workspace share exports

* chore(i18n): refresh iOS Files inventory

* refactor(ios): encode workspace requests before dispatch

* chore(i18n): refresh iOS inventory after rebase
2026-07-06 17:47:52 +01:00

419 lines
16 KiB
Swift

import Foundation
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
enum ChatCodeTokenKind: Equatable {
case plain
case keyword
case string
case comment
case number
}
struct ChatCodeToken: Equatable {
let kind: ChatCodeTokenKind
let text: String
}
/// Minimal single-pass tokenizer for chat code blocks: keywords, strings,
/// comments, and numbers. Unknown languages pass through unstyled; correctness
/// of the source text is never altered, only colors are added.
enum ChatCodeHighlighter {
struct Language {
struct BlockComment {
let open: String
let close: String
}
let keywords: Set<String>
let lineCommentPrefixes: [String]
let lineCommentNeedsBoundary: Bool
let blockComment: BlockComment?
let nestedBlockComments: Bool
let stringDelimiters: Set<Character>
let supportsTripleQuotes: Bool
init(
keywords: Set<String>,
lineCommentPrefixes: [String],
lineCommentNeedsBoundary: Bool = false,
blockComment: BlockComment?,
nestedBlockComments: Bool = false,
stringDelimiters: Set<Character>,
supportsTripleQuotes: Bool)
{
self.keywords = keywords
self.lineCommentPrefixes = lineCommentPrefixes
self.lineCommentNeedsBoundary = lineCommentNeedsBoundary
self.blockComment = blockComment
self.nestedBlockComments = nestedBlockComments
self.stringDelimiters = stringDelimiters
self.supportsTripleQuotes = supportsTripleQuotes
}
}
static let maxHighlightedLines = 200
static let maxHighlightedBytes = 20000
static func language(for id: String?) -> Language? {
switch id {
case "swift": self.swift
case "kotlin", "kt", "kts": self.kotlin
case "typescript", "ts", "tsx", "javascript", "js", "jsx", "mjs", "cjs": self.typescript
case "python", "py", "python3": self.python
case "bash", "sh", "shell", "zsh", "console": self.bash
case "json", "jsonc", "json5": self.json
default: nil
}
}
static func attributedCode(_ code: String, languageId: String?) -> AttributedString {
guard let language = self.language(for: languageId), self.isWithinHighlightLimits(code) else {
return AttributedString(code)
}
var output = AttributedString()
for token in self.tokens(code: code, language: language) {
var piece = AttributedString(token.text)
if let color = self.color(for: token.kind) {
piece.foregroundColor = color
}
output += piece
}
return output
}
static func tokens(code: String, language: Language) -> [ChatCodeToken] {
let chars = Array(code)
var tokens: [ChatCodeToken] = []
var plain = ""
var index = 0
func flushPlain() {
guard !plain.isEmpty else { return }
tokens.append(ChatCodeToken(kind: .plain, text: plain))
plain = ""
}
while index < chars.count {
let character = chars[index]
if let block = language.blockComment, self.matches(chars, at: index, block.open) {
flushPlain()
index = self.consumeBlockComment(
chars,
from: index,
block: block,
nested: language.nestedBlockComments,
into: &tokens)
continue
}
let startsLineComment = language.lineCommentPrefixes.contains { prefix in
self.matches(chars, at: index, prefix) &&
(!language.lineCommentNeedsBoundary || self.isLineCommentBoundary(chars, at: index))
}
if startsLineComment {
flushPlain()
var end = index
while end < chars.count, chars[end] != "\n" {
end += 1
}
tokens.append(ChatCodeToken(kind: .comment, text: String(chars[index..<end])))
index = end
continue
}
if language.stringDelimiters.contains(character) {
flushPlain()
index = self.consumeString(chars, from: index, language: language, into: &tokens)
continue
}
if character.isNumber {
flushPlain()
var end = index
// Loose number scan (hex/exponent/separators); identifiers with
// trailing digits never reach here because the identifier
// branch below consumes them first.
while end < chars.count,
chars[end].isHexDigit || chars[end] == "." || chars[end] == "_"
|| chars[end] == "x" || chars[end] == "X" || chars[end] == "o"
|| chars[end] == "b"
{
end += 1
}
tokens.append(ChatCodeToken(kind: .number, text: String(chars[index..<end])))
index = end
continue
}
if character.isLetter || character == "_" || character == "$" || character == "@" {
var end = index
while end < chars.count,
chars[end].isLetter || chars[end].isNumber || chars[end] == "_" || chars[end] == "$"
|| chars[end] == "@"
{
end += 1
}
let word = String(chars[index..<end])
if language.keywords.contains(word) {
flushPlain()
tokens.append(ChatCodeToken(kind: .keyword, text: word))
} else {
plain += word
}
index = end
continue
}
plain.append(character)
index += 1
}
flushPlain()
return tokens
}
// MARK: - Scanning helpers
private static func consumeBlockComment(
_ chars: [Character],
from start: Int,
block: Language.BlockComment,
nested: Bool,
into tokens: inout [ChatCodeToken]) -> Int
{
var depth = 1
var end = start + block.open.count
while end < chars.count {
if nested, self.matches(chars, at: end, block.open) {
depth += 1
end += block.open.count
} else if self.matches(chars, at: end, block.close) {
depth -= 1
end += block.close.count
if depth == 0 { break }
} else {
end += 1
}
}
tokens.append(ChatCodeToken(kind: .comment, text: String(chars[start..<end])))
return end
}
private static func isLineCommentBoundary(_ chars: [Character], at index: Int) -> Bool {
guard index > 0 else { return true }
let previous = chars[index - 1]
return previous.isWhitespace || ";&|()<>".contains(previous)
}
static func isWithinHighlightLimits(_ code: String) -> Bool {
guard code.utf8.count <= self.maxHighlightedBytes else { return false }
let newlineCount = code.lazy.count(where: { $0 == "\n" })
let lineCount = code.isEmpty ? 0 : newlineCount + 1
return lineCount <= self.maxHighlightedLines
}
private static func consumeString(
_ chars: [Character],
from start: Int,
language: Language,
into tokens: inout [ChatCodeToken]) -> Int
{
let quote = chars[start]
let triple = language.supportsTripleQuotes
&& self.matches(chars, at: start, String(repeating: String(quote), count: 3))
let delimiter = triple ? String(repeating: String(quote), count: 3) : String(quote)
// Backtick templates (TS/JS) and triple quotes span lines; plain
// quotes stop at the newline so a stray quote can't swallow the block.
let multiline = triple || quote == "`"
var end = start + delimiter.count
while end < chars.count {
if chars[end] == "\\" {
end += 2
continue
}
if self.matches(chars, at: end, delimiter) {
end += delimiter.count
break
}
if !multiline, chars[end] == "\n" { break }
end += 1
}
end = min(end, chars.count)
tokens.append(ChatCodeToken(kind: .string, text: String(chars[start..<end])))
return end
}
private static func matches(_ chars: [Character], at index: Int, _ needle: String) -> Bool {
let needleChars = Array(needle)
guard index + needleChars.count <= chars.count else { return false }
for offset in 0..<needleChars.count where chars[index + offset] != needleChars[offset] {
return false
}
return true
}
private static func color(for kind: ChatCodeTokenKind) -> Color? {
switch kind {
case .plain: nil
case .keyword: ChatCodeSyntaxPalette.keyword
case .string: ChatCodeSyntaxPalette.string
case .comment: ChatCodeSyntaxPalette.comment
case .number: ChatCodeSyntaxPalette.number
}
}
// MARK: - Language profiles
private static let swift = Language(
keywords: [
"actor", "any", "as", "associatedtype", "async", "await", "break", "case", "catch",
"class", "continue", "convenience", "default", "defer", "deinit", "do", "else", "enum",
"extension", "fallthrough", "false", "fileprivate", "final", "for", "func", "guard",
"if", "import", "in", "indirect", "init", "inout", "internal", "is", "lazy", "let",
"mutating", "nil", "nonisolated", "open", "operator", "optional", "override", "private",
"protocol", "public", "repeat", "required", "rethrows", "return", "self", "some",
"static", "struct", "subscript", "super", "switch", "throw", "throws", "true", "try",
"typealias", "unowned", "var", "weak", "where", "while",
],
lineCommentPrefixes: ["//"],
blockComment: Language.BlockComment(open: "/*", close: "*/"),
nestedBlockComments: true,
stringDelimiters: ["\""],
supportsTripleQuotes: true)
private static let kotlin = Language(
keywords: [
"abstract", "as", "break", "by", "catch", "class", "companion", "constructor",
"continue", "crossinline", "data", "do", "else", "enum", "false", "final", "finally",
"for", "fun", "if", "import", "in", "init", "inline", "interface", "internal", "is",
"lateinit", "null", "object", "open", "out", "override", "package", "private",
"protected", "public", "reified", "return", "sealed", "super", "suspend", "this",
"throw", "true", "try", "typealias", "val", "var", "vararg", "when", "where", "while",
],
lineCommentPrefixes: ["//"],
blockComment: Language.BlockComment(open: "/*", close: "*/"),
nestedBlockComments: true,
stringDelimiters: ["\""],
supportsTripleQuotes: true)
private static let typescript = Language(
keywords: [
"abstract", "any", "as", "async", "await", "boolean", "break", "case", "catch", "class",
"const", "continue", "declare", "default", "delete", "do", "else", "enum", "export",
"extends", "false", "finally", "for", "from", "function", "get", "if", "implements",
"import", "in", "infer", "instanceof", "interface", "is", "keyof", "let", "namespace",
"never", "new", "null", "number", "of", "private", "protected", "public", "readonly",
"return", "set", "static", "string", "super", "switch", "symbol", "this", "throw",
"true", "try", "type", "typeof", "undefined", "unknown", "var", "void", "while",
"yield",
],
lineCommentPrefixes: ["//"],
blockComment: Language.BlockComment(open: "/*", close: "*/"),
stringDelimiters: ["\"", "'", "`"],
supportsTripleQuotes: false)
private static let python = Language(
keywords: [
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "case",
"class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
"global", "if", "import", "in", "is", "lambda", "match", "nonlocal", "not", "or",
"pass", "raise", "return", "self", "try", "while", "with", "yield",
],
lineCommentPrefixes: ["#"],
blockComment: nil,
stringDelimiters: ["\"", "'"],
supportsTripleQuotes: true)
private static let bash = Language(
keywords: [
"alias", "case", "cd", "declare", "do", "done", "echo", "elif", "else", "esac",
"exit", "export", "false", "fi", "for", "function", "if", "in", "local", "readonly",
"return", "select", "set", "shift", "source", "then", "true", "unset", "until",
"while",
],
lineCommentPrefixes: ["#"],
lineCommentNeedsBoundary: true,
blockComment: nil,
stringDelimiters: ["\"", "'"],
supportsTripleQuotes: false)
private static let json = Language(
keywords: ["false", "null", "true"],
lineCommentPrefixes: [],
blockComment: nil,
stringDelimiters: ["\""],
supportsTripleQuotes: false)
}
/// Content-keyed memo so streaming re-renders reuse finished highlight work
/// instead of re-tokenizing every completed block on each delta tick.
/// Public so app file previews reuse the chat renderer's highlighting.
@MainActor
public enum ChatCodeHighlightCache {
private static var cache: [String: AttributedString] = [:]
// Bounded: wholesale reset past the cap keeps long sessions flat; a miss
// just re-tokenizes one visible block, which is cheap.
private static let capacity = 160
public static func highlighted(code: String, languageId: String?) -> AttributedString {
guard ChatCodeHighlighter.language(for: languageId) != nil,
ChatCodeHighlighter.isWithinHighlightLimits(code)
else {
return AttributedString(code)
}
let key = "\(languageId ?? "")\u{0}\(code)"
if let hit = self.cache[key] { return hit }
let value = ChatCodeHighlighter.attributedCode(code, languageId: languageId)
if self.cache.count >= self.capacity {
self.cache.removeAll(keepingCapacity: true)
}
self.cache[key] = value
return value
}
}
enum ChatCodeSyntaxPalette {
static var keyword: Color {
self.adaptive(light: (0.68, 0.24, 0.64), dark: (1.00, 0.48, 0.70))
}
static var string: Color {
self.adaptive(light: (0.82, 0.18, 0.11), dark: (1.00, 0.51, 0.44))
}
static var number: Color {
self.adaptive(light: (0.15, 0.16, 0.85), dark: (0.85, 0.79, 0.49))
}
static var comment: Color {
self.adaptive(light: (0.44, 0.50, 0.55), dark: (0.50, 0.55, 0.60))
}
private static func adaptive(
light: (Double, Double, Double),
dark: (Double, Double, Double)) -> Color
{
#if os(macOS)
// Semantic NSColor resolution is unreliable for arbitrary appearances
// in SwiftPM (see OpenClawChatTheme); use an explicit dynamic provider.
Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let value = isDark ? dark : light
return NSColor(calibratedRed: value.0, green: value.1, blue: value.2, alpha: 1)
}))
#else
Color(uiColor: UIColor { traits in
let value = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: value.0, green: value.1, blue: value.2, alpha: 1)
})
#endif
}
}