From 608e472465f3e76b25558238390cfdc642316676 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 21 Mar 2026 20:00:51 +0100 Subject: [PATCH] feat: VS Code extension + rivet serve --watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. vscode-rivet extension: - LSP client connecting to rivet lsp via stdio - WebView panel embedding rivet serve dashboard - Commands: showDashboard, showGraph, showSTPA, validate, addArtifact - Activates on workspaceContains:rivet.yaml - Status bar with port display - Auto-starts rivet serve --watch in background - Bidirectional navigation (dashboard → editor via postMessage) 2. rivet serve --watch: - Watches rivet.yaml, sources, schemas, docs for changes - Debounced at 300ms, filters to .yaml/.yml/.md files - Auto-POSTs /reload on change (existing endpoint) - Port 0 support for auto-assigned ports - Uses notify crate v7 Implements: FEAT-057 Refs: REQ-007 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 119 +++++++++++++- rivet-cli/Cargo.toml | 1 + rivet-cli/src/main.rs | 23 ++- rivet-cli/src/serve/mod.rs | 165 ++++++++++++++++++- vscode-rivet/.vscodeignore | 7 + vscode-rivet/bin/esbuild.js | 14 ++ vscode-rivet/package.json | 89 ++++++++++ vscode-rivet/src/extension.ts | 301 ++++++++++++++++++++++++++++++++++ vscode-rivet/tsconfig.json | 16 ++ 9 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 vscode-rivet/.vscodeignore create mode 100644 vscode-rivet/bin/esbuild.js create mode 100644 vscode-rivet/package.json create mode 100644 vscode-rivet/src/extension.ts create mode 100644 vscode-rivet/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 13e4152..45ca112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1017,6 +1028,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1541,6 +1561,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "intrusive-collections" version = "0.9.7" @@ -1696,6 +1745,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "la-arena" version = "0.3.1" @@ -1738,7 +1807,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1868,6 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1889,6 +1962,34 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2000,7 +2101,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2049,6 +2150,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -2344,6 +2451,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -2464,6 +2580,7 @@ dependencies = [ "log", "lsp-server", "lsp-types", + "notify", "petgraph 0.7.1", "rivet-core", "serde", diff --git a/rivet-cli/Cargo.toml b/rivet-cli/Cargo.toml index 0cc2540..2b5e02d 100644 --- a/rivet-cli/Cargo.toml +++ b/rivet-cli/Cargo.toml @@ -32,6 +32,7 @@ petgraph = { workspace = true } urlencoding = { workspace = true } lsp-server = "0.7" lsp-types = "0.97" +notify = "7" [dev-dependencies] serde_json = { workspace = true } diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 8bd299d..9f19cca 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -382,12 +382,15 @@ enum Command { /// Start the HTMX-powered dashboard server Serve { - /// Port to listen on + /// Port to listen on (0 = auto-assign) #[arg(short = 'P', long, default_value = "3000")] port: u16, /// Address to bind to (default: 127.0.0.1, localhost only) #[arg(short = 'B', long, default_value = "127.0.0.1")] bind: String, + /// Watch filesystem for changes and auto-reload + #[arg(long)] + watch: bool, }, /// Sync external project dependencies into .rivet/repos/ @@ -766,9 +769,10 @@ fn run(cli: Cli) -> Result { format, strict, } => cmd_commits(&cli, since.as_deref(), range.as_deref(), format, *strict), - Command::Serve { port, bind } => { + Command::Serve { port, bind, watch } => { check_for_updates(); let port = *port; + let watch = *watch; let bind = bind.clone(); if bind == "0.0.0.0" || bind == "::" { eprintln!( @@ -785,6 +789,13 @@ fn run(cli: Cli) -> Result { doc_dirs.push(dir); } } + // Collect source dirs for file watcher + let source_paths: Vec = ctx + .config + .sources + .iter() + .map(|s| cli.project.join(&s.path)) + .collect(); let project_name = ctx.config.project.name.clone(); let project_path = std::fs::canonicalize(&cli.project).unwrap_or_else(|_| cli.project.clone()); @@ -796,11 +807,13 @@ fn run(cli: Cli) -> Result { ctx.doc_store.unwrap_or_default(), ctx.result_store.unwrap_or_default(), project_name, - project_path, - schemas_dir, - doc_dirs, + project_path.clone(), + schemas_dir.clone(), + doc_dirs.clone(), port, bind, + watch, + source_paths, ))?; Ok(true) } diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index d359f24..bc0cfab 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -311,6 +311,140 @@ fn reload_state( }) } +/// Spawn a detached background thread that watches the filesystem for changes +/// to artifact YAML files, schema files, and documents, then triggers a reload. +fn spawn_file_watcher( + port: u16, + project_path: &std::path::Path, + schemas_dir: &std::path::Path, + source_paths: &[PathBuf], + doc_dirs: &[PathBuf], +) { + use notify::{RecursiveMode, Watcher}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + let (tx, rx) = mpsc::channel(); + + let mut watcher = + match notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + use notify::EventKind; + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => { + // Filter to only relevant file extensions + let dominated = event.paths.iter().any(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| matches!(ext, "yaml" | "yml" | "md")) + }); + if dominated { + let _ = tx.send(()); + } + } + _ => {} + } + } + }) { + Ok(w) => w, + Err(e) => { + eprintln!("[watch] failed to create file watcher: {e}"); + return; + } + }; + + // Watch rivet.yaml + let rivet_yaml = project_path.join("rivet.yaml"); + if rivet_yaml.exists() { + if let Err(e) = watcher.watch(&rivet_yaml, RecursiveMode::NonRecursive) { + eprintln!("[watch] failed to watch {}: {e}", rivet_yaml.display()); + } + } + + // Watch schemas directory + if schemas_dir.exists() { + if let Err(e) = watcher.watch(schemas_dir, RecursiveMode::Recursive) { + eprintln!("[watch] failed to watch {}: {e}", schemas_dir.display()); + } + } + + // Watch source directories + for src in source_paths { + if src.exists() { + let mode = if src.is_dir() { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + if let Err(e) = watcher.watch(src, mode) { + eprintln!("[watch] failed to watch {}: {e}", src.display()); + } + } + } + + // Watch doc directories + for doc_dir in doc_dirs { + if doc_dir.exists() { + if let Err(e) = watcher.watch(doc_dir, RecursiveMode::Recursive) { + eprintln!("[watch] failed to watch {}: {e}", doc_dir.display()); + } + } + } + + // Detached thread — dies when the process exits + std::thread::spawn(move || { + // Keep the watcher alive for the lifetime of this thread + let _watcher = watcher; + let debounce = Duration::from_millis(300); + let mut last_reload = Instant::now() - debounce; + + loop { + // Block until we get a change notification + if rx.recv().is_err() { + break; // Sender dropped, watcher gone + } + + // Drain any additional events that arrived + while rx.try_recv().is_ok() {} + + // Debounce: skip if we reloaded recently + let elapsed = last_reload.elapsed(); + if elapsed < debounce { + std::thread::sleep(debounce - elapsed); + // Drain again after sleeping + while rx.try_recv().is_ok() {} + } + + eprintln!("[watch] reloading..."); + last_reload = Instant::now(); + + // Fire POST /reload with HX-Request header + match std::net::TcpStream::connect(format!("127.0.0.1:{port}")) { + Ok(mut stream) => { + use std::io::Write; + let request = format!( + "POST /reload HTTP/1.1\r\n\ + Host: 127.0.0.1:{port}\r\n\ + HX-Request: true\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n" + ); + let _ = stream.write_all(request.as_bytes()); + let _ = stream.flush(); + // Read response (don't care about contents, just drain) + let _ = std::io::Read::read_to_end(&mut stream, &mut Vec::new()); + } + Err(e) => { + eprintln!("[watch] failed to connect for reload: {e}"); + } + } + } + }); + + eprintln!("[watch] watching for file changes..."); +} + /// Start the axum HTTP server on the given port. #[allow(clippy::too_many_arguments)] pub async fn run( @@ -325,6 +459,8 @@ pub async fn run( doc_dirs: Vec, port: u16, bind: String, + watch: bool, + source_paths: Vec, ) -> Result<()> { let git = capture_git_info(&project_path); let loaded_at = std::process::Command::new("date") @@ -346,6 +482,11 @@ pub async fn run( let cached_diagnostics = rivet_core::validate::validate(&store, &schema, &graph); + // Clone paths before moving into AppState so they remain available for the watcher. + let project_path_for_watch = project_path.clone(); + let schemas_dir_for_watch = schemas_dir.clone(); + let doc_dirs_for_watch = doc_dirs.clone(); + let state: SharedState = Arc::new(RwLock::new(AppState { store, schema, @@ -402,7 +543,7 @@ pub async fn run( .route("/assets/mermaid.js", get(mermaid_asset)) .route("/reload", post(reload_handler)) .with_state(state.clone()) - .layer(axum::middleware::from_fn_with_state(state, wrap_full_page)) + .layer(axum::middleware::from_fn_with_state(state.clone(), wrap_full_page)) .layer(axum::middleware::map_response( |mut response: axum::response::Response| async move { response.headers_mut().insert( @@ -416,9 +557,27 @@ pub async fn run( )); let addr = format!("{bind}:{port}"); - eprintln!("rivet dashboard listening on http://{bind}:{port}"); - let listener = tokio::net::TcpListener::bind(&addr).await?; + let actual_port = listener.local_addr()?.port(); + + // When port=0 the OS picks a free port — update the stored context. + if actual_port != port { + let mut guard = state.write().await; + guard.context.port = actual_port; + } + + eprintln!("rivet dashboard listening on http://{bind}:{actual_port}"); + + if watch { + spawn_file_watcher( + actual_port, + &project_path_for_watch, + &schemas_dir_for_watch, + &source_paths, + &doc_dirs_for_watch, + ); + } + axum::serve(listener, app).await?; Ok(()) } diff --git a/vscode-rivet/.vscodeignore b/vscode-rivet/.vscodeignore new file mode 100644 index 0000000..32b84ee --- /dev/null +++ b/vscode-rivet/.vscodeignore @@ -0,0 +1,7 @@ +src/** +node_modules/** +.vscode/** +tsconfig.json +bin/** +**/*.ts +**/*.map diff --git a/vscode-rivet/bin/esbuild.js b/vscode-rivet/bin/esbuild.js new file mode 100644 index 0000000..8498319 --- /dev/null +++ b/vscode-rivet/bin/esbuild.js @@ -0,0 +1,14 @@ +const esbuild = require('esbuild'); +const production = process.argv.includes('--production'); + +esbuild.build({ + entryPoints: ['src/extension.ts'], + bundle: true, + outfile: 'out/extension.js', + external: ['vscode'], + format: 'cjs', + platform: 'node', + target: 'node18', + sourcemap: !production, + minify: production, +}).catch(() => process.exit(1)); diff --git a/vscode-rivet/package.json b/vscode-rivet/package.json new file mode 100644 index 0000000..851be1e --- /dev/null +++ b/vscode-rivet/package.json @@ -0,0 +1,89 @@ +{ + "name": "rivet-sdlc", + "displayName": "Rivet SDLC", + "description": "SDLC artifact traceability with live validation, hover info, and embedded dashboard", + "publisher": "pulseengine", + "version": "0.2.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pulseengine/rivet" + }, + "icon": "icon.png", + "galleryBanner": { + "color": "#1a1b27", + "theme": "dark" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Linters", + "Programming Languages", + "Visualization" + ], + "activationEvents": [ + "workspaceContains:rivet.yaml" + ], + "main": "./out/extension", + "contributes": { + "commands": [ + { + "command": "rivet.showDashboard", + "title": "Show Dashboard", + "category": "Rivet" + }, + { + "command": "rivet.showGraph", + "title": "Show Traceability Graph", + "category": "Rivet" + }, + { + "command": "rivet.showSTPA", + "title": "Show STPA Analysis", + "category": "Rivet" + }, + { + "command": "rivet.validate", + "title": "Validate Artifacts", + "category": "Rivet" + }, + { + "command": "rivet.addArtifact", + "title": "Add Artifact", + "category": "Rivet" + } + ], + "configuration": { + "title": "Rivet SDLC", + "properties": { + "rivet.binaryPath": { + "type": "string", + "default": "", + "description": "Path to rivet binary (leave empty to find on PATH)" + }, + "rivet.serve.port": { + "type": "number", + "default": 0, + "description": "Port for the embedded dashboard (0 = auto-assign)" + } + } + } + }, + "dependencies": { + "vscode-languageclient": "^9.0.0" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^22", + "typescript": "^5.9.0", + "esbuild": "^0.25.0" + }, + "scripts": { + "vscode:prepublish": "npm run esbuild -- --production", + "compile": "tsc -b", + "esbuild": "node ./bin/esbuild.js", + "watch": "tsc -b -w", + "package": "npx @vscode/vsce package" + } +} diff --git a/vscode-rivet/src/extension.ts b/vscode-rivet/src/extension.ts new file mode 100644 index 0000000..41b7323 --- /dev/null +++ b/vscode-rivet/src/extension.ts @@ -0,0 +1,301 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as net from 'net'; +import { execFileSync, ChildProcess, spawn } from 'child_process'; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind, +} from 'vscode-languageclient/node'; + +let client: LanguageClient | undefined; +let serveProcess: ChildProcess | undefined; +let dashboardPanel: vscode.WebviewPanel | undefined; +let dashboardPort: number | undefined; +let statusBarItem: vscode.StatusBarItem; + +export async function activate(context: vscode.ExtensionContext) { + // --- Commands --- + context.subscriptions.push( + vscode.commands.registerCommand('rivet.showDashboard', () => showDashboard(context)), + vscode.commands.registerCommand('rivet.showGraph', () => showDashboard(context, '/graph')), + vscode.commands.registerCommand('rivet.showSTPA', () => showDashboard(context, '/stpa')), + vscode.commands.registerCommand('rivet.validate', () => runValidate()), + vscode.commands.registerCommand('rivet.addArtifact', () => addArtifact()), + ); + + // --- Status Bar --- + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 90); + statusBarItem.command = 'rivet.showDashboard'; + statusBarItem.tooltip = 'Open Rivet Dashboard'; + statusBarItem.text = '$(shield) Rivet'; + statusBarItem.show(); + context.subscriptions.push(statusBarItem); + + // --- LSP Client --- + const rivetPath = findRivetBinary(context); + if (!rivetPath) { + statusBarItem.text = '$(shield) Rivet (not found)'; + vscode.window.showWarningMessage( + 'Rivet binary not found. Install with: cargo install rivet-cli' + ); + return; + } + + try { + const serverOptions: ServerOptions = { + command: rivetPath, + args: ['lsp'], + transport: TransportKind.stdio, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: 'file', language: 'yaml', pattern: '**/artifacts/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/safety/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/schemas/**/*.yaml' }, + { scheme: 'file', language: 'yaml', pattern: '**/rivet.yaml' }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher('**/*.yaml'), + }, + }; + + client = new LanguageClient('rivet', 'Rivet SDLC', serverOptions, clientOptions); + await client.start(); + context.subscriptions.push({ dispose: () => client?.stop() }); + + statusBarItem.text = '$(shield) Rivet'; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showWarningMessage(`Rivet LSP failed to start: ${msg}`); + statusBarItem.text = '$(shield) Rivet (LSP error)'; + } + + // --- Start serve --watch in background --- + startServe(context, rivetPath); +} + +export function deactivate() { + if (serveProcess) { + serveProcess.kill(); + serveProcess = undefined; + } + return client?.stop(); +} + +// --- Binary discovery --- + +function findRivetBinary(context: vscode.ExtensionContext): string | undefined { + const configured = vscode.workspace.getConfiguration('rivet').get('binaryPath'); + if (configured && configured.length > 0 && fs.existsSync(configured)) return configured; + + // Check bundled binary + const binaryName = process.platform === 'win32' ? 'rivet.exe' : 'rivet'; + const bundled = path.join(context.extensionPath, 'bin', binaryName); + if (fs.existsSync(bundled)) return bundled; + + // Check PATH + try { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + return execFileSync(cmd, ['rivet'], { encoding: 'utf8' }).trim(); + } catch { + return undefined; + } +} + +// --- Serve process --- + +function startServe(context: vscode.ExtensionContext, rivetPath: string) { + const configuredPort = vscode.workspace.getConfiguration('rivet').get('serve.port') || 0; + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) return; + + // Check rivet.yaml exists + if (!fs.existsSync(path.join(workspaceRoot, 'rivet.yaml'))) return; + + serveProcess = spawn(rivetPath, [ + 'serve', + '--port', String(configuredPort), + '--watch', + ], { + cwd: workspaceRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Capture the port from stdout + serveProcess.stderr?.on('data', (data: Buffer) => { + const line = data.toString(); + const match = line.match(/listening on http:\/\/[\w.]+:(\d+)/); + if (match) { + dashboardPort = parseInt(match[1], 10); + statusBarItem.text = `$(shield) Rivet :${dashboardPort}`; + console.log(`rivet serve started on port ${dashboardPort}`); + } + // Forward watch reload messages + if (line.includes('[watch]')) { + console.log(`rivet: ${line.trim()}`); + } + }); + + serveProcess.on('exit', (code) => { + console.log(`rivet serve exited with code ${code}`); + serveProcess = undefined; + dashboardPort = undefined; + statusBarItem.text = '$(shield) Rivet'; + }); + + context.subscriptions.push({ + dispose: () => { + serveProcess?.kill(); + serveProcess = undefined; + }, + }); +} + +// --- Dashboard WebView --- + +function showDashboard(context: vscode.ExtensionContext, path: string = '/') { + if (!dashboardPort) { + vscode.window.showWarningMessage( + 'Rivet dashboard not running. Waiting for serve to start...' + ); + return; + } + + if (dashboardPanel) { + dashboardPanel.reveal(vscode.ViewColumn.Beside); + // Navigate to the requested path + dashboardPanel.webview.html = getDashboardHtml(dashboardPort, path); + return; + } + + dashboardPanel = vscode.window.createWebviewPanel( + 'rivetDashboard', + 'Rivet Dashboard', + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + }, + ); + + dashboardPanel.webview.html = getDashboardHtml(dashboardPort, path); + + // Handle messages from the webview (e.g., navigate to artifact) + dashboardPanel.webview.onDidReceiveMessage( + (message: { command: string; artifactId?: string; file?: string; line?: number }) => { + if (message.command === 'openArtifact' && message.file) { + const uri = vscode.Uri.file(message.file); + vscode.workspace.openTextDocument(uri).then((doc) => { + const line = message.line || 0; + vscode.window.showTextDocument(doc, { + selection: new vscode.Range(line, 0, line, 0), + viewColumn: vscode.ViewColumn.One, + }); + }); + } + }, + undefined, + context.subscriptions, + ); + + dashboardPanel.onDidDispose(() => { + dashboardPanel = undefined; + }); +} + +function getDashboardHtml(port: number, initialPath: string): string { + const url = `http://127.0.0.1:${port}${initialPath}`; + return ` + + + + + + + + +`; +} + +// --- Validate command --- + +async function runValidate() { + const rivetPath = findRivetBinary({ extensionPath: '' } as vscode.ExtensionContext); + if (!rivetPath) { + vscode.window.showErrorMessage('Rivet binary not found'); + return; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) return; + + try { + const output = execFileSync(rivetPath, ['validate', '--format', 'text'], { + cwd: workspaceRoot, + encoding: 'utf8', + timeout: 30000, + }); + + if (output.includes('PASS')) { + vscode.window.showInformationMessage('Rivet: Validation PASS ✓'); + } else { + const channel = vscode.window.createOutputChannel('Rivet Validate'); + channel.appendLine(output); + channel.show(); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Rivet validation failed: ${msg}`); + } +} + +// --- Add artifact command --- + +async function addArtifact() { + const rivetPath = findRivetBinary({ extensionPath: '' } as vscode.ExtensionContext); + if (!rivetPath) { + vscode.window.showErrorMessage('Rivet binary not found'); + return; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) return; + + // Quick-pick for type + const artifactType = await vscode.window.showQuickPick( + ['requirement', 'design-decision', 'feature', 'loss', 'hazard', 'system-constraint', 'uca'], + { placeHolder: 'Select artifact type' }, + ); + if (!artifactType) return; + + // Input for title + const title = await vscode.window.showInputBox({ + placeHolder: 'Artifact title', + prompt: `Enter title for new ${artifactType}`, + }); + if (!title) return; + + try { + const output = execFileSync(rivetPath, [ + 'add', '-t', artifactType, '--title', title, + ], { + cwd: workspaceRoot, + encoding: 'utf8', + timeout: 10000, + }); + vscode.window.showInformationMessage(`Rivet: ${output.trim()}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to add artifact: ${msg}`); + } +} diff --git a/vscode-rivet/tsconfig.json b/vscode-rivet/tsconfig.json new file mode 100644 index 0000000..deaab40 --- /dev/null +++ b/vscode-rivet/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "outDir": "out", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules"] +}