diff --git a/CHANGELOG.md b/CHANGELOG.md index 82eb173..f0de146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ### Changed +- Show the image's editable virtual `source` filesystem automatically in the + VS Code Explorer without adding a workspace folder. - Require an explicit `runa-cli` subcommand: `repl` starts the interactive session, and each image operation is also available as a one-shot command. - Rename the REPL actor listing command from `actors` to `ps`, remove its @@ -13,6 +15,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- Always prompt for JSON state when Spawn with State is invoked from a source + context menu, including multi-selection menu arguments. - Close extension connections with a WebSocket handshake on disconnect, reconnect, and deactivation instead of terminating the transport immediately. - Exit the REPL on Ctrl+C and complete the WebSocket close handshake instead diff --git a/README.md b/README.md index d3ab0ec..751dad0 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,11 @@ actors created through the REPL or another client. Click an actor to inspect it. Server notifications automatically refresh the tree and open snapshots; **Runa: Refresh Image** is also available on demand. -The extension mounts a **source** workspace directory backed by the image, -including `actors/counter.rn`. Save edits to update the working file without +The **Runa Image Sources** Explorer view automatically shows the image's virtual +filesystem beneath **source**, including `actors/counter.rn`, without adding a +workspace folder. Open a file and save edits to update the image without committing. Right-click a source file to **Spawn** (null state) or **Spawn with -State**, or to commit, browse history, and roll back. Historical revisions +State** (which prompts for JSON), or to commit, browse history, and roll back. Historical revisions remain read-only. Use **Runa: Spawn Actor** to choose a source path and supply JSON initial state. diff --git a/docs/protocol.md b/docs/protocol.md index 6205235..dc72be4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -140,7 +140,8 @@ The stdio transport and child-process client have been removed. The TypeScript VS Code client issues `list_actors`, `inspect_actor`, source file/version-control operations, `spawn`, `send`, `tick`, and `system`. -VS Code mounts writable image working files under a `source` workspace folder, +VS Code automatically displays writable image files under a `source` directory +in its image-source Explorer tree, without modifying workspace folders, and caches read-only commit documents by connection, path, and revision. It preserves i64 inputs and bounds the complete outgoing envelope as well as incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages, diff --git a/vscode/README.md b/vscode/README.md index aea4c3b..389b139 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -24,9 +24,10 @@ In that host: 1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`; `runa.address` changes the suggested endpoint. -2. Open the image's `source` folder (see [Image source files](#image-source-files)). - Right-click a `.rn` file and choose **Spawn Actor** (initial state `null`) or - **Spawn Actor with State** (enter JSON, for example `0` for +2. The **Runa Image Sources** Explorer view automatically shows the image's + `source` directory (see [Image source files](#image-source-files)). + Right-click a `.rn` file and choose **Spawn** (initial state `null`) or + **Spawn with State...** (enter JSON in the input field, for example `0` for `actors/counter`). **Runa: Spawn Actor** in **Runa Actors** prompts for a source path and state. The command reports and returns the new actor reference. Mailbox capacity is 64. @@ -97,11 +98,12 @@ in the Runa output channel and an error notification; commands are never retried ## Image source files -The image owns actor source text. On connect, the extension mounts it as a -writable workspace folder named `source` (`runa-image:///source`). -In a saved multi-root workspace this happens automatically; otherwise accept the -prompt or run **Runa: Add Image Source Folder** (VS Code may restart extensions -when a single-folder window becomes multi-root). Directories are derived from +The image owns actor source text. On connect, **Runa Image Sources** automatically +shows its virtual filesystem beneath `source` (`runa-image:///source`). +No workspace changes, folder prompt, or extension restart is required. +Opening a file reads it from the image; saving writes back to the image. +The optional **Runa: Add Source Folder to Workspace** command is still available +if a regular workspace mount is desired. Directories are derived from source paths: `actors/counter` appears as `source/actors/counter.rn`. Source paths are canonical and exclude the `source/` prefix and `.rn` suffix. @@ -113,6 +115,8 @@ paths are canonical and exclude the `source/` prefix and `.rn` suffix. - **Spawn** uses the latest saved working source, committed or not. If the editor has unsaved changes, spawn and commit ask to save first; cancelling does nothing. Unsaved editor text is never sent implicitly. +- **Spawn with State...** opens a JSON input field; cancelling the field does + not spawn an actor. - **Runa: Commit Source** records the saved working source as a new revision and reports its revision string. - **Runa: Source History** lists revisions for the file and opens the selected diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 0cdda2b..1b83fb1 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -1,12 +1,17 @@ import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; -import { RevisionDocuments } from "../src/sourceProvider"; +import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider"; export async function run(): Promise { const image = await startImage(); + const folders = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.toString()); try { await vscode.commands.executeCommand("runa.connect", image.address); + assert.deepEqual( + vscode.workspace.workspaceFolders?.map((folder) => folder.uri.toString()), + folders, + ); await editSources(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", @@ -134,16 +139,24 @@ export async function run(): Promise { } async function editSources(address: string): Promise { - const folder = vscode.workspace.workspaceFolders?.find( - (candidate) => candidate.uri.scheme === "runa-image", + const extension = vscode.extensions.all.find( + (candidate) => candidate.packageJSON.name === "runa", ); - assert.ok(folder, "connect mounts the image source folder"); - assert.equal(folder.name, "source"); - assert.equal(folder.uri.toString(), `runa-image://${address}/source`); - assert.deepEqual(await vscode.workspace.fs.readDirectory(folder.uri), [ + assert.ok(extension); + const { sourceTree } = extension.exports as { sourceTree: ImageSourceTree }; + const [root] = sourceTree.getChildren(); + assert.ok(root); + assert.equal(root.uri.toString(), `runa-image://${address}/source`); + assert.deepEqual(await vscode.workspace.fs.readDirectory(root.uri), [ ["actors", vscode.FileType.Directory], ]); - const actors = vscode.Uri.joinPath(folder.uri, "actors"); + const [directory] = sourceTree.getChildren(root); + assert.ok(directory); + assert.equal(directory.directory, true); + const [file] = sourceTree.getChildren(directory); + assert.ok(file); + assert.equal(sourceTree.getTreeItem(file).contextValue, "runa.sourceFile"); + const actors = vscode.Uri.joinPath(root.uri, "actors"); assert.deepEqual(await vscode.workspace.fs.readDirectory(actors), [ ["counter.rn", vscode.FileType.File], ]); @@ -204,11 +217,28 @@ async function editSources(address: string): Promise { await control(address, { operation: "inspect_actor", parameters: { actor_ref: nullActor } }), ); assert.match(reply, /"state":\{"type":"null"\}/); - const actor = await vscode.commands.executeCommand( - "runa.spawnSourceWithState", - uri, - "40", - ); + const inputWindow = vscode.window as { showInputBox: (...args: unknown[]) => unknown }; + const originalInput = inputWindow.showInputBox; + const inputs: unknown[][] = []; + let stateAnswer: string | undefined; + inputWindow.showInputBox = async (...args: unknown[]) => { + inputs.push(args); + return stateAnswer; + }; + let actor: string | undefined; + try { + assert.equal( + await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]), + undefined, + ); + stateAnswer = "40"; + actor = await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]); + assert.equal(inputs.length, 2, "context menus always prompt for state"); + assert.match(JSON.stringify(inputs[1]), /Initial state for actors\/counter/); + } finally { + inputWindow.showInputBox = originalInput; + } + assert.match(String(actor), /^[A-Za-z0-9_-]{21}$/); await vscode.commands.executeCommand("runa.send", { actor_ref: actor }, "null"); await vscode.commands.executeCommand("runa.tick", 10); reply = JSON.stringify( @@ -278,7 +308,7 @@ async function editSources(address: string): Promise { assert.equal( vscode.workspace.workspaceFolders?.filter((candidate) => candidate.uri.scheme === "runa-image") .length, - 1, + 0, ); const reopened = await vscode.commands.executeCommand( "runa.sourceHistory", @@ -303,7 +333,7 @@ async function editSources(address: string): Promise { revisions.dispose(); } console.log( - "Runa sources: mounted folder, save, uncommitted spawn, commit, history, rollback, session isolation passed", + "Runa sources: automatic tree, state input, save, uncommitted spawn, commit, history, rollback, session isolation passed", ); } diff --git a/vscode/package.json b/vscode/package.json index a7908e4..a459563 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -14,6 +14,7 @@ "main": "./out/src/extension.js", "activationEvents": [ "onView:runa.actors", + "onView:runa.sources", "onFileSystem:runa-image" ], "scripts": { @@ -163,10 +164,19 @@ { "id": "runa.actors", "name": "Runa Actors" + }, + { + "id": "runa.sources", + "name": "Runa Image Sources" } ] }, "viewsWelcome": [ + { + "view": "runa.sources", + "contents": "Connect to browse and edit the image's virtual filesystem.\n[Connect](command:runa.connect)", + "when": "!runa.connected" + }, { "view": "runa.actors", "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", @@ -175,6 +185,16 @@ ], "menus": { "view/title": [ + { + "command": "runa.connect", + "when": "view == runa.sources && !runa.connected", + "group": "navigation" + }, + { + "command": "runa.refresh", + "when": "view == runa.sources && runa.connected", + "group": "navigation" + }, { "command": "runa.spawn", "when": "view == runa.actors && runa.connected", @@ -200,6 +220,31 @@ } ], "view/item/context": [ + { + "command": "runa.spawnSource", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_1_spawn@1" + }, + { + "command": "runa.spawnSourceWithState", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_1_spawn@2" + }, + { + "command": "runa.commitSource", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_2_source@1" + }, + { + "command": "runa.sourceHistory", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_2_source@2" + }, + { + "command": "runa.rollbackSource", + "when": "view == runa.sources && viewItem == runa.sourceFile && runa.connected", + "group": "runa_2_source@3" + }, { "command": "runa.send", "when": "view == runa.actors && viewItem == runa.actor", diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 5022f08..c6add51 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -5,6 +5,7 @@ import { RefreshQueue } from "./refresh"; import { parseValue } from "./values"; import { ImageSourceFileSystem, + ImageSourceTree, RevisionDocuments, REVISION_SCHEME, SOURCE_SCHEME, @@ -130,12 +131,14 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider { } } -type SourceTarget = vscode.Uri | { path: string } | string | undefined; +type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined; -export function activate(context: vscode.ExtensionContext): void { +export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } { const tree = new ActorTree(); const documents = new SnapshotDocuments(); const sources = new ImageSourceFileSystem(); + const sourceTree = new ImageSourceTree(sources); + const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree }); const revisions = new RevisionDocuments(); const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const output = vscode.window.createOutputChannel("Runa"); @@ -176,6 +179,8 @@ export function activate(context: vscode.ExtensionContext): void { status.command = connected ? "runa.refresh" : "runa.connect"; view.description = connected ? endpoint : "Disconnected"; view.message = connected ? undefined : "Not connected to an image."; + sourceView.description = connected ? endpoint : "Disconnected"; + sourceTree.refresh(); }; const disconnect = () => { @@ -201,6 +206,7 @@ export function activate(context: vscode.ExtensionContext): void { await sources.store.refresh(); if (current !== client || currentSession !== session) return; tree.replace(actors); + sourceTree.refresh(); documents.refresh(session, endpoint, actors); view.message = actors.length ? undefined @@ -232,20 +238,6 @@ export function activate(context: vscode.ExtensionContext): void { return true; }; - const offerSourceFolder = async (address: string) => { - const uri = sourceFolderUri(address); - if (vscode.workspace.workspaceFolders?.some((f) => f.uri.toString() === uri.toString())) return; - if (vscode.workspace.workspaceFile !== undefined) { - await addSourceFolder(address); - return; - } - const choice = await vscode.window.showInformationMessage( - "Runa: Add the image's source folder to the workspace? VS Code restarts extensions when a window becomes a multi-root workspace; reconnect afterwards.", - "Add Source Folder", - ); - if (choice && client && endpoint === address) await addSourceFolder(address); - }; - const connect = async (target?: string) => { const address = target ?? @@ -285,6 +277,7 @@ export function activate(context: vscode.ExtensionContext): void { tree.replace([]); documents.disconnect(currentSession); sources.store.detach(); + sourceTree.refresh(); connectionStatus(false); output.appendLine(`Disconnected from ${address}`); if (error) report(error); @@ -314,7 +307,6 @@ export function activate(context: vscode.ExtensionContext): void { ? undefined : "No actors. Spawn a .rn file from the source folder."; output.appendLine(`Connected to ${address} (protocol v4)`); - await offerSourceFolder(address); }; const inspect = async (selected?: ActorSnapshot) => { @@ -410,6 +402,7 @@ export function activate(context: vscode.ExtensionContext): void { if (uri?.scheme === SOURCE_SCHEME) selected = uri; } if (selected === undefined) return pickSource(placeHolder); + if (typeof selected === "object" && "uri" in selected) selected = selected.uri; if (selected instanceof vscode.Uri) { if (selected.authority !== connectionEndpoint) { throw new Error(`${selected.toString(true)} belongs to another image; connect to it first`); @@ -486,7 +479,8 @@ export function activate(context: vscode.ExtensionContext): void { const connection = active(); const path = await sourceTarget(target, connection.endpoint, "Select the source to spawn"); if (path === undefined) return; - const state = stateText ?? (await jsonInput(`Initial state for ${path}`)); + const state = + typeof stateText === "string" ? stateText : await jsonInput(`Initial state for ${path}`); if (state === undefined) return; return spawnPath(path, state, connection); }; @@ -647,6 +641,8 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( tree, sources, + sourceTree, + sourceView, revisions, documents, view, @@ -689,4 +685,5 @@ export function activate(context: vscode.ExtensionContext): void { ); connectionStatus(false); status.show(); + return { sourceTree }; } diff --git a/vscode/src/sourceProvider.ts b/vscode/src/sourceProvider.ts index 1ddb2ab..8b6ee68 100644 --- a/vscode/src/sourceProvider.ts +++ b/vscode/src/sourceProvider.ts @@ -29,6 +29,51 @@ export function sourcePathOf(uri: vscode.Uri): string { return entry.path; } +export interface SourceEntry { + uri: vscode.Uri; + directory: boolean; +} + +export class ImageSourceTree implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changes.event; + + constructor(private readonly files: ImageSourceFileSystem) {} + + refresh(): void { + this.changes.fire(undefined); + } + + getChildren(parent?: SourceEntry): SourceEntry[] { + const endpoint = this.files.store.image; + if (!this.files.store.connected || endpoint === undefined) return []; + if (!parent) return [{ uri: sourceFolderUri(endpoint), directory: true }]; + return this.files.readDirectory(parent.uri).map(([name, type]) => ({ + uri: vscode.Uri.joinPath(parent.uri, name), + directory: type === vscode.FileType.Directory, + })); + } + + getTreeItem(entry: SourceEntry): vscode.TreeItem { + const item = new vscode.TreeItem( + entry.uri, + entry.directory + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None, + ); + item.id = entry.uri.toString(); + item.contextValue = entry.directory ? "runa.sourceDirectory" : "runa.sourceFile"; + if (!entry.directory) { + item.command = { command: "vscode.open", title: "Open Source", arguments: [entry.uri] }; + } + return item; + } + + dispose(): void { + this.changes.dispose(); + } +} + /** Writable working files of the connected image under `runa-image:///source`. */ export class ImageSourceFileSystem implements vscode.FileSystemProvider { private readonly changes = new vscode.EventEmitter();