feat: show image sources automatically in VS Code
Browse the image's writable virtual source filesystem in an Explorer tree without changing workspace folders. Wire source context actions and ensure Spawn with State prompts for JSON when VS Code passes menu selection arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
89794b08dd
commit
7332aef585
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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://<endpoint>/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://<endpoint>/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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<unknown>("runa.spawn", {
|
||||
path: "actors/counter",
|
||||
|
|
@ -134,16 +139,24 @@ export async function run(): Promise<void> {
|
|||
}
|
||||
|
||||
async function editSources(address: string): Promise<void> {
|
||||
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<void> {
|
|||
await control(address, { operation: "inspect_actor", parameters: { actor_ref: nullActor } }),
|
||||
);
|
||||
assert.match(reply, /"state":\{"type":"null"\}/);
|
||||
const actor = await vscode.commands.executeCommand<string>(
|
||||
"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<string>("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<void> {
|
|||
assert.equal(
|
||||
vscode.workspace.workspaceFolders?.filter((candidate) => candidate.uri.scheme === "runa-image")
|
||||
.length,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
const reopened = await vscode.commands.executeCommand<string>(
|
||||
"runa.sourceHistory",
|
||||
|
|
@ -303,7 +333,7 @@ async function editSources(address: string): Promise<void> {
|
|||
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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SourceEntry>, vscode.Disposable {
|
||||
private readonly changes = new vscode.EventEmitter<SourceEntry | undefined>();
|
||||
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://<endpoint>/source`. */
|
||||
export class ImageSourceFileSystem implements vscode.FileSystemProvider {
|
||||
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
|
||||
|
|
|
|||
Loading…
Reference in New Issue