diff --git a/cmd/gh-aw-wasm/main.go b/cmd/gh-aw-wasm/main.go index c54c8a0402..ba47909fc2 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/editor.js b/docs/public/editor/editor.js index 202e8cded2..7727a7995e 100644 --- a/docs/public/editor/editor.js +++ b/docs/public/editor/editor.js @@ -45,6 +45,8 @@ const divider = $('divider'); const panelEditor = $('panelEditor'); const panelOutput = $('panelOutput'); const panels = $('panels'); +const tabBar = $('tabBar'); +const tabAdd = $('tabAdd'); // --------------------------------------------------------------- // State @@ -56,6 +58,11 @@ let autoCompile = true; 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 // --------------------------------------------------------------- @@ -116,14 +123,103 @@ function syncLineNumberScroll() { lineNumbers.scrollTop = editor.scrollTop; } +// --------------------------------------------------------------- +// File tabs +// --------------------------------------------------------------- +function getFile(name) { + return files.find(f => f.name === name); +} + +function renderTabs() { + tabBar.querySelectorAll('.tab').forEach(el => el.remove()); + + 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); + + 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) { + const current = getFile(activeTab); + if (current) current.content = editor.value; + + 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(); + if (getFile(trimmed)) { switchTab(trimmed); return; } + + 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. +`; + + files.push({ name: trimmed, content: defaultImportContent }); + switchTab(trimmed); +} + +function removeTab(name) { + if (name === MAIN_FILE) return; + files = files.filter(f => f.name !== name); + if (activeTab === name) { + switchTab(MAIN_FILE); + } else { + renderTabs(); + } + if (autoCompile && isReady) scheduleCompile(); +} + +tabAdd.addEventListener('click', addTab); + // --------------------------------------------------------------- // Editor setup // --------------------------------------------------------------- editor.value = DEFAULT_CONTENT; updateLineNumbers(); +renderTabs(); editor.addEventListener('input', () => { updateLineNumbers(); + const file = getFile(activeTab); + if (file) file.content = editor.value; if (autoCompile && isReady) scheduleCompile(); }); @@ -160,11 +256,25 @@ function scheduleCompile() { compileTimer = setTimeout(doCompile, 400); } +function getImportFiles() { + 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) { clearTimeout(compileTimer); compileTimer = null; } - const md = editor.value; + // 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'; @@ -181,7 +291,8 @@ async function doCompile() { 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'); diff --git a/docs/public/wasm/compiler-loader.js b/docs/public/wasm/compiler-loader.js index 5456013886..5cb8778356 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 d98b63f595..5d9349a151 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 ac4f1c74e2..6bb7256996 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 c56e478cf9..61e4b0fd7a 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 377ac295cb..012453d728 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 34b9a2ab0b..574b629d95 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 0000000000..f3d1c8556e --- /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 0000000000..89d9bc1c0c --- /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 d4e2ed04ee..6b0dc12d10 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) }