From dfdbb50b835c757687aaeaae6327fec44e382b42 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 17 Feb 2026 21:00:41 +0000 Subject: [PATCH 1/2] feat: add import support to wasm playground editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable `imports:` resolution in the browser-based wasm compiler by introducing a virtual filesystem. The editor now supports multiple file tabs — the main workflow file plus any shared imports — and passes them to the Go wasm runtime as a files map. Changes: - Add virtual_fs.go / virtual_fs_wasm.go for in-memory file storage - Replace os.ReadFile with readFileFunc in import/include/yaml parsers - Update remote_fetch_wasm.go to check virtual FS instead of os.Stat - Accept optional files object in compileWorkflow(md, files) - Thread files through compiler-worker.js and compiler-loader.js - Add file tab UI to the playground editor (add/remove/switch tabs) Co-Authored-By: Claude Opus 4.6 --- cmd/gh-aw-wasm/main.go | 49 +++-- docs/public/editor/index.html | 273 ++++++++++++++++++++++++++-- docs/public/wasm/compiler-loader.js | 14 +- docs/public/wasm/compiler-worker.js | 6 +- pkg/parser/import_processor.go | 5 +- pkg/parser/include_expander.go | 3 +- pkg/parser/include_processor.go | 2 +- pkg/parser/remote_fetch_wasm.go | 9 +- pkg/parser/virtual_fs.go | 8 + pkg/parser/virtual_fs_wasm.go | 43 +++++ pkg/parser/yaml_import.go | 3 +- 11 files changed, 375 insertions(+), 40 deletions(-) create mode 100644 pkg/parser/virtual_fs.go create mode 100644 pkg/parser/virtual_fs_wasm.go diff --git a/cmd/gh-aw-wasm/main.go b/cmd/gh-aw-wasm/main.go index c54c8a04021..ba47909fc28 100644 --- a/cmd/gh-aw-wasm/main.go +++ b/cmd/gh-aw-wasm/main.go @@ -5,6 +5,7 @@ package main import ( "syscall/js" + "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/workflow" ) @@ -14,21 +15,25 @@ func main() { } // compileWorkflow is the JS-callable function. -// Usage: compileWorkflow(markdownString) → Promise<{yaml, warnings, error}> +// Usage: compileWorkflow(markdownString, filesObject?) → Promise<{yaml, warnings, error}> // -// Only a single argument (the markdown string) is accepted. -// Import resolution is not currently supported in the Wasm build. +// Arguments: +// - markdownString: the main workflow markdown content +// - filesObject (optional): a JS object mapping file paths to content strings, +// used for import resolution (e.g. {"shared/tools.md": "---\ntools:..."}) func compileWorkflow(this js.Value, args []js.Value) any { if len(args) < 1 { - return newRejectedPromise("compileWorkflow requires exactly 1 argument: markdown string") - } - - if len(args) > 1 { - return newRejectedPromise("compileWorkflow accepts only 1 argument; importResolver is not supported in the Wasm build") + return newRejectedPromise("compileWorkflow requires at least 1 argument: markdown string") } markdown := args[0].String() + // Extract virtual files from optional second argument + var files map[string][]byte + if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() { + files = jsObjectToFileMap(args[1]) + } + var handler js.Func handler = js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { resolve := promiseArgs[0] @@ -37,7 +42,7 @@ func compileWorkflow(this js.Value, args []js.Value) any { go func() { defer handler.Release() - result, err := doCompile(markdown) + result, err := doCompile(markdown, files) if err != nil { reject.Invoke(js.Global().Get("Error").New(err.Error())) return @@ -51,8 +56,30 @@ func compileWorkflow(this js.Value, args []js.Value) any { return js.Global().Get("Promise").New(handler) } -// doCompile performs the actual compilation entirely in memory — no filesystem access. -func doCompile(markdown string) (js.Value, error) { +// jsObjectToFileMap converts a JS object {path: content, ...} to map[string][]byte. +func jsObjectToFileMap(obj js.Value) map[string][]byte { + files := make(map[string][]byte) + + // Use Object.keys() to iterate over the JS object + keys := js.Global().Get("Object").Call("keys", obj) + length := keys.Length() + for i := 0; i < length; i++ { + key := keys.Index(i).String() + value := obj.Get(key).String() + files[key] = []byte(value) + } + + return files +} + +// doCompile performs the actual compilation entirely in memory. +func doCompile(markdown string, files map[string][]byte) (js.Value, error) { + // Set up virtual filesystem for import resolution + if files != nil { + parser.SetVirtualFiles(files) + defer parser.ClearVirtualFiles() + } + compiler := workflow.NewCompiler( workflow.WithNoEmit(true), workflow.WithSkipValidation(true), diff --git a/docs/public/editor/index.html b/docs/public/editor/index.html index 7b167199e3d..0d6c5b8ad29 100644 --- a/docs/public/editor/index.html +++ b/docs/public/editor/index.html @@ -788,6 +788,113 @@ } } +/* ============================================================ + File Tabs + ============================================================ */ +.tab-bar { + display: flex; + align-items: stretch; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-secondary); + min-height: 36px; + overflow-x: auto; + overflow-y: hidden; + user-select: none; + transition: background var(--transition), border-color var(--transition); +} + +.tab-bar::-webkit-scrollbar { + height: 0; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-secondary); + border-right: 1px solid var(--border-secondary); + cursor: pointer; + white-space: nowrap; + transition: all var(--transition); + position: relative; +} + +.tab:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tab.active { + background: var(--bg-input); + color: var(--text-primary); + font-weight: 500; +} + +.tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); +} + +.tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 3px; + padding: 0; + opacity: 0; + transition: all var(--transition); +} + +.tab:hover .tab-close { + opacity: 0.7; +} + +.tab-close:hover { + opacity: 1 !important; + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.tab-add { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + min-width: 32px; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + transition: all var(--transition); +} + +.tab-add:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.tab-bar-right { + margin-left: auto; + display: flex; + align-items: center; + padding-right: 12px; +} + /* ============================================================ Keyboard hint ============================================================ */ @@ -890,15 +997,15 @@
-
- - - Workflow (.md) - - - - Ctrl+Enter - +
+ + +
+ + + Ctrl+Enter + +
@@ -988,6 +1095,8 @@ const panelEditor = $('panelEditor'); const panelOutput = $('panelOutput'); const panels = $('panels'); +const tabBar = $('tabBar'); +const tabAdd = $('tabAdd'); // --------------------------------------------------------------- // State @@ -999,6 +1108,11 @@ let compileTimer = null; let currentYaml = ''; +// File tabs state: ordered list of { name, content } +const MAIN_FILE = 'workflow.md'; +let files = [{ name: MAIN_FILE, content: DEFAULT_CONTENT }]; +let activeTab = MAIN_FILE; + // --------------------------------------------------------------- // Theme // --------------------------------------------------------------- @@ -1068,14 +1182,129 @@ lineNumbers.scrollTop = editor.scrollTop; } +// --------------------------------------------------------------- +// File tabs +// --------------------------------------------------------------- +function getFile(name) { + return files.find(f => f.name === name); +} + +function renderTabs() { + // Remove existing tab elements (but keep the add button and right section) + tabBar.querySelectorAll('.tab').forEach(el => el.remove()); + + // Insert tabs before the add button + for (const file of files) { + const tab = document.createElement('div'); + tab.className = 'tab' + (file.name === activeTab ? ' active' : ''); + tab.dataset.name = file.name; + + const label = document.createElement('span'); + label.textContent = file.name; + tab.appendChild(label); + + // Close button (not for the main workflow file) + if (file.name !== MAIN_FILE) { + const close = document.createElement('button'); + close.className = 'tab-close'; + close.title = 'Remove file'; + close.innerHTML = ''; + close.addEventListener('click', (e) => { + e.stopPropagation(); + removeTab(file.name); + }); + tab.appendChild(close); + } + + tab.addEventListener('click', () => switchTab(file.name)); + tabBar.insertBefore(tab, tabAdd); + } +} + +function switchTab(name) { + // Save current editor content to active file + const current = getFile(activeTab); + if (current) { + current.content = editor.value; + } + + // Switch to new tab + activeTab = name; + const file = getFile(name); + if (file) { + editor.value = file.content; + updateLineNumbers(); + } + + renderTabs(); +} + +function addTab() { + const name = prompt('File path (e.g. shared/my-tools.md):'); + if (!name || !name.trim()) return; + + const trimmed = name.trim(); + + // Check for duplicates + if (getFile(trimmed)) { + switchTab(trimmed); + return; + } + + // Create default content for shared imports + const defaultImportContent = [ + '---', + '# Shared workflow component', + '# This file can define: tools, steps, engine, mcp-servers, etc.', + 'tools:', + ' - name: example_tool', + ' description: An example tool', + '---', + '', + '# Instructions', + '', + 'Add your shared workflow instructions here.', + '' + ].join('\n'); + + files.push({ name: trimmed, content: defaultImportContent }); + switchTab(trimmed); +} + +function removeTab(name) { + if (name === MAIN_FILE) return; + + files = files.filter(f => f.name !== name); + + // If we removed the active tab, switch to main + if (activeTab === name) { + switchTab(MAIN_FILE); + } else { + renderTabs(); + } + + // Trigger recompile since imports changed + if (autoCompile && isReady) { + scheduleCompile(); + } +} + +tabAdd.addEventListener('click', addTab); + // --------------------------------------------------------------- // Editor setup // --------------------------------------------------------------- editor.value = DEFAULT_CONTENT; updateLineNumbers(); +renderTabs(); editor.addEventListener('input', () => { updateLineNumbers(); + // Save to active file + const file = getFile(activeTab); + if (file) { + file.content = editor.value; + } if (autoCompile && isReady) { scheduleCompile(); } @@ -1122,6 +1351,17 @@ compileTimer = setTimeout(doCompile, 400); } +function getImportFiles() { + // Build a map of all non-main files for import resolution + const importFiles = {}; + for (const file of files) { + if (file.name !== MAIN_FILE) { + importFiles[file.name] = file.content; + } + } + return Object.keys(importFiles).length > 0 ? importFiles : undefined; +} + async function doCompile() { if (!isReady || isCompiling) return; if (compileTimer) { @@ -1129,7 +1369,16 @@ compileTimer = null; } - const md = editor.value; + // Always save current editor content + const currentFile = getFile(activeTab); + if (currentFile) { + currentFile.content = editor.value; + } + + // Get the main workflow content + const mainFile = getFile(MAIN_FILE); + const md = mainFile ? mainFile.content : ''; + if (!md.trim()) { outputPre.style.display = 'none'; outputPlaceholder.style.display = 'flex'; @@ -1148,13 +1397,13 @@ warningBanner.classList.remove('visible'); try { - const result = await compiler.compile(md); + const importFiles = getImportFiles(); + const result = await compiler.compile(md, importFiles); if (result.error) { setStatus('error', 'Error'); errorText.textContent = result.error; errorBanner.classList.add('visible'); - // Keep previous output visible but dim? No, just keep it. } else { setStatus('ready', 'Ready'); currentYaml = result.yaml; diff --git a/docs/public/wasm/compiler-loader.js b/docs/public/wasm/compiler-loader.js index 54560138869..5cb87783565 100644 --- a/docs/public/wasm/compiler-loader.js +++ b/docs/public/wasm/compiler-loader.js @@ -8,6 +8,8 @@ * const compiler = createWorkerCompiler(); * await compiler.ready; * const { yaml, warnings, error } = await compiler.compile(markdownString); + * // With imports: + * const { yaml } = await compiler.compile(markdown, { 'shared/tools.md': '...' }); * compiler.terminate(); */ @@ -17,7 +19,7 @@ * @param {Object} [options] * @param {string} [options.workerUrl] - URL to compiler-worker.js * (default: resolves relative to this module) - * @returns {{ compile: (markdown: string) => Promise<{yaml: string, warnings: string[], error: string|null}>, + * @returns {{ compile: (markdown: string, files?: Record) => Promise<{yaml: string, warnings: string[], error: string|null}>, * ready: Promise, * terminate: () => void }} */ @@ -111,9 +113,11 @@ export function createWorkerCompiler(options = {}) { * Compile a markdown workflow string to GitHub Actions YAML. * * @param {string} markdown + * @param {Record} [files] - Optional map of file paths to content + * for import resolution (e.g. {"shared/tools.md": "---\ntools:..."}) * @returns {Promise<{yaml: string, warnings: string[], error: string|null}>} */ - function compile(markdown) { + function compile(markdown, files) { if (isTerminated) { return Promise.reject(new Error('Compiler worker has been terminated.')); } @@ -122,7 +126,11 @@ export function createWorkerCompiler(options = {}) { return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); - worker.postMessage({ type: 'compile', id, markdown }); + const msg = { type: 'compile', id, markdown }; + if (files && Object.keys(files).length > 0) { + msg.files = files; + } + worker.postMessage(msg); }); } diff --git a/docs/public/wasm/compiler-worker.js b/docs/public/wasm/compiler-worker.js index d98b63f5956..5d9349a151c 100644 --- a/docs/public/wasm/compiler-worker.js +++ b/docs/public/wasm/compiler-worker.js @@ -3,7 +3,7 @@ * the compileWorkflow function via postMessage. * * Message protocol (inbound): - * { type: 'compile', id: , markdown: } + * { type: 'compile', id: , markdown: , files?: } * * Message protocol (outbound): * { type: 'ready' } @@ -113,7 +113,9 @@ try { // compileWorkflow returns a Promise (Go side). - var result = await compileWorkflow(msg.markdown); + // Pass optional files object for import resolution. + var files = msg.files || null; + var result = await compileWorkflow(msg.markdown, files); // The Go function returns { yaml: string, warnings: Array, error: null|string } var warnings = []; diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index ac4f1c74e23..6bb72569962 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -3,7 +3,6 @@ package parser import ( "encoding/json" "fmt" - "os" "path" "sort" "strings" @@ -448,7 +447,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a } // Read the imported file to extract nested imports - content, err := os.ReadFile(item.fullPath) + content, err := readFileFunc(item.fullPath) if err != nil { return nil, fmt.Errorf("failed to read imported file '%s': %w", item.fullPath, err) } @@ -841,7 +840,7 @@ func topologicalSortImports(imports []string, baseDir string, cache *ImportCache } // Read and parse the file to extract its imports - content, err := os.ReadFile(fullPath) + content, err := readFileFunc(fullPath) if err != nil { importLog.Printf("Failed to read file %s during topological sort: %v", fullPath, err) dependencies[importPath] = []string{} diff --git a/pkg/parser/include_expander.go b/pkg/parser/include_expander.go index c56e478cf97..61e4b0fd7a7 100644 --- a/pkg/parser/include_expander.go +++ b/pkg/parser/include_expander.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "fmt" - "os" "path/filepath" "strings" ) @@ -163,7 +162,7 @@ func processIncludesForField(content, baseDir string, extractFunc func(string) ( } // Read the included file - fileContent, err := os.ReadFile(fullPath) + fileContent, err := readFileFunc(fullPath) if err != nil { // For any processing errors, fail compilation return nil, "", fmt.Errorf("failed to read included file '%s': %w", fullPath, err) diff --git a/pkg/parser/include_processor.go b/pkg/parser/include_processor.go index 377ac295cbe..012453d7288 100644 --- a/pkg/parser/include_processor.go +++ b/pkg/parser/include_processor.go @@ -117,7 +117,7 @@ func processIncludesWithVisited(content, baseDir string, extractTools bool, visi // processIncludedFileWithVisited processes a single included file with cycle detection for nested includes func processIncludedFileWithVisited(filePath, sectionName string, extractTools bool, visited map[string]bool) (string, error) { includeLog.Printf("Reading included file: %s (extractTools=%t, section=%s)", filePath, extractTools, sectionName) - content, err := os.ReadFile(filePath) + content, err := readFileFunc(filePath) if err != nil { return "", fmt.Errorf("failed to read included file %s: %w", filePath, err) } diff --git a/pkg/parser/remote_fetch_wasm.go b/pkg/parser/remote_fetch_wasm.go index 34b9a2ab0be..574b629d959 100644 --- a/pkg/parser/remote_fetch_wasm.go +++ b/pkg/parser/remote_fetch_wasm.go @@ -4,7 +4,6 @@ package parser import ( "fmt" - "os" "path/filepath" "strings" ) @@ -80,10 +79,12 @@ func ResolveIncludePath(filePath, baseDir string, cache *ImportCache) (string, e return "", fmt.Errorf("security: path %s must be within .github folder (resolves to: %s)", filePath, relativePath) } - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", fullPath) + // In wasm builds, check the virtual filesystem first + if VirtualFileExists(fullPath) { + return fullPath, nil } - return fullPath, nil + + return "", fmt.Errorf("file not found: %s", fullPath) } func isWorkflowSpec(path string) bool { diff --git a/pkg/parser/virtual_fs.go b/pkg/parser/virtual_fs.go new file mode 100644 index 00000000000..f3d1c8556e8 --- /dev/null +++ b/pkg/parser/virtual_fs.go @@ -0,0 +1,8 @@ +package parser + +import "os" + +// readFileFunc is the function used to read file contents throughout the parser. +// In wasm builds, this is overridden to read from a virtual filesystem +// populated by the browser via SetVirtualFiles. +var readFileFunc = os.ReadFile diff --git a/pkg/parser/virtual_fs_wasm.go b/pkg/parser/virtual_fs_wasm.go new file mode 100644 index 00000000000..89d9bc1c0ce --- /dev/null +++ b/pkg/parser/virtual_fs_wasm.go @@ -0,0 +1,43 @@ +//go:build js || wasm + +package parser + +import "fmt" + +// virtualFiles holds in-memory file contents for wasm builds. +// Keys are resolved file paths (e.g. "shared/elastic-tools.md"). +var virtualFiles map[string][]byte + +// SetVirtualFiles populates the virtual filesystem for wasm import resolution. +// Call this before compiling a workflow that uses imports. +// The keys should be file paths relative to the workflow directory +// (e.g. "shared/elastic-tools.md"). +func SetVirtualFiles(files map[string][]byte) { + virtualFiles = files +} + +// ClearVirtualFiles removes all virtual files. +func ClearVirtualFiles() { + virtualFiles = nil +} + +// VirtualFileExists checks if a path exists in the virtual filesystem. +func VirtualFileExists(path string) bool { + if virtualFiles == nil { + return false + } + _, ok := virtualFiles[path] + return ok +} + +func init() { + // Override readFileFunc in wasm builds to check virtual files first. + readFileFunc = func(path string) ([]byte, error) { + if virtualFiles != nil { + if content, ok := virtualFiles[path]; ok { + return content, nil + } + } + return nil, fmt.Errorf("file not found in virtual filesystem: %s", path) + } +} diff --git a/pkg/parser/yaml_import.go b/pkg/parser/yaml_import.go index d4e2ed04eeb..6b0dc12d109 100644 --- a/pkg/parser/yaml_import.go +++ b/pkg/parser/yaml_import.go @@ -3,7 +3,6 @@ package parser import ( "encoding/json" "fmt" - "os" "path/filepath" "strings" @@ -74,7 +73,7 @@ func processYAMLWorkflowImport(filePath string) (jobs string, services string, e yamlImportLog.Printf("Processing YAML workflow import: %s", filePath) // Read the YAML file - content, err := os.ReadFile(filePath) + content, err := readFileFunc(filePath) if err != nil { return "", "", fmt.Errorf("failed to read YAML file: %w", err) } From c8c7958c0bccab2e85bbb1cef66f1cc753d1caa2 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 17 Feb 2026 21:40:50 +0000 Subject: [PATCH 2/2] fix: add schema metadata to test lock content for compatibility The schema versioning change (e995df1d4) requires lock files to have a gh-aw-metadata comment. Update the test lock content to include it. Co-Authored-By: Claude Opus 4.6 --- pkg/workflow/action_sha_validation_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/action_sha_validation_test.go b/pkg/workflow/action_sha_validation_test.go index b4cd683e35f..abdc3cfeca3 100644 --- a/pkg/workflow/action_sha_validation_test.go +++ b/pkg/workflow/action_sha_validation_test.go @@ -205,7 +205,8 @@ func TestActionSHAValidationSavesCache(t *testing.T) { // Create a lock file with an action lockFile := filepath.Join(testDir, "test-workflow.lock.yml") - lockContent := `name: Test Workflow + lockContent := `# gh-aw-metadata: {"schema_version":"v1","compiler_version":"test"} +name: Test Workflow on: push jobs: test: