From 447f9e29748aa4bed2d3d75516adfc0bb37b2799 Mon Sep 17 00:00:00 2001 From: Gianni Massi Date: Thu, 4 Jun 2026 12:29:24 +0200 Subject: [PATCH 01/12] feat(editor): scoped file I/O service + --editor mode skeleton Add additive --editor mode: a FileService scoped to a root dir with listDir/readFile/writeFile, rejecting ../ and symlink escapes. Wired through two seams: a fileOp WKScriptMessageHandler (GUI) and a fileop stdin command (CI test seam, no GUI needed). Adds the editor HTML/CSS/JS shell (lazy file tree, open/edit/save, markdown preview+source tabs) and a navigation-policy guard that cancels non-agent:// navigations and opens external links in the system browser. --markdown/--a2ui/--url untouched. Covered by scripts/editor-smoke.sh + Makefile arg-parse tests + JS syntax check. --- Makefile | 5 + scripts/check-js-syntax.py | 2 + scripts/editor-smoke.sh | 65 +++++ src/main.swift | 573 ++++++++++++++++++++++++++++++++++++- 4 files changed, 635 insertions(+), 10 deletions(-) create mode 100755 scripts/editor-smoke.sh diff --git a/Makefile b/Makefile index b700c74..ca70b58 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,9 @@ test: $(BINARY) @echo '{"surfaceUpdate":{"components":[{"id":"root","component":{"Column":{"children":{"explicitList":["doc","btn"]}}}},{"id":"doc","component":{"MarkdownDoc":{"fieldName":"review","text":"# Hi\n\nHello."}}},{"id":"btn","component":{"Button":{"label":{"literalString":"OK"},"action":{"name":"ok"}}}}]}}{"beginRendering":{"root":"root"}}' | ./$(BINARY) --a2ui --timeout 1 2>&1 | grep -qv '"error"' && echo "PASS: MarkdownDoc renders without error" || (echo "FAIL: MarkdownDoc errored" && exit 1) @echo '# Hi' | ./$(BINARY) --markdown --timeout 1 2>/dev/null | grep -qv '"error"' && echo "PASS: --markdown stdin test with heading renders" || (echo "FAIL: --markdown stdin rendering" && exit 1) @printf '' | ./$(BINARY) --markdown --timeout 1 2>/dev/null | grep -q 'no markdown provided on stdin' && echo "PASS: empty markdown stdin yields error message" || (echo "FAIL: empty markdown stdin" && exit 1) + @./$(BINARY) --editor 2>&1 | grep -q "requires a path" && echo "PASS: --editor with no path rejects" || (echo "FAIL: --editor no path" && exit 1) + @./$(BINARY) --editor /tmp --a2ui 2>&1 | grep -q "mutually exclusive" && echo "PASS: --editor --a2ui rejects" || (echo "FAIL: --editor --a2ui mutual exclusion" && exit 1) + @./$(BINARY) --editor /tmp --markdown 2>&1 | grep -q "mutually exclusive" && echo "PASS: --editor --markdown rejects" || (echo "FAIL: --editor --markdown mutual exclusion" && exit 1) + @strings $(BINARY) | grep -q "__editorBoot" && echo "PASS: editor JS embedded in binary" || (echo "FAIL: editor JS not embedded" && exit 1) + @bash scripts/editor-smoke.sh ./$(BINARY) || (echo "FAIL: editor smoke (see above)" && exit 1) @echo "All smoke tests pass" diff --git a/scripts/check-js-syntax.py b/scripts/check-js-syntax.py index 5490146..4cc568b 100644 --- a/scripts/check-js-syntax.py +++ b/scripts/check-js-syntax.py @@ -49,6 +49,8 @@ def main() -> int: ("a2uiRendererJS", extract_plain(content, "a2uiRendererJS")), ("markdownRendererJS", extract_raw(content, "markdownRendererJS")), ("micromarkJS", extract_raw(content, "micromarkJS")), + ("editorJS", extract_raw(content, "editorJS")), + ("highlightJS", extract_raw(content, "highlightJS")), ] ok = True diff --git a/scripts/editor-smoke.sh b/scripts/editor-smoke.sh new file mode 100755 index 0000000..ba66d88 --- /dev/null +++ b/scripts/editor-smoke.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Binary smoke test for --editor mode. Drives the `fileop` stdin protocol +# against a throwaway temp dir and asserts on stdout + on-disk effects. +# This is the CI-testable seam for the Swift FileService (no GUI needed): +# the same code services the GUI's `fileOp` message handler. +set -u + +BIN="${1:-./webview-cli}" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/webview-editor-smoke.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "FAIL: $1"; exit 1; } + +# Fixture tree. +mkdir -p "$TMP/sub" +printf '# Title\n\nBody.\n' > "$TMP/readme.md" +printf 'alpha\nbeta\n' > "$TMP/sub/notes.txt" +printf 'secret\n' > "$TMP/.hidden" + +# op -> stdout (data object), exits binary via --timeout fallback. +op() { + printf '%s\n' "$1" | "$BIN" --editor "$TMP" --timeout 3 2>/dev/null +} + +# 1. listDir root: dirs first, dotfiles hidden. +OUT="$(op '{"type":"fileop","op":"listDir","path":""}')" +echo "$OUT" | grep -q '"ok":true' || fail "listDir not ok: $OUT" +echo "$OUT" | grep -q '"name":"sub"' || fail "listDir missing sub dir: $OUT" +echo "$OUT" | grep -q '"name":"readme.md"' || fail "listDir missing readme.md: $OUT" +echo "$OUT" | grep -q '.hidden' && fail "listDir leaked dotfile: $OUT" +# dir 'sub' must sort before file 'readme.md' +echo "$OUT" | grep -oE '"name":"(sub|readme.md)"' | head -1 | grep -q sub || fail "dirs not sorted first: $OUT" +echo "PASS: editor listDir (dirs first, dotfiles hidden)" + +# 2. readFile round-trips UTF-8 content. +OUT="$(op '{"type":"fileop","op":"readFile","path":"readme.md"}')" +echo "$OUT" | grep -q '"ok":true' || fail "readFile not ok: $OUT" +echo "$OUT" | grep -q '# Title' || fail "readFile missing content: $OUT" +echo "PASS: editor readFile round-trips content" + +# 3. writeFile persists to disk. +op '{"type":"fileop","op":"writeFile","path":"created.txt","content":"written by smoke\n"}' >/dev/null +[ -f "$TMP/created.txt" ] || fail "writeFile did not create file" +grep -q 'written by smoke' "$TMP/created.txt" || fail "writeFile content mismatch" +echo "PASS: editor writeFile persists to disk" + +# 4. Path escape via ../ is rejected (read + write). +OUT="$(op '{"type":"fileop","op":"readFile","path":"../../../../etc/hosts"}')" +echo "$OUT" | grep -q 'escapes root' || fail "readFile escape not rejected: $OUT" +OUT="$(op '{"type":"fileop","op":"writeFile","path":"../escape.txt","content":"x"}')" +echo "$OUT" | grep -q 'escapes root' || fail "writeFile escape not rejected: $OUT" +[ -f "$TMP/../escape.txt" ] && fail "escape write actually landed on disk" +echo "PASS: editor rejects ../ path escapes (read + write)" + +# 5. Opening a file path uses its parent dir as the root. +OUT="$(printf '%s\n' '{"type":"fileop","op":"listDir","path":""}' | "$BIN" --editor "$TMP/readme.md" --timeout 3 2>/dev/null)" +echo "$OUT" | grep -q '"name":"readme.md"' || fail "file-as-root did not open parent dir: $OUT" +echo "PASS: editor file-as-root opens parent directory" + +# 6. Nonexistent root errors cleanly (exit 3, error JSON). +OUT="$("$BIN" --editor "$TMP/does-not-exist" --timeout 3 2>/dev/null)" +echo "$OUT" | grep -q '"status":"error"' || fail "missing root did not error: $OUT" +echo "PASS: editor missing root emits error" + +echo "All editor smoke tests pass" diff --git a/src/main.swift b/src/main.swift index a0eae68..2c3bd2f 100644 --- a/src/main.swift +++ b/src/main.swift @@ -19,6 +19,8 @@ struct Config { var allowHtml: Bool = false var folderMode: Bool = false var folderPath: String? = nil + var editorMode: Bool = false + var editorRoot: String = "" } func parseArgs() -> Config? { @@ -46,6 +48,14 @@ func parseArgs() -> Config? { config.a2ui = true case "--markdown": config.markdownMode = true + case "--editor": + config.editorMode = true + // Consume the next token as the path only if it's not another flag. + // A missing path is reported by validation ("--editor requires a path"). + if i + 1 < args.count && !args[i + 1].hasPrefix("-") { + i += 1 + config.editorRoot = args[i] + } case "--comments": config.comments = true case "--edits": @@ -80,21 +90,18 @@ func parseArgs() -> Config? { } // Validate mutual exclusion - let modeCount = [config.a2ui, config.markdownMode, config.folderMode].filter { $0 }.count + let modeCount = [config.a2ui, config.markdownMode, config.folderMode, config.editorMode].filter { $0 }.count if modeCount > 1 { - writeStderr("Error: --a2ui, --markdown, and --folder are mutually exclusive") - return nil - } - if config.markdownMode && !config.url.isEmpty { - writeStderr("Error: --markdown and --url are mutually exclusive") + writeStderr("Error: --a2ui, --markdown, --folder, and --editor are mutually exclusive") return nil } - if config.folderMode && !config.url.isEmpty { - writeStderr("Error: --folder and --url are mutually exclusive") + + if config.editorMode && config.editorRoot.isEmpty { + writeStderr("Error: --editor requires a path") return nil } - if !config.a2ui && !config.markdownMode && !config.folderMode && config.url.isEmpty { return nil } + if !config.a2ui && !config.markdownMode && !config.folderMode && !config.editorMode && config.url.isEmpty { return nil } return config } @@ -114,6 +121,9 @@ func printUsage() { --folder [path] Folder browser mode: browse files in a directory. Opens path if given, otherwise shows a folder picker. Markdown files render inline; other files open externally. + --editor Text editor mode: opens a file or directory with a file + tree, syntax highlighting, markdown preview + link + following. Edits save to disk in place. Options: --title Window title (default: "webview-cli") @@ -212,6 +222,122 @@ class AgentSchemeHandler: NSObject, WKURLSchemeHandler { } } +// MARK: - File Service (editor mode) + +/// Filesystem access scoped to a single root directory. Every path is resolved +/// against `root` and rejected if it escapes (via `..` or a symlink pointing +/// outside). Used by both the `fileOp` JS bridge and the `fileop` stdin test seam. +struct FileService { + let root: URL + static let maxReadBytes = 4 * 1024 * 1024 // 4MB cap on readFile + + /// `rootPath` may be a directory (opened directly) or a file (its parent + /// directory becomes the root, and the file is the initial selection). + init?(rootPath: String) { + let expanded = (rootPath as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded).resolvingSymlinksInPath().standardizedFileURL + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) else { return nil } + self.root = isDir.boolValue ? url : url.deletingLastPathComponent() + } + + /// Resolve a relative (or absolute) path to a URL guaranteed to live under + /// `root`. Returns nil on any escape — both the lexical (`..`) and the + /// symlink-resolved forms must stay inside the root. + func resolve(_ path: String) -> URL? { + let trimmed = path.trimmingCharacters(in: .whitespaces) + let base: URL = trimmed.hasPrefix("/") + ? URL(fileURLWithPath: trimmed) + : root.appendingPathComponent(trimmed) + let lexical = base.standardizedFileURL + let symlinkResolved = base.resolvingSymlinksInPath().standardizedFileURL + for candidate in [lexical, symlinkResolved] { + let p = candidate.path + if p != root.path && !p.hasPrefix(root.path + "/") { return nil } + } + return lexical + } + + /// Path of `url` relative to `root` ("" for the root itself). + func relPath(_ url: URL) -> String { + let p = url.standardizedFileURL.path + if p == root.path { return "" } + if p.hasPrefix(root.path + "/") { return String(p.dropFirst(root.path.count + 1)) } + return url.lastPathComponent + } + + func listDir(_ path: String) -> [String: Any] { + guard let dir = resolve(path) else { return ["ok": false, "error": "path escapes root"] } + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { + return ["ok": false, "error": "not a directory"] + } + guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { + return ["ok": false, "error": "cannot read directory"] + } + var entries: [[String: Any]] = [] + for name in names { + if name.hasPrefix(".") { continue } // hide dotfiles by default + let child = dir.appendingPathComponent(name) + var childIsDir: ObjCBool = false + FileManager.default.fileExists(atPath: child.path, isDirectory: &childIsDir) + entries.append([ + "name": name, + "path": relPath(child), + "type": childIsDir.boolValue ? "dir" : "file" + ]) + } + // Directories first, then alphabetical (case-insensitive). + entries.sort { + let ad = ($0["type"] as? String) == "dir" + let bd = ($1["type"] as? String) == "dir" + if ad != bd { return ad } + return ($0["name"] as? String ?? "").lowercased() < ($1["name"] as? String ?? "").lowercased() + } + return ["ok": true, "path": relPath(dir), "entries": entries] + } + + func readFile(_ path: String) -> [String: Any] { + guard let file = resolve(path) else { return ["ok": false, "error": "path escapes root"] } + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: file.path, isDirectory: &isDir), !isDir.boolValue else { + return ["ok": false, "error": "not a file"] + } + guard let data = try? Data(contentsOf: file) else { return ["ok": false, "error": "cannot read file"] } + if data.count > FileService.maxReadBytes { + return ["ok": false, "error": "file too large (>\(FileService.maxReadBytes / (1024*1024))MB)", "binary": true] + } + guard let content = String(data: data, encoding: .utf8) else { + return ["ok": false, "error": "not a UTF-8 text file", "binary": true] + } + return ["ok": true, "path": relPath(file), "content": content] + } + + func writeFile(_ path: String, content: String) -> [String: Any] { + guard let file = resolve(path) else { return ["ok": false, "error": "path escapes root"] } + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: file.path, isDirectory: &isDir), isDir.boolValue { + return ["ok": false, "error": "is a directory"] + } + do { + try content.data(using: .utf8)?.write(to: file, options: .atomic) + return ["ok": true, "path": relPath(file)] + } catch { + return ["ok": false, "error": "write failed: \(error.localizedDescription)"] + } + } + + /// Dispatch a parsed operation dict to the right method. + func perform(op: String, path: String, content: String?) -> [String: Any] { + switch op { + case "listDir": return listDir(path) + case "readFile": return readFile(path) + case "writeFile": return writeFile(path, content: content ?? "") + default: return ["ok": false, "error": "unknown op: \(op)"] + } + } +} + // MARK: - Stdin Reader class StdinReader { @@ -259,6 +385,10 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS static let recentFoldersKey = "webview-cli.recentFolders" static let maxRecentFolders = 10 + // Editor mode state + var fileService: FileService? + var stdinReader: StdinReader? + init(config: Config) { self.config = config super.init() @@ -270,6 +400,8 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS contentController.add(self, name: "complete") contentController.add(self, name: "ready") contentController.add(self, name: "navigate") + contentController.add(self, name: "fileOp") + contentController.add(self, name: "openExternal") let errorScript = WKUserScript( source: """ @@ -323,6 +455,8 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS setupMarkdownMode() } else if config.folderMode { setupFolderMode() + } else if config.editorMode { + setupEditorMode() } else { guard let url = URL(string: config.url), url.scheme != nil else { emitAndExit(status: "error", message: "Invalid URL (must include scheme): \(config.url)", code: 3) @@ -374,6 +508,76 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS webView.load(URLRequest(url: URL(string: "agent://host/index.html")!)) } + func setupEditorMode() { + // Initialize the scoped file service. Bail early with a clear error if + // the root path doesn't exist. + guard let fs = FileService(rootPath: config.editorRoot) else { + emitAndExit(status: "error", message: "editor root not found: \(config.editorRoot)", code: 3) + return + } + fileService = fs + + // Load the editor renderer + shared markdown stack + highlighter. + schemeHandler.loadRawResource(path: "index.html", content: editorHTML) + schemeHandler.loadRawResource(path: "editor.js", content: editorJS) + schemeHandler.loadRawResource(path: "editor.css", content: editorCSS) + schemeHandler.loadRawResource(path: "styles.css", content: a2uiRendererCSS) + schemeHandler.loadRawResource(path: "micromark.js", content: micromarkJS) + schemeHandler.loadRawResource(path: "markdown-renderer.js", content: markdownRendererJS) + schemeHandler.loadRawResource(path: "highlight.js", content: highlightJS) + + // Start the stdin command reader (powers the `fileop` test seam and `close`). + stdinReader = StdinReader(coordinator: self) + stdinReader?.start() + + webView.load(URLRequest(url: URL(string: "agent://host/index.html")!)) + } + + /// Tell the editor JS what root + initial selection to boot with. Called + /// once the renderer signals ready. + func bootstrapEditor() { + guard let fs = fileService else { return } + // If the user passed a file path, select it; otherwise just open the root. + let expanded = (config.editorRoot as NSString).expandingTildeInPath + let target = URL(fileURLWithPath: expanded).resolvingSymlinksInPath().standardizedFileURL + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: target.path, isDirectory: &isDir) + let initialFile = isDir.boolValue ? "" : fs.relPath(target) + let info: [String: Any] = [ + "root": fs.root.lastPathComponent, + "initialFile": initialFile + ] + if let data = try? JSONSerialization.data(withJSONObject: info), + let json = String(data: data, encoding: .utf8) { + let b64 = Data(json.utf8).base64EncodedString() + let js = "window.__editorBoot && window.__editorBoot(new TextDecoder('utf-8').decode(Uint8Array.from(atob('\(b64)'), c => c.charCodeAt(0))))" + webView.evaluateJavaScript(js) { _, err in + if let err = err { writeStderr("[editor] boot JS error: \(err)") } + } + } + } + + /// Run a file operation and return the JSON-serializable result dict. + func runFileOp(op: String, path: String, content: String?) -> [String: Any] { + guard let fs = fileService else { return ["ok": false, "error": "no file service"] } + return fs.perform(op: op, path: path, content: content) + } + + /// Reply to a `fileOp` JS request by calling window.__fileOpReply(id, json). + func replyFileOp(id: Any, result: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: result), + let json = String(data: data, encoding: .utf8) else { return } + let b64 = Data(json.utf8).base64EncodedString() + let idLiteral: String + if let n = id as? Int { idLiteral = String(n) } + else if let s = id as? String { idLiteral = "\"\(s)\"" } + else { idLiteral = "0" } + let js = "window.__fileOpReply && window.__fileOpReply(\(idLiteral), new TextDecoder('utf-8').decode(Uint8Array.from(atob('\(b64)'), c => c.charCodeAt(0))))" + webView.evaluateJavaScript(js) { _, err in + if let err = err { writeStderr("[editor] fileOp reply error: \(err)") } + } + } + func readA2UIFromStdin() { DispatchQueue.global(qos: .userInitiated).async { var lines: [String] = [] @@ -506,7 +710,15 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS // MARK: - Stdin Commands (for agent:// mode) - func handleStdinCommand(_ line: String) { + func handleStdinCommand(_ chunk: String) { + // A single read may contain multiple newline-delimited commands. + for line in chunk.split(separator: "\n", omittingEmptySubsequences: true) { + handleStdinLine(String(line).trimmingCharacters(in: .whitespaces)) + } + } + + private func handleStdinLine(_ line: String) { + guard !line.isEmpty else { return } guard let data = line.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = json["type"] as? String else { @@ -523,6 +735,14 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS webView.load(URLRequest(url: URL(string: navigateTo)!)) } } + case "fileop": + // Test/debug seam: run a file op and emit the result on stdout, then exit. + // The GUI path uses the `fileOp` message handler instead. + let op = json["op"] as? String ?? "" + let path = json["path"] as? String ?? "" + let content = json["content"] as? String + let result = runFileOp(op: op, path: path, content: content) + emitAndExit(status: "fileop", data: result, code: 0) case "close": emitAndExit(status: "cancelled", code: 1) default: @@ -550,6 +770,22 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS let path = body["path"] as? String { handleNavigation(to: path) } + case "fileOp": + guard let body = message.body as? [String: Any] else { return } + let id = body["id"] ?? 0 + let op = body["op"] as? String ?? "" + let path = body["path"] as? String ?? "" + let content = body["content"] as? String + let result = runFileOp(op: op, path: path, content: content) + replyFileOp(id: id, result: result) + case "openExternal": + if let body = message.body as? [String: Any], + let urlStr = body["url"] as? String, + let url = URL(string: urlStr), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + NSWorkspace.shared.open(url) + } default: break } } @@ -561,7 +797,32 @@ class AppCoordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate, NS // A2UI renderer JS is loaded — safe to inject data now rendererReady = true flushA2UIIfReady() + } else if config.editorMode { + rendererReady = true + bootstrapEditor() + } + } + + // MARK: - Navigation policy (editor mode link guard) + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard config.editorMode, let url = navigationAction.request.url else { + decisionHandler(.allow); return + } + let scheme = url.scheme?.lowercased() ?? "" + // Allow the renderer's own agent:// resources and the initial load. + if scheme == "agent" || scheme == "about" { + decisionHandler(.allow); return + } + // Anything else (http/https/file) must NOT navigate the webview — that + // would blow away the editor. External links are opened by the JS + // openExternal bridge; internal links are routed through openFile. + if scheme == "http" || scheme == "https" { + NSWorkspace.shared.open(url) } + decisionHandler(.cancel) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { @@ -1864,6 +2125,298 @@ let markdownRendererJS = #""" """# +// MARK: - Embedded Editor (--editor mode) + +let editorHTML = #""" +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Editor + + + + +
+ +
+
+
+
+
+ + + + + + +"""# + +let editorCSS = #""" +html, body { height: 100%; padding: 0; margin: 0; overflow: hidden; } +#editor-app { display: flex; flex-direction: row; height: 100vh; width: 100vw; } +#editor-sidebar { + width: 240px; min-width: 160px; max-width: 420px; flex: 0 0 auto; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; overflow: hidden; +} +.editor-root-name { + padding: 10px 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.6px; color: var(--muted); border-bottom: 1px solid var(--border); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.editor-tree { flex: 1; overflow-y: auto; padding: 4px 0; font-size: 13px; } +.editor-tree-row { + display: flex; align-items: center; gap: 6px; padding: 3px 8px; + cursor: pointer; white-space: nowrap; user-select: none; border-radius: 4px; +} +.editor-tree-row:hover { background: var(--surface-2); } +.editor-tree-row.active { background: var(--accent); color: #fff; } +.editor-tree-icon { width: 12px; display: inline-block; color: var(--muted); font-size: 10px; flex: 0 0 auto; } +.editor-tree-row.active .editor-tree-icon { color: #fff; } +.editor-tree-label { overflow: hidden; text-overflow: ellipsis; } +.editor-tree-error { padding: 6px 12px; color: var(--danger); font-size: 11px; } +.editor-tree-children { } +#editor-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); } +.editor-tabbar { + display: flex; align-items: center; gap: 8px; padding: 6px 12px; + border-bottom: 1px solid var(--border); min-height: 36px; +} +.editor-tab-name { font-size: 12px; color: var(--text); font-weight: 600; } +.editor-tab-btn { + padding: 4px 12px; font-size: 11px; border: 1px solid var(--border); border-radius: 5px; + background: var(--surface); color: var(--text); cursor: pointer; +} +.editor-tab-btn:hover:not(:disabled) { filter: brightness(1.15); } +.editor-tab-btn:disabled { opacity: 0.4; cursor: default; } +.editor-tab-btn.save-active { background: var(--success); color: #1a1a1c; border-color: transparent; } +.editor-content { flex: 1; overflow: auto; padding: 16px 20px; display: flex; flex-direction: column; } +.editor-empty { color: var(--muted); font-size: 13px; font-style: italic; } +.editor-source { + flex: 1; width: 100%; min-height: 200px; padding: 10px 12px; + font: 13px/1.6 'SF Mono', Monaco, Menlo, 'Courier New', monospace; + background: var(--bg); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; resize: none; tab-size: 4; +} +.editor-source:focus { outline: none; border-color: var(--accent); } +.editor-md-tabs { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--border); } +.editor-md-tab { + padding: 5px 12px; font-size: 11px; background: transparent; border: none; + border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer; +} +.editor-md-tab:hover { color: var(--text); } +.editor-md-tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } +.editor-md-preview { flex: 1; overflow: auto; } +"""# + +// Syntax highlighter — populated in a later slice. Stub returns null so callers +// fall back to plain (escaped) text until the real tokenizer lands. +let highlightJS = #""" +(function(){ + 'use strict'; + window.highlightCode = function(code, lang){ return null; }; +})(); +"""# + +let editorJS = #""" +(function(){ + 'use strict'; + let ROOT = ''; + let opSeq = 0; + const opCb = new Map(); + + // ---- fileOp bridge (JS -> Swift -> __fileOpReply) ---- + function fileOp(op, path, content){ + return new Promise((resolve) => { + const id = ++opSeq; + const mh = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.fileOp; + if (mh) { + opCb.set(id, resolve); + mh.postMessage({ id: id, op: op, path: path, content: content == null ? null : content }); + } else if (window.__fileOpMock) { + // jsdom / test path: a mock services the request synchronously or via promise. + Promise.resolve(window.__fileOpMock(op, path, content)).then(resolve); + } else { + resolve({ ok: false, error: 'no bridge' }); + } + }); + } + window.__fileOpReply = function(id, json){ + const cb = opCb.get(id); + if (!cb) return; + opCb.delete(id); + let r; + try { r = JSON.parse(json); } catch(e) { r = { ok: false, error: 'bad reply json' }; } + cb(r); + }; + window.__editorFileOp = fileOp; // exposed for tests + + function el(tag, cls, txt){ + const e = document.createElement(tag); + if (cls) e.className = cls; + if (txt != null) e.textContent = txt; + return e; + } + + // ---- file tree (lazy-expanding) ---- + const expanded = new Set(); + async function buildTreeLevel(path, container, depth){ + const res = await fileOp('listDir', path); + if (!res || !res.ok) { + container.appendChild(el('div', 'editor-tree-error', '! ' + ((res && res.error) || 'error'))); + return; + } + for (const entry of res.entries) { + const row = el('div', 'editor-tree-row ' + (entry.type === 'dir' ? 'is-dir' : 'is-file')); + row.style.paddingLeft = (depth * 12 + 8) + 'px'; + row.dataset.path = entry.path; + row.dataset.type = entry.type; + const icon = el('span', 'editor-tree-icon', entry.type === 'dir' ? '▸' : '·'); + row.appendChild(icon); + row.appendChild(el('span', 'editor-tree-label', entry.name)); + container.appendChild(row); + if (entry.type === 'dir') { + const kids = el('div', 'editor-tree-children'); + kids.style.display = 'none'; + container.appendChild(kids); + row.addEventListener('click', async (e) => { + e.stopPropagation(); + if (expanded.has(entry.path)) { + expanded.delete(entry.path); kids.style.display = 'none'; icon.textContent = '▸'; + } else { + expanded.add(entry.path); kids.style.display = ''; icon.textContent = '▾'; + if (!kids.dataset.loaded) { kids.dataset.loaded = '1'; await buildTreeLevel(entry.path, kids, depth + 1); } + } + }); + } else { + row.addEventListener('click', (e) => { e.stopPropagation(); openFile(entry.path); }); + } + } + } + async function renderTree(){ + const tree = document.getElementById('editor-tree'); + tree.innerHTML = ''; + await buildTreeLevel('', tree, 0); + } + window.__editorRenderTree = renderTree; // exposed for tests + + // ---- open / edit / save ---- + const MD_EXT = /\.(md|markdown|mdown|mkd|mdx)$/i; + let current = { path: null, original: '', dirty: false, getText: null }; + + function markActive(path){ + document.querySelectorAll('.editor-tree-row.is-file').forEach(r => { + r.classList.toggle('active', r.dataset.path === path); + }); + } + + function renderTabbar(path, dirty){ + const bar = document.getElementById('editor-tabbar'); + bar.innerHTML = ''; + if (!path) return; + const name = path.split('/').pop(); + bar.appendChild(el('span', 'editor-tab-name', (dirty ? '● ' : '') + name)); + const spacer = el('span', 'editor-tab-spacer'); spacer.style.flex = '1'; bar.appendChild(spacer); + const save = el('button', 'editor-tab-btn' + (dirty ? ' save-active' : ''), 'Save'); + save.title = 'Save (⌘S)'; + save.disabled = !dirty; + save.addEventListener('click', saveCurrent); + bar.appendChild(save); + } + + function setDirty(d){ current.dirty = d; renderTabbar(current.path, d); } + + async function saveCurrent(){ + if (!current.path || !current.dirty) return; + const text = current.getText ? current.getText() : current.original; + const res = await fileOp('writeFile', current.path, text); + if (res && res.ok) { current.original = text; setDirty(false); } + else { console.error('save failed', res); } + } + window.__editorSave = saveCurrent; // exposed for tests + + function openText(path, text, content){ + const ta = el('textarea', 'editor-source'); + ta.value = text; ta.spellcheck = false; + ta.addEventListener('input', () => setDirty(ta.value !== current.original)); + current.getText = () => ta.value; + content.appendChild(ta); + renderTabbar(path, false); + ta.focus(); + } + + function openMarkdown(path, text, content){ + const tabs = el('div', 'editor-md-tabs'); + const tPrev = el('button', 'editor-md-tab active', 'Preview'); tPrev.dataset.tab = 'preview'; + const tSrc = el('button', 'editor-md-tab', 'Source'); tSrc.dataset.tab = 'source'; + tabs.appendChild(tPrev); tabs.appendChild(tSrc); + content.appendChild(tabs); + const preview = el('div', 'editor-md-preview a2ui-markdown-preview'); + const ta = el('textarea', 'editor-source'); ta.value = text; ta.spellcheck = false; ta.style.display = 'none'; + content.appendChild(preview); content.appendChild(ta); + function rerender(){ if (window.renderMarkdown) window.renderMarkdown(ta.value, preview, {}); } + rerender(); + ta.addEventListener('input', () => setDirty(ta.value !== current.original)); + current.getText = () => ta.value; + function switchTab(name){ + if (name === 'preview') { + rerender(); + preview.style.display = ''; ta.style.display = 'none'; + tPrev.classList.add('active'); tSrc.classList.remove('active'); + } else { + preview.style.display = 'none'; ta.style.display = ''; + tSrc.classList.add('active'); tPrev.classList.remove('active'); + } + } + tPrev.addEventListener('click', () => switchTab('preview')); + tSrc.addEventListener('click', () => switchTab('source')); + renderTabbar(path, false); + } + + async function openFile(path){ + const res = await fileOp('readFile', path); + const content = document.getElementById('editor-content'); + content.innerHTML = ''; + if (!res || !res.ok) { + content.appendChild(el('div', 'editor-empty', + ((res && res.binary) ? 'Binary or oversized file: ' : 'Cannot open: ') + ((res && res.error) || path))); + current = { path: null, original: '', dirty: false, getText: null }; + renderTabbar(null, false); + return; + } + current = { path: path, original: res.content, dirty: false, getText: null }; + markActive(path); + if (MD_EXT.test(path)) openMarkdown(path, res.content, content); + else openText(path, res.content, content); + } + window.__editorOpenFile = openFile; // exposed for tests + window.__editorCurrent = () => current; // exposed for tests + + // ---- boot ---- + window.__editorBoot = function(json){ + let info; + try { info = JSON.parse(json); } catch(e) { info = {}; } + ROOT = info.root || ''; + const rn = document.getElementById('editor-root-name'); + if (rn) rn.textContent = ROOT || '/'; + renderTree().then(() => { if (info.initialFile) openFile(info.initialFile); }); + }; + + // Global Cmd/Ctrl+S to save. + document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + saveCurrent(); + } + }); +})(); +"""# + + // MARK: - Main guard let config = parseArgs() else { From 43c2f6882246edd7158b8fd0ad12952243e628f6 Mon Sep 17 00:00:00 2001 From: Gianni Massi Date: Thu, 4 Jun 2026 12:31:08 +0200 Subject: [PATCH 02/12] test(editor): headless jsdom smoke for editor JS UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the editor JS in jsdom with a mock fileOp backend: boot + lazy tree render (dirs first), directory expansion, markdown preview+tabs, edit→dirty→Save round-trip through writeFile, plain source editor for non-markdown, and the binary/oversized error state. Wired into make test. --- Makefile | 1 + scripts/editor-runtime-smoke.mjs | 129 +++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 scripts/editor-runtime-smoke.mjs diff --git a/Makefile b/Makefile index ca70b58..fc8a73a 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ install: $(BINARY) test: $(BINARY) @python3 scripts/check-js-syntax.py || (echo "FAIL: embedded JS has syntax errors (see above)" && exit 1) @node scripts/runtime-smoke.mjs || (echo "FAIL: runtime smoke (see above) — embedded JS functionally broken" && exit 1) + @node scripts/editor-runtime-smoke.mjs || (echo "FAIL: editor runtime smoke (see above) — editor JS functionally broken" && exit 1) @./$(BINARY) --help 2>&1 >/dev/null | head -1 | grep -q Usage && echo "PASS: --help prints usage to stderr" || (echo "FAIL: --help" && exit 1) @echo '{}' | ./$(BINARY) --a2ui --timeout 1 2>/dev/null | grep -q status && echo "PASS: a2ui smoke" || (echo "FAIL: a2ui smoke" && exit 1) @./$(BINARY) --url "not-a-valid-url" 2>/dev/null | grep -q '"error"' && echo "PASS: invalid URL emits error JSON" || (echo "FAIL: invalid URL" && exit 1) diff --git a/scripts/editor-runtime-smoke.mjs b/scripts/editor-runtime-smoke.mjs new file mode 100644 index 0000000..68fc54a --- /dev/null +++ b/scripts/editor-runtime-smoke.mjs @@ -0,0 +1,129 @@ +// Headless runtime smoke for --editor mode JS. Loads the editor JS stack into +// jsdom with a mock fileOp backend and exercises the real UI code paths: +// 1. boot + lazy file tree render (dirs first) +// 2. expanding a directory lazily lists its children +// 3. opening a markdown file renders a preview + Source/Source tabs +// 4. editing the source marks dirty and Save round-trips through writeFile +// 5. opening a non-markdown file shows a plain source editor +// 6. opening a binary/oversized file shows the error state (no crash) +// +// jsdom can only cover the JS half; the Swift FileService is covered by +// scripts/editor-smoke.sh against the real binary. + +import { readFileSync } from "node:fs"; +import { JSDOM } from "jsdom"; + +const SRC = "src/main.swift"; +const src = readFileSync(SRC, "utf8"); + +function extractRaw(name) { + const m = src.match(new RegExp(`(?:^|\\n)let ${name} = #"""\\n([\\s\\S]*?)\\n"""#`)); + return m ? m[1] : null; +} + +const micromark = extractRaw("micromarkJS"); +const markdownRenderer = extractRaw("markdownRendererJS"); +const highlight = extractRaw("highlightJS"); +const editor = extractRaw("editorJS"); + +for (const [name, val] of [["micromarkJS", micromark], ["markdownRendererJS", markdownRenderer], ["highlightJS", highlight], ["editorJS", editor]]) { + if (!val) { console.error(`FAIL: could not extract ${name}`); process.exit(1); } +} + +// Minimal editor DOM scaffold (mirrors editorHTML). +const dom = new JSDOM(` +
+ +
+
+`, { runScripts: "outside-only", url: "agent://host/index.html" }); + +const w = dom.window; +// Editor uses window.webkit.messageHandlers.fileOp when present; leave it absent +// so fileOp falls through to __fileOpMock. +w.HTMLElement.prototype.scrollIntoView = function () {}; + +for (const [name, js] of [["micromarkJS", micromark], ["markdownRendererJS", markdownRenderer], ["highlightJS", highlight], ["editorJS", editor]]) { + try { w.eval(js); } catch (e) { console.error(`FAIL: ${name} threw on load:`, e.message); process.exit(1); } +} + +// In-memory mock filesystem. +const FS = { + "": { dir: true, entries: [ + { name: "sub", path: "sub", type: "dir" }, + { name: "readme.md", path: "readme.md", type: "file" }, + { name: "data.json", path: "data.json", type: "file" }, + ]}, + "sub": { dir: true, entries: [{ name: "notes.txt", path: "sub/notes.txt", type: "file" }] }, + "readme.md": { content: "# Title\n\nBody **bold**.\n" }, + "data.json": { content: '{\n "a": 1\n}\n' }, + "sub/notes.txt": { content: "alpha\nbeta\n" }, +}; +w.__fileOpMock = (op, path, content) => { + if (op === "listDir") { const e = FS[path]; return e && e.dir ? { ok: true, path, entries: e.entries } : { ok: false, error: "not a dir" }; } + if (op === "readFile") { const e = FS[path]; return e && e.content != null ? { ok: true, path, content: e.content } : { ok: false, error: "not a file" }; } + if (op === "writeFile") { FS[path] = { content }; return { ok: true, path }; } + return { ok: false, error: "unknown op" }; +}; + +const tick = () => new Promise(r => setTimeout(r, 10)); + +// 1. Boot + tree render. +w.__editorBoot(JSON.stringify({ root: "edroot", initialFile: "" })); +await tick(); await tick(); +const rows = () => Array.from(w.document.querySelectorAll("#editor-tree .editor-tree-row")); +let topRows = rows(); +if (topRows.length < 3) { console.error("FAIL: expected >=3 top-level tree rows, got", topRows.length); process.exit(1); } +if (w.document.getElementById("editor-root-name").textContent !== "edroot") { console.error("FAIL: root name not set"); process.exit(1); } +// Dirs first: the first row must be the 'sub' directory. +if (!topRows[0].classList.contains("is-dir") || topRows[0].dataset.path !== "sub") { + console.error("FAIL: dirs not sorted first; first row is", topRows[0].dataset.path); process.exit(1); +} +console.log("PASS: editor boots and renders file tree (dirs first)"); + +// 2. Expand a directory lazily. +const subRow = topRows.find(r => r.dataset.path === "sub"); +subRow.dispatchEvent(new w.Event("click", { bubbles: true })); +await tick(); await tick(); +const notesRow = rows().find(r => r.dataset.path === "sub/notes.txt"); +if (!notesRow) { console.error("FAIL: expanding 'sub' did not reveal notes.txt"); process.exit(1); } +console.log("PASS: expanding a directory lazily lists children"); + +// 3. Open a markdown file -> preview rendered + tabs. +await w.__editorOpenFile("readme.md"); +await tick(); +const preview = w.document.querySelector(".editor-md-preview"); +if (!preview || !preview.querySelector("h1")) { console.error("FAIL: markdown preview missing

"); process.exit(1); } +if (preview.querySelector("h1").textContent !== "Title") { console.error("FAIL: preview

wrong:", preview.querySelector("h1").textContent); process.exit(1); } +if (!w.document.querySelector(".editor-md-tabs")) { console.error("FAIL: markdown tabs missing"); process.exit(1); } +console.log("PASS: opening markdown renders preview + tabs"); + +// 4. Edit source -> dirty -> save round-trips through writeFile. +const srcArea = w.document.querySelector(".editor-content textarea.editor-source"); +if (!srcArea) { console.error("FAIL: markdown source textarea missing"); process.exit(1); } +srcArea.value = "# Title\n\nEdited body.\n"; +srcArea.dispatchEvent(new w.Event("input", { bubbles: true })); +if (!w.__editorCurrent().dirty) { console.error("FAIL: editing did not mark dirty"); process.exit(1); } +await w.__editorSave(); +await tick(); +if (FS["readme.md"].content !== "# Title\n\nEdited body.\n") { console.error("FAIL: save did not persist via writeFile, got:", JSON.stringify(FS["readme.md"].content)); process.exit(1); } +if (w.__editorCurrent().dirty) { console.error("FAIL: still dirty after save"); process.exit(1); } +console.log("PASS: editing marks dirty and Save round-trips through writeFile"); + +// 5. Open a non-markdown file -> plain source editor (no preview tabs). +await w.__editorOpenFile("data.json"); +await tick(); +if (w.document.querySelector(".editor-md-tabs")) { console.error("FAIL: non-markdown file should not show markdown tabs"); process.exit(1); } +const codeArea = w.document.querySelector(".editor-content textarea.editor-source"); +if (!codeArea || !codeArea.value.includes('"a": 1')) { console.error("FAIL: code file did not open in source editor"); process.exit(1); } +console.log("PASS: non-markdown file opens in plain source editor"); + +// 6. Error state for a file the backend rejects. +w.__fileOpMock = (op) => ({ ok: false, error: "not a UTF-8 text file", binary: true }); +await w.__editorOpenFile("blob.bin"); +await tick(); +const empty = w.document.querySelector(".editor-content .editor-empty"); +if (!empty || !/Binary or oversized/.test(empty.textContent)) { console.error("FAIL: binary file error state missing"); process.exit(1); } +console.log("PASS: binary/oversized file shows error state without crashing"); + +console.log("All editor runtime smoke checks pass"); From 12d083a1c5aeb22e4709bcce79d4b3eca7b302b4 Mon Sep 17 00:00:00 2001 From: Gianni Massi Date: Thu, 4 Jun 2026 12:34:25 +0200 Subject: [PATCH 03/12] feat(editor): syntax highlighting for code files + fenced markdown blocks Compact dependency-free tokenizer (window.highlightCode): a single generic scanner driven by per-language keyword/comment/string config, covering js/ts, python, go, rust, swift, json, yaml, bash, sql, css, and a c-like fallback. HTML is always escaped (XSS-safe). Code files open in a live highlighted editor (transparent textarea over a painted
); fenced blocks in markdown preview are highlighted in place via highlightCodeBlocks. highlightLangFor maps extensions. jsdom smoke covers tokenization, escaping, extension mapping, and both render paths.
---
 scripts/editor-runtime-smoke.mjs |  45 +++++++-
 src/main.swift                   | 172 +++++++++++++++++++++++++++++--
 2 files changed, 206 insertions(+), 11 deletions(-)

diff --git a/scripts/editor-runtime-smoke.mjs b/scripts/editor-runtime-smoke.mjs
index 68fc54a..29d58bf 100644
--- a/scripts/editor-runtime-smoke.mjs
+++ b/scripts/editor-runtime-smoke.mjs
@@ -110,13 +110,25 @@ if (FS["readme.md"].content !== "# Title\n\nEdited body.\n") { console.error("FA
 if (w.__editorCurrent().dirty) { console.error("FAIL: still dirty after save"); process.exit(1); }
 console.log("PASS: editing marks dirty and Save round-trips through writeFile");
 
-// 5. Open a non-markdown file -> plain source editor (no preview tabs).
+// 5. Open a recognized code file -> highlighted code editor (no markdown tabs).
 await w.__editorOpenFile("data.json");
 await tick();
-if (w.document.querySelector(".editor-md-tabs")) { console.error("FAIL: non-markdown file should not show markdown tabs"); process.exit(1); }
-const codeArea = w.document.querySelector(".editor-content textarea.editor-source");
-if (!codeArea || !codeArea.value.includes('"a": 1')) { console.error("FAIL: code file did not open in source editor"); process.exit(1); }
-console.log("PASS: non-markdown file opens in plain source editor");
+if (w.document.querySelector(".editor-md-tabs")) { console.error("FAIL: code file should not show markdown tabs"); process.exit(1); }
+const codeInput = w.document.querySelector(".editor-content .editor-code .editor-code-input");
+if (!codeInput || !codeInput.value.includes('"a": 1')) { console.error("FAIL: code file did not open in code editor"); process.exit(1); }
+const codePre = w.document.querySelector(".editor-content .editor-code pre.editor-code-hl code");
+if (!codePre || !codePre.querySelector(".hl-str") || !codePre.querySelector(".hl-num")) {
+  console.error("FAIL: code editor not highlighted; pre HTML:", codePre && codePre.innerHTML); process.exit(1);
+}
+console.log("PASS: code file opens in highlighted code editor");
+
+// 5b. Plain (unrecognized extension) file -> plain source editor.
+await w.__editorOpenFile("sub/notes.txt");
+await tick();
+const plainArea = w.document.querySelector(".editor-content textarea.editor-source");
+if (!plainArea || !plainArea.value.includes("alpha")) { console.error("FAIL: .txt did not open in plain source editor"); process.exit(1); }
+if (w.document.querySelector(".editor-content .editor-code")) { console.error("FAIL: .txt should not use highlighted code editor"); process.exit(1); }
+console.log("PASS: unrecognized extension opens in plain source editor");
 
 // 6. Error state for a file the backend rejects.
 w.__fileOpMock = (op) => ({ ok: false, error: "not a UTF-8 text file", binary: true });
@@ -126,4 +138,27 @@ const empty = w.document.querySelector(".editor-content .editor-empty");
 if (!empty || !/Binary or oversized/.test(empty.textContent)) { console.error("FAIL: binary file error state missing"); process.exit(1); }
 console.log("PASS: binary/oversized file shows error state without crashing");
 
+// 7. Highlighter unit checks.
+const hl = w.highlightCode("const x = 42; // note", "javascript");
+if (!/hl-kw[^>]*>const]*>42]*>\/\/ note]*>'hi']*>'hi''", "javascript");
+if (/