From 46c346d00e23127a3cae975b07a073669b3a1f52 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 7 May 2026 09:03:02 -0700 Subject: [PATCH 01/10] [WIP] Publishing and editing object scripts directly from the viewer. --- doc/Message_Interfaces.md | 402 +++++++++++++++++++++++++++++++++++++- package.json | 12 ++ src/extension.ts | 93 +++++++++ src/synchservice.ts | 87 ++++++++- src/utils.ts | 11 ++ src/viewereditwsclient.ts | 61 ++++++ 6 files changed, 661 insertions(+), 5 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 52cb53f..45b725d 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -5,6 +5,7 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents - [Usage Flow](#usage-flow) +- [VS Code Launch URI](#vs-code-launch-uri) - [JSON-RPC Method Summary](#json-rpc-method-summary) - [Session Management Interfaces](#session-management-interfaces) - [SessionHandshake](#sessionhandshake) @@ -31,6 +32,17 @@ This document describes all the message interfaces defined for WebSocket communi - [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - [WebSocketHandlers](#websockethandlers) - [ClientInfo](#clientinfo) +- [Object Content Interfaces](#object-content-interfaces) + - [Core Data Types](#core-data-types) + - [ObjectPublish](#objectpublish) + - [ObjectUnpublish](#objectunpublish) + - [ObjectUpdate](#objectupdate) + - [ObjectContentGet](#objectcontentget) + - [ObjectContentSave](#objectcontentsave) + - [ObjectItemCreate](#objectitemcreate) + - [ObjectItemDelete](#objectitemdelete) + - [ObjectScriptSetRunning](#objectscriptsetrunning) + - [ObjectRequest](#objectrequest) ## Usage Flow @@ -53,17 +65,84 @@ This document describes all the message interfaces defined for WebSocket communi - When subscription needs to be terminated, viewer sends `script.unsubscribe` notification with `ScriptUnsubscribe` data - Extension handles unsubscription by cleaning up local script tracking -4. **Runtime Events:** +4. **Object Content Publishing:** + + - Viewer sends `object.publish` notification when an in-world object's contents are made available for editing + - Viewer sends `object.unpublish` notification when an object is removed or the owner stops publishing + - Viewer sends `object.update` notification when object inventory changes (full replacement or delta) + - Extension calls `object.content.get` to fetch an item's content on demand + - Extension calls `object.content.save` to write modified content back to the viewer + - Extension calls `object.item.create` / `object.item.delete` to manage inventory items + - Extension calls `object.script.set_running` to start or stop a script + +5. **Runtime Events:** - Viewer sends `language.syntax.change` notification with `SyntaxChange` when language changes - Viewer sends `script.compiled` notification with `CompilationResult` after script compilation - Viewer sends `runtime.debug` notification with `RuntimeDebug` for debug messages during script execution - Viewer sends `runtime.error` notification with `RuntimeError` when runtime errors occur -5. **Connection Termination:** +6. **Connection Termination:** - Either side can send `session.disconnect` notification with `SessionDisconnect` data - Connection is closed gracefully +## VS Code Launch URI + +The viewer can launch VS Code and trigger an automatic WebSocket connection by opening a `vscode://` URI via the operating system's default URI handler. The extension registers a URI handler for this scheme; VS Code will launch itself if not already running and deliver the URI to the extension. + +### URI Format + +``` +vscode://lindenlab.sl-vscode-plugin/connect[?port=][&object=][&script=] +``` + +### Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `port` | No | Port number the viewer's WebSocket server is listening on. Overrides the user's configured port for this session. Defaults to the configured `slVscodeEdit.network.websocketPort` (default `9020`) if absent. Must be in range 1024–65535. | +| `object` | No | UUID of a root prim. After the handshake completes the extension calls `object.request` to ask the viewer to publish this object. The viewer then sends an `object.publish` notification and the object appears as a workspace folder in the Explorer. | +| `script` | No | UUID of a script. After the handshake completes the extension locates the corresponding temp file via `script.list` and opens it, triggering the normal `script.subscribe` + live-sync flow. | + +`object` and `script` are mutually exclusive in typical use but both may be supplied; the extension will process both. + +### Examples + +``` +# Open VS Code and connect on default port +vscode://lindenlab.sl-vscode-plugin/connect + +# Connect on a custom port +vscode://lindenlab.sl-vscode-plugin/connect?port=9021 + +# Connect and immediately publish a specific object +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&object=550e8400-e29b-41d4-a716-446655440000 + +# Connect and open a specific script for editing +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&script=6ba7b810-9dad-11d1-80b4-00c04fd430c8 +``` + +### Post-connection sequence + +When the URI contains an `object` or `script` parameter the extension acts only **after** the handshake is fully complete (`session.ok` received): + +``` +URI received by extension + │ + ▼ +WebSocket connects → session.handshake → session.ok + │ + ├─ object= → object.request({ object_id }) call + │ │ + │ ▼ (async, when viewer is ready) + │ object.publish notification + │ + └─ script= → script.list call → open temp file + │ + ▼ + script.subscribe + live-sync +``` + ## JSON-RPC Method Summary | Method | Direction | Type | Interface/Parameters | @@ -89,6 +168,21 @@ This document describes all the message interfaces defined for WebSocket communi | `script.compiled` | Viewer → Extension | Notification | `CompilationResult` | | `runtime.debug` | Viewer → Extension | Notification | `RuntimeDebug` | | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | +| `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | +| `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | +| `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | +| `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | +| `object.content.save` | Extension → Viewer | Call | `ObjectContentSaveParams` | +| `object.content.save` (response)| Viewer → Extension | Response | `ObjectContentSaveResponse`| +| `object.item.create` | Extension → Viewer | Call | `ObjectItemCreateParams` | +| `object.item.create` (response) | Viewer → Extension | Response | `ObjectItemCreateResponse` | +| `object.item.delete` | Extension → Viewer | Call | `ObjectItemDeleteParams` | +| `object.item.delete` (response) | Viewer → Extension | Response | `ObjectItemDeleteResponse` | +| `object.script.set_running` | Extension → Viewer | Call | `ObjectScriptSetRunningParams` | +| `object.script.set_running` (response) | Viewer → Extension | Response | `ObjectScriptSetRunningResponse` | +| `object.request` | Extension → Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer → Extension | Response | `ObjectRequestResponse` | ## Session Management Interfaces @@ -577,3 +671,307 @@ interface ClientInfo { - `scriptName`: Name of the script being edited - `scriptId`: Unique identifier for the script - `extension`: File extension or script type + +--- + +## Object Content Interfaces + +These interfaces support publishing in-world object inventories (scripts and notecards) to the external editor as a browseable virtual filesystem. The extension exposes published objects under the `sl://objects/` URI scheme. + +### Core Data Types + +```typescript +type InventoryItemType = "script" | "notecard"; + +type ScriptVM = "lso" | "mono" | "luau"; + +/** Permission mask fields. Only owner and next_owner are transmitted. */ +interface ItemPermissions { + owner: number; // e.g. PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + next_owner: number; +} + +/** + * Inventory item within an object or linked prim. + * asset_id is intentionally never transmitted. + */ +interface ObjectInventoryItem { + item_id: string; // Inventory item UUID + name: string; // Display name (no file extension) + description?: string; + type: InventoryItemType; + subtype?: number; // Scripts only: language from II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) + vm?: ScriptVM; // Scripts only: which VM the script targets + running?: boolean; // Scripts only: whether the script is running + permissions?: ItemPermissions; + creator_id?: string; +} + +/** A linked (child) prim within a linkset */ +interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (root=1, children≥2) + link_name: string; + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** Root of a linkset, as published to the extension */ +interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; + object_description?: string; + region?: string; + owner_id?: string; + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim's scripts and notecards + linked_objects?: LinkedObject[]; // Child prims +} +``` + +**Script display extensions** (synthetic, derived from `subtype`): + +| `subtype` | Extension | +| --------- | --------- | +| `0` (LSL) | `.lsl` | +| `1` (Luau)| `.luau` | +| notecard | `.txt` | + +--- + +### ObjectPublish + +**JSON-RPC Method:** `object.publish` (notification from viewer) + +Sent when the viewer publishes an in-world object's inventory for external editing. Triggers creation of a virtual filesystem workspace folder in the extension. + +```typescript +interface ObjectPublishMessage { + object: PublishedObject; +} +``` + +**Fields:** + +- `object`: The full published object tree, including root prim inventory and all linked prim inventories. + +--- + +### ObjectUnpublish + +**JSON-RPC Method:** `object.unpublish` (notification from viewer) + +Sent when the viewer removes a previously published object — for example when the owner deselects it, moves away, or the object is deleted. + +```typescript +interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim that is being unpublished +- `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) + +--- + +### ObjectUpdate + +**JSON-RPC Method:** `object.update` (notification from viewer) + +Sent when the inventory of a published object changes. Supports two modes: +- **Full replacement**: `inventory` and/or `linked_objects` fields replace the entire prior state. +- **Delta update**: `changes` field describes only what changed. Takes precedence over full replacement fields when present. + +```typescript +interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids removed + modified?: ObjectInventoryItem[]; // metadata-only changes + content_changed?: string[]; // item_ids whose content changed (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; // running state toggled +} + +interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids removed + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement (used when changes is absent) + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta (takes precedence when present) + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} +``` + +--- + +### ObjectContentGet + +**JSON-RPC Method:** `object.content.get` (call from extension to viewer) + +Requests the text content of a script or notecard. The extension calls this lazily when the user opens a file in the virtual filesystem. + +```typescript +interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) — no object_id + link_id needed + item_id: string; +} + +interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. +- `item_id`: Inventory item UUID. +- `content`: The raw text content of the item. +- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. + +--- + +### ObjectContentSave + +**JSON-RPC Method:** `object.content.save` (call from extension to viewer) + +Writes modified content back to the viewer. For scripts, the viewer will attempt to compile the updated source. + +```typescript +interface ObjectContentSaveParams { + prim_id: string; + item_id: string; + content: string; +} + +interface ObjectContentSaveResponse { + success: boolean; + message?: string; +} +``` + +**Fields:** + +- `success`: Whether the save (and compilation, if applicable) succeeded. +- `message` (optional): Error description on failure. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +Creates a new script or notecard in a prim's inventory. + +```typescript +interface ObjectItemCreateParams { + prim_id: string; + name: string; // Pure SL inventory name — no file extension + type: InventoryItemType; // "script" | "notecard" + vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. + content?: string; // Initial content. Empty item created if absent. +} + +interface ObjectItemCreateResponse { + success: boolean; + item_id?: string; // UUID of the created item (on success) + item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) + message?: string; // Error description (on failure) +} +``` + +--- + +### ObjectItemDelete + +**JSON-RPC Method:** `object.item.delete` (call from extension to viewer) + +Deletes a script or notecard from a prim's inventory. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemDeleteParams { + prim_id: string; + item_id: string; +} + +interface ObjectItemDeleteResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectScriptSetRunning + +**JSON-RPC Method:** `object.script.set_running` (call from extension to viewer) + +Starts or stops a script within a prim. + +```typescript +interface ObjectScriptSetRunningParams { + prim_id: string; + item_id: string; + running: boolean; // true = start, false = stop +} + +interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectRequest + +**JSON-RPC Method:** `object.request` (call from extension to viewer) + +Requests the viewer to publish a specific in-world object. The viewer responds synchronously to confirm the request was accepted, then asynchronously sends an `object.publish` notification with the full object tree. + +This is typically called immediately after the handshake completes when the extension was launched by the viewer with an `object=` URI parameter. + +```typescript +interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +interface ObjectRequestResponse { + success: boolean; + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim of the linkset to publish. +- `success`: Whether the viewer accepted the request. A `true` response does not mean `object.publish` has been sent yet — it means the viewer will send it. +- `message` (optional): Human-readable failure reason. Only present when `success` is `false`. + +**Sequence:** +1. Extension calls `object.request` +2. Viewer responds with `{ success: true }` (or error) +3. Viewer sends `object.publish` notification (asynchronously, when ready) diff --git a/package.json b/package.json index 06c90ad..c3a2f57 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,18 @@ "url": "https://github.com/secondlife/sl-vscode-plugin.git" }, "contributes": { + "resourceLabelFormatters": [ + { + "scheme": "sl", + "authority": "objects", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": false, + "workspaceSuffix": "Second Life" + } + } + ], "commands": [ { "command": "second-life-scripting.enable", diff --git a/src/extension.ts b/src/extension.ts index 4ad5f35..e5c92ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,16 @@ import * as vscode from "vscode"; import { SynchService } from "./synchservice"; import { LanguageService } from "./shared/languageservice"; +import { ObjectContentService } from "./vscode/objectcontentservice"; +import { ObjectContentProvider, SL_SCHEME, SL_AUTHORITY, rootUri } from "./vscode/objectcontentprovider"; +import { ObjectContentDecorator } from "./vscode/ObjectContentDecorator"; import { ConfigService, configPrefix } from "./configservice"; import { VSCodeHost, getOutputChannel, showOutputChannel, logInfo, + logDebug, showStatusMessage, hasWorkspace, showErrorMessage @@ -28,6 +32,95 @@ export function activate(context: vscode.ExtensionContext): void { // Initialize the file sync functionality const synchService = SynchService.getInstance(context); + // Initialize object content service and register the sl:// FileSystemProvider + const objectContentService = ObjectContentService.getInstance(); + const objectContentProvider = new ObjectContentProvider( + objectContentService, + () => synchService.getWebSocket(), + ); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(SL_SCHEME, objectContentProvider, { + isCaseSensitive: true, + }), + objectContentProvider, + objectContentService, + ); + + // Register file decoration provider for sl:// URIs (shows disconnected state) + const objectContentDecorator = new ObjectContentDecorator( + () => synchService.isConnected(), + (listener) => synchService.onDidChangeConnectionState(listener), + ); + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(objectContentDecorator), + objectContentDecorator, + ); + + // Manage workspace folders for published objects so Explorer shows friendly names + context.subscriptions.push( + objectContentService.onDidChangeObjects(({ type, object_id }) => { + const folders = vscode.workspace.workspaceFolders ?? []; + const slIdx = folders.findIndex( + (f) => f.uri.scheme === SL_SCHEME && f.uri.authority === SL_AUTHORITY && f.uri.path === `/${object_id}` + ); + if (type === "added") { + const entry = objectContentService.getObject(object_id); + if (entry && slIdx === -1) { + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } else if (type === "removed") { + if (slIdx !== -1) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1); + } + } + }) + ); + + // Register URI handler so the viewer can launch VS Code and trigger a connection. + // URI format: vscode://lindenlab.sl-vscode-plugin/connect?port=9020[&object=][&script=] + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): void { + + logDebug(`Received URI: ${uri.toString()}`); + + if (uri.path !== "/connect") { return; } + + // Decode the query string first - some viewers incorrectly encode the delimiters + const decodedQuery = decodeURIComponent(uri.query); + logDebug(`Decoded query: ${decodedQuery}`); + + const query = Object.fromEntries( + decodedQuery.split("&").filter(Boolean).map(p => { + const eq = p.indexOf("="); + return eq === -1 + ? [p, ""] + : [p.slice(0, eq), p.slice(eq + 1)]; + }) + ); + + logDebug(`Parsed query: ${JSON.stringify(query)}`); + + const rawPort = query["port"] as string | undefined; + const port = rawPort !== undefined ? parseInt(rawPort, 10) : undefined; + if (port !== undefined && (isNaN(port) || port < 1024 || port > 65535)) { + showErrorMessage(`Second Life: Invalid port in launch URI: ${rawPort}`); + return; + } + + const object_id = query["object"] as string | undefined; + const script_id = query["script"] as string | undefined; + + logInfo(`Connecting with port=${port}, object_id=${object_id ?? "(none)"}, script_id=${script_id ?? "(none)"}`); + + synchService.connectToViewer({ port, object_id, script_id }); + } + }) + ); + // Register output channel for disposal context.subscriptions.push(getOutputChannel()); diff --git a/src/synchservice.ts b/src/synchservice.ts index 6a89be3..4611466 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -20,6 +20,11 @@ import { RuntimeDebug, RuntimeError, } from "./viewereditwsclient"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, +} from "./vscode/objectcontentinterfaces"; import { hasWorkspace, showInfoMessage, @@ -35,6 +40,7 @@ import { ScriptSync } from "./scriptsync"; import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; +import { ObjectContentService } from "./vscode/objectcontentservice"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -50,6 +56,8 @@ export class SynchService implements vscode.Disposable { private activeSync: ScriptSync | undefined; private host: HostInterface; private initialGenerationDone: boolean = false; + private pendingLaunchObjectId?: string; + private pendingLaunchScriptId?: string; public viewerName?: string; public viewerVersion?: string; @@ -62,12 +70,17 @@ export class SynchService implements vscode.Disposable { private syncedFileDecorator : SyncedFileDecorator; + private _onDidChangeConnectionState = new vscode.EventEmitter(); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private disposables: vscode.Disposable[] = []; private constructor(context: vscode.ExtensionContext) { this.context = context; this.host = new VSCodeHost(); this.syncedFileDecorator = new SyncedFileDecorator(this); + // Note: _onDidChangeConnectionState is NOT added to disposables + // because it must survive activate/deactivate cycles } public static getInstance(context?: vscode.ExtensionContext): SynchService { @@ -337,7 +350,7 @@ export class SynchService implements vscode.Disposable { //==================================================================== //#region WebSocket connection management and handlers - private async setupConnection(): Promise { + private async setupConnection(portOverride?: number): Promise { const handlers = { onHandshake: (message: SessionHandshake): any => this.onHandshake(message), onHandshakeOk: (): any => this.onHandshakeOk(), @@ -348,7 +361,9 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - + onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), + onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), }; if (this.websocket && this.websocket.isConnected()) { @@ -359,9 +374,11 @@ export class SynchService implements vscode.Disposable { this.getHandshakePromise(); showStatusMessage("Connecting to Second Life viewer...", handshake); + const port = portOverride + ?? this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020); this.websocket = new ViewerEditWSClient( this.context, - `ws://localhost:${this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020)}` + `ws://localhost:${port}` ); this.websocket.setup(handlers); let connected = await this.websocket.connect(); @@ -424,6 +441,7 @@ export class SynchService implements vscode.Disposable { error_reporting: true, debugging: false, breakpoints: false, + object_publish: true, }, }; return response; @@ -456,6 +474,10 @@ export class SynchService implements vscode.Disposable { if (this.handshakeResolve) { this.handshakeResolve(true, "Connected"); } + + this._onDidChangeConnectionState.fire(true); + + await this.handleLaunchParams(); } private onDisconnect(params: SessionDisconnect): void { @@ -472,8 +494,11 @@ export class SynchService implements vscode.Disposable { ); } + this._onDidChangeConnectionState.fire(false); + // Don't dispose immediately - let the connection close handler do the cleanup // The websocket will be closed by the server, triggering our close handler + ObjectContentService.getInstance().clear(); } private onScriptUnsubscribe(message: ScriptUnsubscribe): void { @@ -872,6 +897,10 @@ export class SynchService implements vscode.Disposable { public getWebSocket(): ViewerEditWSClient | undefined { return this.websocket; } + + public isConnected(): boolean { + return this.websocket?.isConnected() ?? false; + } //#endregion //==================================================================== @@ -954,6 +983,58 @@ export class SynchService implements vscode.Disposable { } //#endregion + public async connectToViewer(params: { port?: number; object_id?: string; script_id?: string }): Promise { + this.pendingLaunchObjectId = params.object_id; + this.pendingLaunchScriptId = params.script_id; + + if (this.websocket?.isConnected()) { + // Already connected — act on params immediately + await this.handleLaunchParams(); + return; + } + + await this.setupConnection(params.port); + // handleLaunchParams is called from onHandshakeOk + } + + private async handleLaunchParams(): Promise { + const objectId = this.pendingLaunchObjectId; + const scriptId = this.pendingLaunchScriptId; + this.pendingLaunchObjectId = undefined; + this.pendingLaunchScriptId = undefined; + + if (objectId && this.websocket?.isConnected()) { + const result = await this.websocket.requestObject({ object_id: objectId }); + if (!result.success) { + showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } + } + + if (scriptId && this.websocket?.isConnected()) { + await this.openScriptById(scriptId); + } + } + + private async openScriptById(scriptId: string): Promise { + if (!this.websocket) { return; } + const list = await this.websocket.getScriptList(); + if (!list.success) { return; } + + try { + const files = await fs.promises.readdir(list.temp_dir); + const match = files.find(f => f.includes(scriptId)); + if (match) { + const tempPath = path.join(list.temp_dir, match); + await vscode.window.showTextDocument(vscode.Uri.file(tempPath)); + // onOpenTextDocument fires and handles the normal subscribe + sync flow + } else { + showWarningMessage(`Script ${scriptId} not found in viewer temp directory`); + } + } catch { + showWarningMessage(`Could not open script ${scriptId} from temp directory`); + } + } + public activate(): void { this.deactivate(); this.initialize(); diff --git a/src/utils.ts b/src/utils.ts index bea5205..e346133 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -36,6 +36,17 @@ export function logInfo(message: string): void { channel.appendLine(`[${timestamp}] INFO: ${message}`); } +/** + * Log a debug message to the output channel (only when debug logging is enabled) + */ +export function logDebug(message: string): void { + // TODO: Check a debug setting to conditionally log + // For now, always log debug messages + const channel = getOutputChannel(); + const timestamp = new Date().toISOString(); + channel.appendLine(`[${timestamp}] DEBUG: ${message}`); +} + /** * Log a warning message to the output channel */ diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 90722b2..9748d2c 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -7,6 +7,23 @@ import { JSONRPCClient } from "./websockclient"; import { ConfigService } from "./configservice"; import { ConfigKey } from "./interfaces/configinterface"; import { showStatusMessage } from "./utils"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + ObjectContentGetParams, + ObjectContentGetResponse, + ObjectContentSaveParams, + ObjectContentSaveResponse, + ObjectItemCreateParams, + ObjectItemCreateResponse, + ObjectItemDeleteParams, + ObjectItemDeleteResponse, + ObjectScriptSetRunningParams, + ObjectScriptSetRunningResponse, + ObjectRequestParams, + ObjectRequestResponse, +} from "./vscode/objectcontentinterfaces"; //#region Message Formats @@ -67,6 +84,12 @@ export interface SyntaxCacheList { success: boolean; } +export interface ScriptList { + temp_dir: string; + script_ids: string[]; + success: boolean; +} + export interface SyntaxCacheGetRequest { filename: string; as_json?: boolean; @@ -124,6 +147,9 @@ export interface WebSocketHandlers { onRuntimeDebug?: (message: RuntimeDebug) => void; onRuntimeError?: (message: RuntimeError) => void; onConnectionClosed?: () => void; + onObjectPublish?: (message: ObjectPublishMessage) => void; + onObjectUnpublish?: (message: ObjectUnpublishMessage) => void; + onObjectUpdate?: (message: ObjectUpdateMessage) => void; } /** @@ -186,6 +212,9 @@ export class ViewerEditWSClient extends JSONRPCClient { this.on("script.compiled", this.handlers.onCompilationResult); this.on("runtime.debug", this.handlers.onRuntimeDebug); this.on("runtime.error", this.handlers.onRuntimeError); + this.on("object.publish", this.handlers.onObjectPublish); + this.on("object.unpublish", this.handlers.onObjectUnpublish); + this.on("object.update", this.handlers.onObjectUpdate); // Setup connection close handler this.setupConnectionCloseHandler(); @@ -226,6 +255,38 @@ export class ViewerEditWSClient extends JSONRPCClient { } } + // ============================================ + // Object Content Calls (Extension → Viewer) + // ============================================ + + public getObjectContent(params: ObjectContentGetParams): Promise { + return this.call("object.content.get", params); + } + + public saveObjectContent(params: ObjectContentSaveParams): Promise { + return this.call("object.content.save", params); + } + + public createObjectItem(params: ObjectItemCreateParams): Promise { + return this.call("object.item.create", params); + } + + public deleteObjectItem(params: ObjectItemDeleteParams): Promise { + return this.call("object.item.delete", params); + } + + public setScriptRunning(params: ObjectScriptSetRunningParams): Promise { + return this.call("object.script.set_running", params); + } + + public requestObject(params: ObjectRequestParams): Promise { + return this.call("object.request", params); + } + + public getScriptList(): Promise { + return this.call("script.list", {}); + } + private setupConnectionCloseHandler(): void { // Instead of overriding dispose, use a periodic check for connection state const checkConnectionInterval = setInterval(() => { From 808125a5cd258632291b1b1d21c64256671c504f Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Tue, 19 May 2026 08:57:10 -0700 Subject: [PATCH 02/10] [WIP] Act as a consumer for objects published from the viewer. Treat them as virtual file systems, so that they appear as folders in the explorer tab. Not yet in a stable state. --- doc/Message_Interfaces.md | 144 ++++--- src/synchservice.ts | 24 +- src/vscode/ObjectContentDecorator.ts | 86 ++++ src/vscode/objectcontentinterfaces.ts | 238 +++++++++++ src/vscode/objectcontentprovider.ts | 587 ++++++++++++++++++++++++++ src/vscode/objectcontentservice.ts | 332 +++++++++++++++ src/websockclient.ts | 38 +- 7 files changed, 1382 insertions(+), 67 deletions(-) create mode 100644 src/vscode/ObjectContentDecorator.ts create mode 100644 src/vscode/objectcontentinterfaces.ts create mode 100644 src/vscode/objectcontentprovider.ts create mode 100644 src/vscode/objectcontentservice.ts diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 45b725d..022378a 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -4,45 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [VS Code Launch URI](#vs-code-launch-uri) -- [JSON-RPC Method Summary](#json-rpc-method-summary) -- [Session Management Interfaces](#session-management-interfaces) - - [SessionHandshake](#sessionhandshake) - - [SessionHandshakeResponse](#sessionhandshakeresponse) - - [Session OK](#session-ok) - - [SessionDisconnect](#sessiondisconnect) -- [Language and Syntax Interfaces](#language-and-syntax-interfaces) - - [SyntaxChange](#syntaxchange) - - [Language Syntax ID Request](#language-syntax-id-request) - - [Language Syntax Request](#language-syntax-request) - - [Language Syntax Cache List](#language-syntax-cache-list) - - [Language Syntax Cache Get](#language-syntax-cache-get) -- [Script Subscription Interfaces](#script-subscription-interfaces) - - [ScriptSubscribe](#scriptsubscribe) - - [ScriptSubscribeResponse](#scriptsubscriberesponse) - - [ScriptUnsubscribe](#scriptunsubscribe) - - [ScriptList](#scriptlist) -- [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) - - [CompilationResult](#compilationresult) -- [Runtime Event Interfaces](#runtime-event-interfaces) - - [RuntimeDebug](#runtimedebug) - - [RuntimeError](#runtimeerror) -- [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - - [WebSocketHandlers](#websockethandlers) - - [ClientInfo](#clientinfo) -- [Object Content Interfaces](#object-content-interfaces) - - [Core Data Types](#core-data-types) - - [ObjectPublish](#objectpublish) - - [ObjectUnpublish](#objectunpublish) - - [ObjectUpdate](#objectupdate) - - [ObjectContentGet](#objectcontentget) - - [ObjectContentSave](#objectcontentsave) - - [ObjectItemCreate](#objectitemcreate) - - [ObjectItemDelete](#objectitemdelete) - - [ObjectScriptSetRunning](#objectscriptsetrunning) - - [ObjectRequest](#objectrequest) +- [Usage Flow](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#usage-flow) +- [VS Code Launch URI](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#vs-code-launch-uri) +- [JSON-RPC Method Summary](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#json-rpc-method-summary) +- [Session Management Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-management-interfaces) + - [SessionHandshake](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshake) + - [SessionHandshakeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshakeresponse) + - [Session OK](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-ok) + - [SessionDisconnect](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessiondisconnect) +- [Language and Syntax Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-and-syntax-interfaces) + - [SyntaxChange](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#syntaxchange) + - [Language Syntax ID Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-id-request) + - [Language Syntax Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-request) + - [Language Syntax Cache List](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-list) + - [Language Syntax Cache Get](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-get) +- [Script Subscription Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#script-subscription-interfaces) + - [ScriptSubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscribe) + - [ScriptSubscribeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscriberesponse) + - [ScriptUnsubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptunsubscribe) + - [ScriptList](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptlist) +- [Compilation Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilation-interfaces) + - [CompilationError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationerror) + - [CompilationResult](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationresult) +- [Runtime Event Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtime-event-interfaces) + - [RuntimeDebug](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimedebug) + - [RuntimeError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimeerror) +- [Handler and Configuration Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#handler-and-configuration-interfaces) + - [WebSocketHandlers](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#websockethandlers) + - [ClientInfo](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#clientinfo) +- [Object Content Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#object-content-interfaces) + - [Core Data Types](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#core-data-types) + - [ObjectPublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectpublish) + - [ObjectUnpublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectunpublish) + - [ObjectUpdate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectupdate) + - [ObjectContentGet](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentget) + - [ObjectContentSave](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentsave) + - [ObjectItemCreate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemcreate) + - [ObjectItemDelete](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemdelete) + - [ObjectScriptSetRunning](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectscriptsetrunning) + - [ObjectRequest](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectrequest) ## Usage Flow @@ -378,14 +378,14 @@ interface SyntaxCacheList { | -------------------------------- | ---------------------------------------------------- | | `builtins.txt` | LSL built-in keyword list in plain text format | | `lsl_definitions.yaml` | LSL language definitions in YAML format | -| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format | +| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format. Used by the viewer's script editor | | `lsl_keywords_pretty.xml` | LSL keyword definitions in formatted LLSD XML format | -| `slua_default.d.luau` | Luau type definition file for editor tooling | -| `slua_default.docs.json` | Luau documentation data in JSON format | +| `secondlife.d.luau` | Luau type definition file. Used by luau-lsp | +| `secondlife.docs.json` | Luau documentation data in JSON format. Used by luau-lsp | | `slua_definitions.yaml` | Luau language definitions in YAML format | -| `slua_keywords.xml` | Luau keyword definitions in LLSD XML format | -| `slua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | -| `slua_selene.yml` | Luau Selene linter configuration in YAML format | +| `lua_keywords.xml` | Luau keyword definitions in LLSD XML format. Used by the viewer's script editor | +| `lua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | +| `secondlife_selene.yml` | Luau Selene linter configuration in YAML format | Not all files may be present in every cache — the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. @@ -683,7 +683,7 @@ These interfaces support publishing in-world object inventories (scripts and not ```typescript type InventoryItemType = "script" | "notecard"; -type ScriptVM = "lso" | "mono" | "luau"; +type ScriptVM = "lsl2" | "mono" | "luau"; /** Permission mask fields. Only owner and next_owner are transmitted. */ interface ItemPermissions { @@ -838,10 +838,10 @@ interface ObjectContentGetParams { } interface ObjectContentGetResponse { + success: boolean; prim_id: string; item_id: string; - content: string; - encoding?: "utf-8" | "base64"; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. } ``` @@ -849,8 +849,8 @@ interface ObjectContentGetResponse { - `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. - `item_id`: Inventory item UUID. -- `content`: The raw text content of the item. -- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. +- `success`: `true` on success. +- `content`: The raw text content of the item. For notecards, the `Linden text version 2` envelope is stripped — only the body text is returned. --- @@ -865,17 +865,28 @@ interface ObjectContentSaveParams { prim_id: string; item_id: string; content: string; + vm?: "mono" | "lsl2" | "luau"; } interface ObjectContentSaveResponse { success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; message?: string; } ``` **Fields:** -- `success`: Whether the save (and compilation, if applicable) succeeded. +- `prim_id`: UUID of the prim that owns the saved item. +- `item_id`: UUID of the saved inventory item. +- `content`: Raw script/notecard source text to store. +- `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. +- `success`: Whether the upload/save operation succeeded. +- `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. +- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. - `message` (optional): Error description on failure. --- @@ -884,25 +895,33 @@ interface ObjectContentSaveResponse { **JSON-RPC Method:** `object.item.create` (call from extension to viewer) -Creates a new script or notecard in a prim's inventory. +Creates a new script in a prim's inventory. The call is asynchronous — the viewer sends +`RezScript` to the simulator and waits for the inventory-changed callback before returning +the created item's details. The simulator may rename the item if a duplicate name exists. + +Notecard creation is not yet supported and will return an error. ```typescript interface ObjectItemCreateParams { - prim_id: string; + prim_id: string; // UUID of the prim to create the item in name: string; // Pure SL inventory name — no file extension - type: InventoryItemType; // "script" | "notecard" - vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. - content?: string; // Initial content. Empty item created if absent. + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "lsl" } -interface ObjectItemCreateResponse { - success: boolean; - item_id?: string; // UUID of the created item (on success) - item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) - message?: string; // Error description (on failure) +// On success, returns an ObjectInventoryItem with prim_id: +interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID } ``` +**Notes:** +- The response matches the `ObjectInventoryItem` structure (same fields as items in + `object.publish` and `object.update` notifications). +- The `name` in the response may differ from the request if the simulator renamed it. +- An `object.update` notification will also fire for the prim (since inventory changed). +- Timeout: 30 seconds. Returns a JSON-RPC internal error if the simulator does not respond. + --- ### ObjectItemDelete @@ -919,7 +938,8 @@ interface ObjectItemDeleteParams { interface ObjectItemDeleteResponse { success: boolean; - message?: string; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request } ``` diff --git a/src/synchservice.ts b/src/synchservice.ts index 4611466..3778377 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -30,6 +30,7 @@ import { showInfoMessage, showStatusMessage, showWarningMessage, + logDebug, logInfo, VSCodeHost, closeTextDocument, @@ -361,9 +362,18 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), - onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), - onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), + onObjectPublish: (msg: ObjectPublishMessage): any => { + logDebug(`[object.publish] object_id=${msg.object.object_id}`); + ObjectContentService.getInstance().handlePublish(msg); + }, + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => { + logDebug(`[object.unpublish] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUnpublish(msg); + }, + onObjectUpdate: (msg: ObjectUpdateMessage): any => { + logDebug(`[object.update] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUpdate(msg); + }, }; if (this.websocket && this.websocket.isConnected()) { @@ -1005,8 +1015,14 @@ export class SynchService implements vscode.Disposable { if (objectId && this.websocket?.isConnected()) { const result = await this.websocket.requestObject({ object_id: objectId }); - if (!result.success) { + if (result.object) { + logDebug(`[object.request] response contained object_id=${result.object.object_id}`); + ObjectContentService.getInstance().handlePublish({ object: result.object }); + } else if (result.success === false) { showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } else { + // Keep this visible while we support mixed viewer versions. + logDebug("[object.request] response contained no object payload; waiting for object.publish notification"); } } diff --git a/src/vscode/ObjectContentDecorator.ts b/src/vscode/ObjectContentDecorator.ts new file mode 100644 index 0000000..c23585a --- /dev/null +++ b/src/vscode/ObjectContentDecorator.ts @@ -0,0 +1,86 @@ +/** + * @file ObjectContentDecorator.ts + * File decoration provider for sl:// virtual filesystem entries. + * Shows visual indicators for connection state, script running state, etc. + * Copyright (C) 2025, Linden Research, Inc. + */ +import { + FileDecorationProvider, + Uri, + FileDecoration, + EventEmitter, + ProviderResult, + CancellationToken, + ThemeColor, + Disposable, +} from "vscode"; +import { SL_SCHEME } from "./objectcontentprovider"; +import { logDebug } from "../utils"; + +/** + * Provides file decorations for sl:// URIs based on connection state. + * When disconnected from the viewer, shows a red badge and tooltip. + */ +export class ObjectContentDecorator implements FileDecorationProvider, Disposable { + private _onDidChangeFileDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private isConnected: boolean = false; + private disposables: Disposable[] = []; + + constructor( + private readonly getConnectionState: () => boolean, + onConnectionChange: (listener: (connected: boolean) => void) => Disposable, + ) { + this.isConnected = getConnectionState(); + logDebug(`[ObjectContentDecorator] Initial connection state: ${this.isConnected}`); + + // Listen for connection state changes + const subscription = onConnectionChange((connected) => { + logDebug(`[ObjectContentDecorator] Connection state changed: ${connected}`); + this.isConnected = connected; + // Refresh all sl:// decorations (deferred to ensure processing when not focused) + setTimeout(() => { + this._onDidChangeFileDecorations.fire(undefined); + }, 0); + }); + this.disposables.push( + subscription, + this._onDidChangeFileDecorations, + ); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + provideFileDecoration(uri: Uri, _token: CancellationToken): ProviderResult { + // Only decorate sl:// URIs + if (uri.scheme !== SL_SCHEME) { + return undefined; + } + + // When disconnected, show red badge with warning + if (!this.isConnected) { + return { + badge: "⚠", + tooltip: "Not connected to Second Life viewer", + color: new ThemeColor("errorForeground"), + }; + } + + // Connected state - no special decoration for now + // Future: could show script running state, dirty state, etc. + return undefined; + } + + /** + * Manually trigger a refresh of decorations for specific URIs or all sl:// URIs. + */ + public refresh(uri?: Uri | Uri[]): void { + this._onDidChangeFileDecorations.fire(uri); + } +} diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts new file mode 100644 index 0000000..d4ee775 --- /dev/null +++ b/src/vscode/objectcontentinterfaces.ts @@ -0,0 +1,238 @@ +/** + * @file objectcontentinterfaces.ts + * Interfaces for object content publishing feature + * Copyright (C) 2025, Linden Research, Inc. + */ + +// ============================================ +// Core Data Types +// ============================================ + +/** + * Types of inventory items supported by the object content provider. + * Only scripts and notecards are supported; other item types are not exposed. + */ +export type InventoryItemType = "script" | "notecard"; + +/** Script virtual machine / compiler target */ +export type ScriptVM = "lsl2" | "mono" | "luau"; + +/** Save-time compile target accepted by object.content.save */ +export type ObjectContentSaveVM = "mono" | "lsl2" | "luau"; + +/** + * Permission masks from viewer's LLPermissions. + * Only owner and next_owner masks are transmitted. + * Bit flags: PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + */ +export interface ItemPermissions { + owner: number; // Current owner's permission mask + next_owner: number; // Applied on transfer +} + +/** + * Inventory item within an object or linked prim. + * Only scripts and notecards are supported; other item types are not exposed. + * Note: asset_id is intentionally not transmitted for security. + */ +export interface ObjectInventoryItem { + item_id: string; // Inventory item UUID (unique within container) + name: string; // Display name + description?: string; // Item description + type: InventoryItemType; + /** Scripts only: script language subtype from viewer's II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) */ + subtype?: number; + /** Scripts only: which VM the script targets/was compiled for (from script metadata) */ + vm?: ScriptVM; + /** For scripts only: whether the script is currently running */ + running?: boolean; + permissions?: ItemPermissions; + creator_id?: string; // Creator UUID +} + +/** + * A linked prim within a linkset + */ +export interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (2+ = children; root is 1) + link_name: string; // Display name of the linked prim + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +/** + * Permission masks for the object itself + */ +export interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** + * Published object container (root of a linkset) + */ +export interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; // Display name of root prim + object_description?: string; + region?: string; // Region where object exists + owner_id?: string; // Owner UUID + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim inventory (scripts and notecards only) + linked_objects?: LinkedObject[]; // Child prims in linkset +} + +// ============================================ +// WebSocket Message Interfaces +// ============================================ + +/** object.publish — Viewer → Extension (notification) */ +export interface ObjectPublishMessage { + object: PublishedObject; +} + +/** object.unpublish — Viewer → Extension (notification) */ +export interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} + +/** Delta changes for inventory items */ +export interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids + modified?: ObjectInventoryItem[]; // metadata changes + content_changed?: string[]; // item_ids (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; +} + +/** Delta changes for linked objects */ +export interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +/** + * object.update — Viewer → Extension (notification) + * Supports full replacement or delta-based updates. + * If `changes` is present it takes precedence over full replacement fields. + */ +export interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} + +/** object.content.get — Extension → Viewer (call) */ +export interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.content.get response */ +export interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +/** object.content.save — Extension → Viewer (call) */ +export interface ObjectContentSaveParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + content: string; + vm?: ObjectContentSaveVM; // Scripts only: explicit compile target +} + +/** object.content.save response */ +export interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; // Scripts only: true if compilation succeeded + errors?: string[]; // Scripts only: compiler diagnostics when compiled is false + message?: string; +} + +/** object.item.create — Extension → Viewer (call) */ +export interface ObjectItemCreateParams { + prim_id: string; // UUID of any prim (root or child) + name: string; // Item name (no extension — pure SL inventory name) + type: InventoryItemType; // "script" for current create flow ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "mono" | "lsl2" +} + +/** object.item.create response */ +export interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID +} + +/** object.item.delete — Extension → Viewer (call) */ +export interface ObjectItemDeleteParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.item.delete response */ +export interface ObjectItemDeleteResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} + +/** object.script.set_running — Extension → Viewer (call) */ +export interface ObjectScriptSetRunningParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + running: boolean; // true = start, false = stop +} + +/** object.script.set_running response */ +export interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} + +/** object.request — Extension → Viewer (call) */ +export interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +/** object.request response */ +export interface ObjectRequestResponse { + object?: PublishedObject; // Primary response payload for requested object + success?: boolean; // Legacy compatibility for older viewers + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} + +// ============================================ +// Internal Service Types (not transmitted) +// ============================================ + +/** Cached content for an inventory item */ +export interface CachedItemContent { + content: Uint8Array; + mtime: number; + dirty: boolean; +} + +/** Internal representation of a published object with content cache */ +export interface ObjectEntry { + object: PublishedObject; + contentCache: Map; // keyed by item_id + publishedAt: number; +} diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts new file mode 100644 index 0000000..aaddf2a --- /dev/null +++ b/src/vscode/objectcontentprovider.ts @@ -0,0 +1,587 @@ +/** + * @file objectcontentprovider.ts + * VS Code FileSystemProvider for the sl:// virtual filesystem. + * Presents published Second Life in-world objects as browseable directories. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { ObjectContentService } from "./objectcontentservice"; +import { ViewerEditWSClient } from "../viewereditwsclient"; +import { + InventoryItemType, + ObjectContentSaveVM, + ObjectInventoryItem, + ScriptVM, +} from "./objectcontentinterfaces"; + +// ============================================ +// Constants +// ============================================ + +export const SL_SCHEME = "sl"; +export const SL_AUTHORITY = "objects"; + +/** PERM_MODIFY bit from viewer LLPermissions */ +const PERM_MODIFY = 0x4000; + +// JSON-RPC error codes used by the viewer +const JSONRPC_INVALID_PARAMS = -32602; +const JSONRPC_FORBIDDEN = -32003; +const JSONRPC_TIMEOUT = -32001; +const JSONRPC_INTERNAL_ERROR = -32603; + +/** + * Extract JSON-RPC error code from error message. + * The websocket client formats errors as "JSON-RPC Error {code}: {message}" + */ +function extractJsonRpcErrorCode(error: Error): number | undefined { + const match = error.message.match(/^JSON-RPC Error (-?\d+):/); + return match ? parseInt(match[1], 10) : undefined; +} + +/** + * Map JSON-RPC error to appropriate FileSystemError. + */ +function mapRpcErrorToFileSystemError(error: unknown, uri: vscode.Uri): Error { + if (!(error instanceof Error)) { + return vscode.FileSystemError.Unavailable(uri); + } + + const code = extractJsonRpcErrorCode(error); + switch (code) { + case JSONRPC_INVALID_PARAMS: + // Prim not found, item not found, or invalid item type + return vscode.FileSystemError.FileNotFound(uri); + case JSONRPC_FORBIDDEN: + // Object not published or insufficient permissions + return vscode.FileSystemError.NoPermissions(uri); + case JSONRPC_TIMEOUT: + // Simulator didn't respond in time + return vscode.FileSystemError.Unavailable(`Request timed out: ${uri}`); + case JSONRPC_INTERNAL_ERROR: + // Asset cache issue or other internal error + return vscode.FileSystemError.Unavailable(error.message); + default: + // Pass through the original error message + return vscode.FileSystemError.Unavailable(error.message); + } +} + +// ============================================ +// URI Helpers +// ============================================ + +interface ParsedObjectUri { + /** Root prim UUID (always present) */ + root_id: string; + /** Child prim UUID if path has 2+ segments, otherwise undefined */ + link_id?: string; + /** Item UUID if this is a file URI, otherwise undefined */ + item_id?: string; + /** Unresolved leaf filename for create flows */ + pending_name?: string; + /** Whether this URI refers to a directory */ + isDirectory: boolean; +} + +interface ParseUriOptions { + allowMissingLeaf?: boolean; +} + +/** Check if a string looks like a UUID */ +function isUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Find an item in an inventory list by its display name. + * Returns the item_id if found, undefined otherwise. + */ +function findItemByDisplayName( + inventory: ObjectInventoryItem[] | undefined, + filename: string +): string | undefined { + if (!inventory) return undefined; + const item = inventory.find((i) => displayName(i) === filename); + return item?.item_id; +} + +/** + * Parse an sl://objects/... URI into its components. + * + * URI shapes: + * sl://objects/{root_id} → root directory + * sl://objects/{root_id}/{seg} → file in root (item_id or display name) + * OR child prim directory (link_id = seg) + * sl://objects/{root_id}/{link_id}/{seg} → file in child prim (item_id or display name) + * + * For file URIs, the segment can be either: + * - A UUID (item_id directly) — used by API calls + * - A display name (e.g., "Hello World.lsl") — used by Explorer + * + * Directories are distinguished from files by checking the object tree. + */ +function parseUri( + uri: vscode.Uri, + service: ObjectContentService, + options?: ParseUriOptions +): ParsedObjectUri { + // Strip leading slash and split + const parts = uri.path.replace(/^\//, "").split("/").filter((p) => p.length > 0); + + if (parts.length === 0) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const root_id = parts[0]; + const entry = service.getObject(root_id); + + if (parts.length === 1) { + return { root_id, isDirectory: true }; + } + + const seg1 = parts[1]; + + if (parts.length === 2) { + // Is seg1 a linked prim (directory) or an item (file)? + const isLinkedPrim = + entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + + if (isLinkedPrim) { + return { root_id, link_id: seg1, isDirectory: true }; + } + + // seg1 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg1)) { + item_id = seg1; + } else { + item_id = findItemByDisplayName(entry?.object.inventory, seg1); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, pending_name: seg1, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, item_id, isDirectory: false }; + } + + if (parts.length === 3) { + const link_id = parts[1]; + const seg2 = parts[2]; + + // seg2 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg2)) { + item_id = seg2; + } else { + const linkedObj = entry?.object.linked_objects?.find((lo) => lo.link_id === link_id); + item_id = findItemByDisplayName(linkedObj?.inventory, seg2); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, link_id, pending_name: seg2, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, link_id, item_id, isDirectory: false }; + } + + throw vscode.FileSystemError.FileNotFound(uri); +} + +/** Build a URI for a root prim directory */ +export function rootUri(root_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}` }); +} + +/** Build a URI for a linked prim directory */ +export function linkedPrimUri(root_id: string, link_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${link_id}` }); +} + +/** Build a URI for a file (item in root or linked prim) */ +export function itemUri(root_id: string, prim_id: string, item_id: string): vscode.Uri { + if (prim_id === root_id) { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${item_id}` }); + } + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${prim_id}/${item_id}` }); +} + +// ============================================ +// Display Name Helpers +// ============================================ + +/** Map item subtype to the appropriate file extension for display */ +function extensionForItem(item: ObjectInventoryItem): string { + if (item.type === "notecard") return ".txt"; + return item.subtype === 1 ? ".luau" : ".lsl"; +} + +/** Returns the display filename (name + synthetic extension) */ +function displayName(item: ObjectInventoryItem): string { + return item.name + extensionForItem(item); +} + +/** + * Derive type and vm from a synthetic display extension. + * Used when creating new items from a filename the user typed. + */ +function typeAndVmFromExtension(ext: string): { type: InventoryItemType; vm: ScriptVM } | undefined { + switch (ext.toLowerCase()) { + case ".luau": return { type: "script", vm: "luau" }; + case ".lsl": return { type: "script", vm: "lsl2" }; + default: return undefined; + } +} + +/** + * Derive object.content.save vm from current script metadata. + * Save API accepts: mono, lsl2, luau. + */ +function saveVmForItem(item: ObjectInventoryItem): ObjectContentSaveVM | undefined { + if (item.type !== "script") { + return undefined; + } + + // Prefer explicit VM from metadata. + if (item.vm === "luau") { + return "luau"; + } + + if (item.vm === "lsl2") { + return "lsl2"; + } + + if (item.vm === "mono") { + return "mono"; + } + + // Compatibility fallback when VM metadata is absent. + if (item.subtype === 1) { + return "luau"; + } + + return "mono"; +} + +/** Strip the synthetic display extension to recover the raw SL inventory name */ +function stripExtension(filename: string): { name: string; ext: string } { + const dot = filename.lastIndexOf("."); + if (dot === -1) return { name: filename, ext: "" }; + return { name: filename.slice(0, dot), ext: filename.slice(dot) }; +} + +// ============================================ +// FileSystemProvider +// ============================================ + +export class ObjectContentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly service: ObjectContentService, + private readonly getClient: () => ViewerEditWSClient | undefined, + ) { + // Forward content invalidations to VS Code as Changed events + this.disposables.push( + service.onDidChangeContent(({ object_id, prim_id, item_id }) => { + const uri = itemUri(object_id, prim_id, item_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + }, 0); + }), + + // Forward tree changes (added/removed objects) as directory changes + service.onDidChangeObjects(({ type, object_id }) => { + const uri = rootUri(object_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + if (type === "added") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); + } else if (type === "removed") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); + } else { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + }, 0); + }), + + this._onDidChangeFile, + ); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + this.disposables = []; + } + + // ============================================ + // FileSystemProvider — required methods + // ============================================ + + watch(_uri: vscode.Uri): vscode.Disposable { + // Change notifications are pushed from the service; no polling needed. + return new vscode.Disposable(() => { }); + } + + stat(uri: vscode.Uri): vscode.FileStat { + const parsed = parseUri(uri, this.service); + + if (parsed.isDirectory) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: 0, + size: 0, + }; + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const cached = this.service.getCachedContent(root_id, item_id!); + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + + return { + type: vscode.FileType.File, + ctime: this.service.getObject(root_id)?.publishedAt ?? 0, + mtime: cached?.mtime ?? 0, + size: cached?.content.byteLength ?? 0, + permissions: canModify ? undefined : vscode.FilePermission.Readonly, + }; + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const parsed = parseUri(uri, this.service); + if (!parsed.isDirectory) { + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + const { root_id, link_id } = parsed; + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const results: [string, vscode.FileType][] = []; + + if (link_id) { + // Listing a child prim directory + const lo = this.service.getLinkedObject(root_id, link_id); + if (!lo) { + throw vscode.FileSystemError.FileNotFound(uri); + } + for (const item of lo.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + } else { + // Listing the root prim directory + for (const item of entry.object.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + for (const lo of entry.object.linked_objects ?? []) { + // Use link_id (UUID) as the directory name so URIs remain stable. + // link_name is used as display label via workspace folder naming. + results.push([lo.link_id, vscode.FileType.Directory]); + } + } + + return results; + } + + async readFile(uri: vscode.Uri): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + // Return cached content if available + const cached = this.service.getCachedContent(root_id, item_id!); + if (cached) { + return cached.content; + } + + // Fetch from viewer + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const response = await client.getObjectContent({ prim_id, item_id: item_id! }); + const text = response.content ?? ""; + const bytes = Buffer.from(text, "utf-8"); + this.service.cacheContent(root_id, item_id!, bytes); + return bytes; + } catch (error) { + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const parsed = parseUri(uri, this.service, { allowMissingLeaf: options.create }); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id } = parsed; + const prim_id = link_id ?? root_id; + + // Permission check (only meaningful for existing items) + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (parsed.item_id) { + // item_id known — existing item + const item = this.service.getItem(root_id, prim_id, parsed.item_id); + if (item) { + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const text = Buffer.from(content).toString("utf-8"); + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const vm = saveVmForItem(item); + const result = await client.saveObjectContent({ + prim_id, + item_id: parsed.item_id, + content: text, + vm, + }); + + if (!result.success) { + throw vscode.FileSystemError.Unavailable( + result.message ?? "Save failed" + ); + } + + if (result.compiled === false) { + const diagnostics = (result.errors ?? []).slice(0, 5).join("\n"); + const details = diagnostics.length > 0 + ? `\n${diagnostics}` + : ""; + void vscode.window.showWarningMessage( + `Second Life: Saved, but compilation failed.${details}` + ); + } + + this.service.cacheContent(root_id, parsed.item_id, content); + this.service.markContentSaved(root_id, parsed.item_id); + return; + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + } + + // No existing item — create new + if (!options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const filename = parsed.pending_name + ?? uri.path.replace(/^\//, "").split("/").slice(-1)[0]; + const { name, ext } = stripExtension(filename); + const createParams = typeAndVmFromExtension(ext); + if (!createParams) { + throw vscode.FileSystemError.NoPermissions( + "Only script creation is currently supported (.lsl, .luau)." + ); + } + + const { type, vm } = createParams; + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.createObjectItem({ + prim_id, + name, + type, + vm, + }); + + if (!result.item_id) { + throw vscode.FileSystemError.Unavailable("Create failed: missing item_id"); + } + + const response = await client.getObjectContent({ prim_id, item_id: result.item_id }); + const text = response.content ?? ""; + // Cache under the real item_id returned by the viewer + this.service.cacheContent(root_id, result.item_id, Buffer.from(text, "utf-8")); + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async delete(uri: vscode.Uri, _options: { recursive: boolean }): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.deleteObjectItem({ prim_id, item_id: item_id! }); + if (!result.success) { + throw vscode.FileSystemError.Unavailable("Delete failed"); + } + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + // Creating directories (linked prims) and renaming are not supported + createDirectory(_uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_uri); + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_oldUri); + } +} diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts new file mode 100644 index 0000000..c0aa513 --- /dev/null +++ b/src/vscode/objectcontentservice.ts @@ -0,0 +1,332 @@ +/** + * @file objectcontentservice.ts + * Singleton service managing published in-world object content. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { + ObjectInventoryItem, + LinkedObject, + ObjectEntry, + CachedItemContent, + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + InventoryChanges, +} from "./objectcontentinterfaces"; + +// ============================================ +// Event Types +// ============================================ + +/** Fired when objects are added, removed, or have metadata/inventory changes */ +export interface ObjectTreeChangeEvent { + type: "added" | "removed" | "updated"; + object_id: string; +} + +/** Fired when cached content for a specific item is invalidated or needs refresh */ +export interface ObjectContentChangeEvent { + object_id: string; + prim_id: string; + item_id: string; +} + +// ============================================ +// Service +// ============================================ + +export class ObjectContentService implements vscode.Disposable { + private static instance: ObjectContentService | undefined; + + private objects: Map = new Map(); + + private _onDidChangeObjects = new vscode.EventEmitter(); + readonly onDidChangeObjects = this._onDidChangeObjects.event; + + private _onDidChangeContent = new vscode.EventEmitter(); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private disposables: vscode.Disposable[] = []; + + private constructor() { + this.disposables.push(this._onDidChangeObjects, this._onDidChangeContent); + } + + static getInstance(): ObjectContentService { + if (!ObjectContentService.instance) { + ObjectContentService.instance = new ObjectContentService(); + } + return ObjectContentService.instance; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + if (ObjectContentService.instance === this) { + ObjectContentService.instance = undefined; + } + } + + // ============================================ + // Message Handlers (Viewer → Extension) + // ============================================ + + handlePublish(msg: ObjectPublishMessage): void { + const entry: ObjectEntry = { + object: msg.object, + contentCache: new Map(), + publishedAt: Date.now(), + }; + this.objects.set(msg.object.object_id, entry); + this._onDidChangeObjects.fire({ type: "added", object_id: msg.object.object_id }); + } + + handleUnpublish(msg: ObjectUnpublishMessage): void { + if (this.objects.delete(msg.object_id)) { + this._onDidChangeObjects.fire({ type: "removed", object_id: msg.object_id }); + } + } + + handleUpdate(msg: ObjectUpdateMessage): void { + const entry = this.objects.get(msg.object_id); + if (!entry) { + return; + } + + if (msg.object_name !== undefined) { + entry.object.object_name = msg.object_name; + } + + if (msg.changes) { + // Delta update + if (msg.changes.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + msg.object_id, + entry.object.inventory, + msg.changes.inventory + ); + } + if (msg.changes.linked_objects) { + const lc = msg.changes.linked_objects; + if (lc.added) { + entry.object.linked_objects = [ + ...(entry.object.linked_objects ?? []), + ...lc.added, + ]; + } + if (lc.removed) { + const removedSet = new Set(lc.removed); + entry.object.linked_objects = (entry.object.linked_objects ?? []).filter( + (lo) => !removedSet.has(lo.link_id) + ); + // Evict cached content for removed linked prims + for (const link_id of lc.removed) { + this._evictPrimCache(entry, msg.object_id, link_id); + } + } + if (lc.modified) { + for (const mod of lc.modified) { + const lo = (entry.object.linked_objects ?? []).find( + (l) => l.link_id === mod.link_id + ); + if (!lo) continue; + if (mod.link_name !== undefined) { + lo.link_name = mod.link_name; + } + if (mod.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + mod.link_id, + lo.inventory, + mod.inventory + ); + } + } + } + } + } else { + // Full replacement + if (msg.inventory !== undefined) { + entry.object.inventory = msg.inventory; + // Evict root prim content cache entirely + this._evictPrimCache(entry, msg.object_id, msg.object_id); + } + if (msg.linked_objects !== undefined) { + // Evict cache for all replaced linked prims + for (const lo of entry.object.linked_objects ?? []) { + this._evictPrimCache(entry, msg.object_id, lo.link_id); + } + entry.object.linked_objects = msg.linked_objects; + } + } + + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + } + + // ============================================ + // Tree Queries + // ============================================ + + getObjects(): readonly ObjectEntry[] { + return Array.from(this.objects.values()); + } + + getObject(object_id: string): ObjectEntry | undefined { + return this.objects.get(object_id); + } + + hasObject(object_id: string): boolean { + return this.objects.has(object_id); + } + + /** + * Returns inventory for any prim in the linkset. + * Pass prim_id === object_id for root prim, or a link_id for a child prim. + */ + getInventory(object_id: string, prim_id: string): ObjectInventoryItem[] | undefined { + const entry = this.objects.get(object_id); + if (!entry) return undefined; + if (prim_id === object_id) { + return entry.object.inventory; + } + return entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory; + } + + getItem(object_id: string, prim_id: string, item_id: string): ObjectInventoryItem | undefined { + return this.getInventory(object_id, prim_id)?.find((i) => i.item_id === item_id); + } + + getLinkedObject(object_id: string, link_id: string): LinkedObject | undefined { + return this.objects.get(object_id)?.object.linked_objects?.find( + (lo) => lo.link_id === link_id + ); + } + + // ============================================ + // Content Cache + // ============================================ + + getCachedContent(object_id: string, item_id: string): CachedItemContent | undefined { + return this.objects.get(object_id)?.contentCache.get(item_id); + } + + cacheContent(object_id: string, item_id: string, content: Uint8Array): void { + const entry = this.objects.get(object_id); + if (!entry) return; + const existing = entry.contentCache.get(item_id); + entry.contentCache.set(item_id, { + content, + mtime: Date.now(), + dirty: existing?.dirty ?? false, + }); + } + + markContentDirty(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = true; + } + } + + markContentSaved(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = false; + } + } + + isContentDirty(object_id: string, item_id: string): boolean { + return this.objects.get(object_id)?.contentCache.get(item_id)?.dirty ?? false; + } + + // ============================================ + // Lifecycle + // ============================================ + + /** Remove all published objects (e.g. on viewer disconnect). */ + clear(): void { + const ids = Array.from(this.objects.keys()); + this.objects.clear(); + for (const object_id of ids) { + this._onDidChangeObjects.fire({ type: "removed", object_id }); + } + } + + // ============================================ + // Private Helpers + // ============================================ + + /** + * Apply delta inventory changes to an item list. + * Fires onDidChangeContent for any content_changed or removed items. + */ + private _applyInventoryChanges( + entry: ObjectEntry, + object_id: string, + prim_id: string, + inventory: ObjectInventoryItem[], + changes: InventoryChanges + ): void { + if (changes.added) { + inventory.push(...changes.added); + } + if (changes.removed) { + const removedSet = new Set(changes.removed); + for (let i = inventory.length - 1; i >= 0; i--) { + if (removedSet.has(inventory[i].item_id)) { + inventory.splice(i, 1); + } + } + for (const item_id of changes.removed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.modified) { + for (const mod of changes.modified) { + const idx = inventory.findIndex((i) => i.item_id === mod.item_id); + if (idx !== -1) { + inventory[idx] = mod; + } + } + } + if (changes.content_changed) { + for (const item_id of changes.content_changed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.running_changed) { + for (const rc of changes.running_changed) { + const item = inventory.find((i) => i.item_id === rc.item_id); + if (item) { + item.running = rc.running; + } + } + } + } + + /** + * Evict all cached content belonging to a specific prim from the entry's cache. + * Used when inventory is fully replaced or a linked prim is removed. + * Fires onDidChangeContent for each evicted item. + */ + private _evictPrimCache(entry: ObjectEntry, object_id: string, prim_id: string): void { + const inventory = + prim_id === object_id + ? entry.object.inventory + : entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory ?? []; + + for (const item of inventory) { + if (entry.contentCache.delete(item.item_id)) { + this._onDidChangeContent.fire({ object_id, prim_id, item_id: item.item_id }); + } + } + } +} diff --git a/src/websockclient.ts b/src/websockclient.ts index c2974e8..a7a51c7 100644 --- a/src/websockclient.ts +++ b/src/websockclient.ts @@ -64,6 +64,7 @@ import * as vscode from "vscode"; import WebSocket from "ws"; +import { logDebug } from "./utils"; /** * JSON-RPC 2.0 message types @@ -448,13 +449,47 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { super(context, url); } + private logIncomingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] <- request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] <- notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] <- response id=${String(message.id)} status=${status}`); + } + } + + private logOutgoingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] -> request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] -> notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] -> response id=${String(message.id)} status=${status}`); + } + } + /** * Handles incoming WebSocket messages with JSON-RPC support */ protected handleMessage(data: WebSocket.RawData): void { try { const message = JSON.parse(data.toString()) as JSONRPCMessage; - // console.log("Received JSON-RPC message:", message); + this.logIncomingMessage(message); if (this.isJSONRPCResponse(message)) { this.handleJSONRPCResponse(message); @@ -674,6 +709,7 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { * Sends a JSON-RPC message */ private sendJSONRPCMessage(message: JSONRPCMessage): boolean { + this.logOutgoingMessage(message); return this.sendMessage(message); } From 05aa0318d31e644179b55f8b4be5041bc55df179 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 27 May 2026 11:40:23 -0700 Subject: [PATCH 03/10] Rider test (#73) Revise workflow for publishing to VSCode marketplace. --- .github/workflows/ci.yml | 76 +-- .github/workflows/generate-release-notes.yml | 48 ++ .github/workflows/pre-commit.yaml | 10 +- .github/workflows/release.yml | 482 +++++++++++++++++++ package-lock.json | 146 +----- package.json | 1 - 6 files changed, 541 insertions(+), 222 deletions(-) create mode 100644 .github/workflows/generate-release-notes.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f3ff1..f284e80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,22 @@ name: CI on: push: branches: [ "*" ] - tags: [ "v*" ] + tags-ignore: [ "**" ] pull_request: branches: [ main, develop ] - workflow_dispatch: jobs: test: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [24.x] steps: - name: Checkout code uses: actions/checkout@v5 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version: '24.x' cache: 'npm' - name: Install dependencies @@ -41,7 +37,6 @@ jobs: - name: Run full test suite run: xvfb-run -a npm test - continue-on-error: false build: needs: test @@ -66,26 +61,9 @@ jobs: - name: Compile extension run: npm run vscode:prepublish - - name: Determine version suffix + - name: Get build SHA id: version - run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "suffix=" >> $GITHUB_OUTPUT - echo "release_type=release" >> $GITHUB_OUTPUT - elif [ "${{ github.ref }}" = "refs/heads/debug" ]; then - echo "suffix=-debug" >> $GITHUB_OUTPUT - echo "release_type=debug" >> $GITHUB_OUTPUT - fi - echo "sha_short=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT - - - name: Update package.json version for non-main builds - if: github.ref != 'refs/heads/main' - run: | - # Get current version and append build info - CURRENT_VERSION=$(node -p "require('./package.json').version") - NEW_VERSION="${CURRENT_VERSION}${{ steps.version.outputs.suffix }}-${GITHUB_RUN_NUMBER}+${{ steps.version.outputs.sha_short }}" - npm version "$NEW_VERSION" --no-git-tag-version - echo "Updated version to: $NEW_VERSION" + run: echo "sha_short=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT - name: Package extension run: vsce package @@ -95,52 +73,10 @@ jobs: run: | PACKAGE_FILE=$(ls *.vsix | head -1) echo "filename=$PACKAGE_FILE" >> $GITHUB_OUTPUT - echo "name=$(basename "$PACKAGE_FILE" .vsix)" >> $GITHUB_OUTPUT - name: Upload VSIX artifact uses: actions/upload-artifact@v4 with: - name: vscode-extension-${{ steps.version.outputs.release_type }}-${{ steps.version.outputs.sha_short }} + name: vscode-extension-${{ steps.version.outputs.sha_short }} path: ${{ steps.package.outputs.filename }} retention-days: 30 - - release: - name: Update release info - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - - steps: - - name: Get version from tag - id: version - run: | - VERSION=${GITHUB_REF#refs/tags/v} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Download VSIX artifact - uses: actions/download-artifact@v4 - with: - pattern: vscode-extension-* - merge-multiple: true - - - name: Get package filename - id: package - run: | - PACKAGE_FILE=$(ls *.vsix | head -1) - echo "filename=$PACKAGE_FILE" >> $GITHUB_OUTPUT - - - name: Create release - id: release - uses: secondlife-3p/action-gh-release@v1 - with: - # name the release page for the branch - name: "SL-VScode Plugin ${{ steps.version.outputs.version }} Release" - prerelease: true - generate_release_notes: true - target_commitish: ${{ github.sha }} - append_body: true - files: ${{ steps.package.outputs.filename }} diff --git a/.github/workflows/generate-release-notes.yml b/.github/workflows/generate-release-notes.yml new file mode 100644 index 0000000..ed5a7c4 --- /dev/null +++ b/.github/workflows/generate-release-notes.yml @@ -0,0 +1,48 @@ +name: Generate Release Notes + +on: + workflow_call: + inputs: + tag_name: + description: Tag name to generate release notes for + required: true + type: string + target_commitish: + description: Branch or commit SHA used as target for note generation + required: true + type: string + outputs: + release_notes: + description: Generated release notes body + value: ${{ jobs.generate.outputs.release_notes }} + +permissions: + contents: read + +jobs: + generate: + runs-on: ubuntu-latest + outputs: + release_notes: ${{ steps.generate.outputs.release_notes }} + + steps: + - name: Generate release notes body + id: generate + uses: actions/github-script@v7 + env: + TAG_NAME: ${{ inputs.tag_name }} + TARGET_COMMITISH: ${{ inputs.target_commitish }} + with: + script: | + const { owner, repo } = context.repo; + const tag_name = process.env.TAG_NAME; + const target_commitish = process.env.TARGET_COMMITISH; + + const response = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name, + target_commitish, + }); + + core.setOutput('release_notes', response.data.body || ''); diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 18a488f..debd5c3 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -7,7 +7,6 @@ on: branches: [main, develop] permissions: - id-token: write contents: read jobs: @@ -20,18 +19,15 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 'latest' + node-version: '24.x' cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.x' - - name: Install pre-commit - run: pip install pre-commit - - name: Run pre-commit checks - run: pre-commit run --all-files + uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..055859b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,482 @@ +name: Cut Release + +on: + workflow_dispatch: + inputs: + tag_name: + description: Release tag to create, for example v1.0.3 + required: true + type: string + target_commitish: + description: Branch or commit SHA the tag should point to + required: true + default: develop + type: string + prerelease: + description: Mark the GitHub release as a prerelease + required: true + default: true + type: boolean + draft: + description: Create the GitHub release as a draft + required: true + default: false + type: boolean + publish_marketplace: + description: Publish the extension to the VS Code Marketplace + required: true + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + authorize_actor: + name: Authorize triggering user + runs-on: ubuntu-latest + steps: + - name: Ensure actor has access + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const username = context.actor; + + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + const allowed = ['admin', 'maintain']; + + if (!allowed.includes(response.data.permission)) { + core.setFailed( + `${username} must have maintain or admin access to run this workflow. Current permission: ${response.data.permission}` + ); + return; + } + + core.info(`${username} is authorized with permission: ${response.data.permission}`); + + validate: + name: Validate release inputs + needs: authorize_actor + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set_variables.outputs.version }} + steps: + - name: Validate + shell: bash + run: | + if [ -z "${{ inputs.tag_name }}" ]; then + echo "tag_name is required and cannot be empty" >&2 + exit 1 + fi + + # Enforce plain vX.Y.Z — no pre-release suffixes or build metadata. + # The VS Code Marketplace does not support SemVer pre-release identifiers in + # extension versions. Use an odd minor number (e.g. v1.3.0) with the + # prerelease input flag instead of a suffix like v1.2.3-rc.1. + if [[ ! "${{ inputs.tag_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "tag_name must be plain vX.Y.Z (for example v1.0.3)." >&2 + echo "The VS Code Marketplace does not support pre-release suffixes in version numbers." >&2 + echo "Use an odd minor version (e.g. v1.3.0) with the prerelease flag for pre-release builds." >&2 + exit 1 + fi + + # Enforce odd/even minor version convention: + # even minor (0, 2, 4, ...) → stable release (prerelease must be false) + # odd minor (1, 3, 5, ...) → pre-release (prerelease must be true) + MINOR=$(echo "${{ inputs.tag_name }}" | cut -d. -f2) + IS_PRERELEASE="${{ inputs.prerelease }}" + if (( MINOR % 2 == 0 )) && [[ "$IS_PRERELEASE" == "true" ]]; then + echo "Even minor version (${{ inputs.tag_name }}) must not be marked as prerelease." >&2 + echo "Even minor = stable release. Use an odd minor version for pre-release builds." >&2 + exit 1 + fi + if (( MINOR % 2 == 1 )) && [[ "$IS_PRERELEASE" == "false" ]]; then + echo "Odd minor version (${{ inputs.tag_name }}) must be marked as prerelease." >&2 + echo "Odd minor = pre-release. Use an even minor version for stable releases." >&2 + exit 1 + fi + + echo "tag_name=${{ inputs.tag_name }}" >&2 + echo "target_commitish=${{ inputs.target_commitish }}" >&2 + echo "prerelease=${{ inputs.prerelease }}" >&2 + echo "draft=${{ inputs.draft }}" >&2 + + - name: Set Variables + id: set_variables + shell: bash + run: | + TAG_NAME="${{ inputs.tag_name }}" + SEMANTIC_VERSION="${TAG_NAME#v}" + echo "version=$SEMANTIC_VERSION" >> "$GITHUB_OUTPUT" + echo "version=$SEMANTIC_VERSION" >&2 + + release_notes: + needs: validate + name: Generate release notes + runs-on: ubuntu-latest + outputs: + release_notes: ${{ steps.generate.outputs.release_notes }} + permissions: + contents: write + + steps: + - name: Generate release notes body + id: generate + uses: actions/github-script@v7 + env: + TAG_NAME: ${{ inputs.tag_name }} + TARGET_COMMITISH: ${{ inputs.target_commitish }} + with: + script: | + const { owner, repo } = context.repo; + const tag_name = process.env.TAG_NAME; + const target_commitish = process.env.TARGET_COMMITISH; + + const response = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name, + target_commitish, + }); + + core.setOutput('release_notes', response.data.body || ''); + + - name: Checkout target branch + uses: actions/checkout@v5 + with: + ref: ${{ inputs.target_commitish }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: 'npm' + + - name: Update version + shell: bash + run: | + VERSION="${{ needs.validate.outputs.version }}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Write release notes file + shell: bash + env: + RELEASE_NOTES: ${{ steps.generate.outputs.release_notes }} + run: | + printf '%s\n' "$RELEASE_NOTES" > release_notes.md + + - name: Update CHANGELOG.md + shell: bash + run: | + VERSION="${{ needs.validate.outputs.version }}" + RELEASE_DATE=$(date -u +%Y-%m-%d) + + { + echo "## [${VERSION}] - ${RELEASE_DATE}" + echo + cat release_notes.md + echo + } > changelog_entry.md + + awk ' + BEGIN { inserted = 0 } + /^## \[/ && inserted == 0 { + while ((getline line < "changelog_entry.md") > 0) print line + close("changelog_entry.md") + print "" + inserted = 1 + } + { print } + END { + if (inserted == 0) { + print "" + while ((getline line < "changelog_entry.md") > 0) print line + close("changelog_entry.md") + } + } + ' CHANGELOG.md > CHANGELOG.new.md + + mv CHANGELOG.new.md CHANGELOG.md + cat CHANGELOG.md >&2 + + - name: Update README badges from package.json + shell: bash + run: | + node - <<'NODE' + const fs = require('fs'); + + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + let vscodeVersion = pkg.engines && pkg.engines.vscode ? String(pkg.engines.vscode) : ''; + + if (vscodeVersion.startsWith('^')) { + vscodeVersion = `${vscodeVersion.slice(1)}+`; + } + + const versionBadge = `[![Version](https://img.shields.io/badge/version-${pkg.version}-blue.svg)](https://github.com/secondlife/sl-vscode-plugin)`; + const licenseBadge = `[![License](https://img.shields.io/badge/license-${pkg.license}-green.svg)](LICENSE)`; + const vscodeBadge = `[![VS Code](https://img.shields.io/badge/VS%20Code-${encodeURIComponent(vscodeVersion)}-red.svg)](https://code.visualstudio.com/)`; + + let readme = fs.readFileSync('README.md', 'utf8'); + + readme = readme.replace(/^\[!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-[^)]+\)\]\(https:\/\/github\.com\/secondlife\/sl-vscode-plugin\)$/m, versionBadge); + readme = readme.replace(/^\[!\[License\]\(https:\/\/img\.shields\.io\/badge\/license-[^)]+\)\]\(LICENSE\)$/m, licenseBadge); + readme = readme.replace(/^\[!\[VS Code\]\(https:\/\/img\.shields\.io\/badge\/VS%20Code-[^)]+\)\]\(https:\/\/code\.visualstudio\.com\/\)$/m, vscodeBadge); + + fs.writeFileSync('README.md', readme); + NODE + + - name: Package modified files for downstream jobs + shell: bash + run: | + rm -rf release-notes-files + mkdir -p release-notes-files + + git ls-files -m > modified-files.txt + git ls-files --others --exclude-standard >> modified-files.txt + + if [ ! -s modified-files.txt ]; then + echo "No modified files detected" >&2 + else + while IFS= read -r file; do + if [ -n "$file" ] && [ -e "$file" ]; then + mkdir -p "release-notes-files/$(dirname "$file")" + cp "$file" "release-notes-files/$file" + fi + done < modified-files.txt + fi + + - name: Upload modified files artifact + uses: actions/upload-artifact@v4 + with: + name: release-notes-modified-files-${{ github.run_id }} + path: | + release-notes-files + modified-files.txt + retention-days: 1 + + # - name: Commit and push changelog update + # shell: bash + # run: | + # TARGET_BRANCH="${{ inputs.target_commitish }}" + # TARGET_BRANCH="${TARGET_BRANCH#refs/heads/}" + + # if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null; then + # echo "target_commitish must be a branch name (or refs/heads/) to update CHANGELOG.md" >&2 + # echo "Received: ${{ inputs.target_commitish }}" >&2 + # exit 1 + # fi + + # git config user.name "github-actions[bot]" + # git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # git add CHANGELOG.md + + # if git diff --cached --quiet; then + # echo "No changelog changes to commit" + # exit 0 + # fi + + # git commit -m "docs: update changelog for ${{ inputs.tag_name }}" + # git push origin HEAD:$TARGET_BRANCH + + build: + needs: release_notes + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ inputs.target_commitish }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: 'npm' + + - name: Download modified files artifact + uses: actions/download-artifact@v4 + with: + name: release-notes-modified-files-${{ github.run_id }} + path: . + + - name: Apply modified files from release_notes job + shell: bash + run: | + if [ -d release-notes-files ]; then + cp -R release-notes-files/. . + fi + + - name: Create release branch, commit, and push tag + shell: bash + run: | + TARGET_BRANCH="${{ inputs.target_commitish }}" + TARGET_BRANCH="${TARGET_BRANCH#refs/heads/}" + RELEASE_BRANCH="release/${{ inputs.tag_name }}" + + if ! git ls-remote --exit-code --heads origin "$TARGET_BRANCH" > /dev/null; then + echo "target_commitish must be a branch name (or refs/heads/) to create a release branch" >&2 + echo "Received: ${{ inputs.target_commitish }}" >&2 + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -b "$RELEASE_BRANCH" + git add -u + + if git diff --cached --quiet; then + echo "No repository changes to commit" + else + git commit -m "chore: prepare release ${{ inputs.tag_name }}" + fi + + git push origin "$RELEASE_BRANCH" + + git tag -a "${{ inputs.tag_name }}" -m "Release ${{ inputs.tag_name }}" + git push origin "refs/tags/${{ inputs.tag_name }}" + + - name: Create release PRs + uses: actions/github-script@v7 + env: + RELEASE_NOTES: ${{ needs.release_notes.outputs.release_notes }} + with: + script: | + const { owner, repo } = context.repo; + const releaseBranch = `release/${{ inputs.tag_name }}`; + const tagName = '${{ inputs.tag_name }}'; + const prTitle = `chore: prepare release ${tagName}`; + const releaseNotes = process.env.RELEASE_NOTES || ''; + const prBody = `Automated release PR for ${tagName}.\n\nUpdates CHANGELOG.md, package.json version, and README badges.\n\n## Release Notes\n\n${releaseNotes}`; + + for (const base of ['develop', 'main']) { + try { + const pr = await github.rest.pulls.create({ + owner, + repo, + title: prTitle, + head: releaseBranch, + base, + body: prBody, + }); + core.info(`Created PR into ${base}: ${pr.data.html_url}`); + } catch (err) { + core.warning(`Failed to create PR into ${base}: ${err.message}`); + } + } + + - name: Install dependencies + run: npm install + + - name: Compile extension + run: npm run vscode:prepublish + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Package extension + run: vsce package + + - name: Get package filename + id: package + shell: bash + run: | + PACKAGE_FILE=$(ls *.vsix | head -1) + echo "filename=$PACKAGE_FILE" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: vscode-extension-release-${{ github.run_id }} + path: ${{ steps.package.outputs.filename }} + retention-days: 30 + + release: + name: Publish release + needs: [build, release_notes, validate] + runs-on: ubuntu-latest + + steps: + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: vscode-extension-release-${{ github.run_id }} + path: vsix/ + + - name: Download modified files artifact + uses: actions/download-artifact@v4 + with: + name: release-notes-modified-files-${{ github.run_id }} + path: . + + - name: Apply modified files from release_notes job + shell: bash + run: | + if [ -d release-notes-files ]; then + cp -R release-notes-files/. . + fi + + - name: Get package filename + id: package + shell: bash + run: | + PACKAGE_FILE=$(ls vsix/*.vsix | head -1) + echo "filename=$PACKAGE_FILE" >> "$GITHUB_OUTPUT" + + - name: Create release page + id: release + uses: secondlife-3p/action-gh-release@v1 + with: + # name the release page for the branch + tag_name: ${{ inputs.tag_name }} + target_commitish: ${{ inputs.target_commitish }} + name: "SL-VScode Plugin ${{ needs.validate.outputs.version }} Release" + prerelease: ${{ inputs.prerelease }} + draft: ${{ inputs.draft }} + body_path: release_notes.md + files: ${{ steps.package.outputs.filename }} + + publish: + name: Publish to VS Code Marketplace + needs: [release, build, validate] + runs-on: ubuntu-latest + # Skip if publish not requested or if this is a draft release + if: ${{ inputs.publish_marketplace && !inputs.draft }} + + steps: + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: vscode-extension-release-${{ github.run_id }} + path: vsix/ + + - name: Get package filename + id: package + shell: bash + run: | + PACKAGE_FILE=$(ls vsix/*.vsix | head -1) + echo "filename=$PACKAGE_FILE" >> "$GITHUB_OUTPUT" + + - name: Publish to Marketplace + shell: bash + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: | + npm install -g @vscode/vsce + + if [[ "${{ inputs.prerelease }}" == "true" ]]; then + echo "Publishing pre-release to Marketplace..." >&2 + vsce publish --pre-release --packagePath "${{ steps.package.outputs.filename }}" --pat "$VSCE_PAT" + else + echo "Publishing stable release to Marketplace..." >&2 + vsce publish --packagePath "${{ steps.package.outputs.filename }}" --pat "$VSCE_PAT" + fi diff --git a/package-lock.json b/package-lock.json index befb184..a4cc9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sl-vscode-plugin", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sl-vscode-plugin", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", @@ -29,7 +29,6 @@ "eslint": "^9.34.0", "glob": "^11.1.0", "mocha": "^10.0.0", - "pre-commit": "^1.2.2", "typescript": "^5.9.2" }, "engines": { @@ -525,7 +524,6 @@ "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.42.0", "@typescript-eslint/types": "8.42.0", @@ -932,7 +930,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1084,13 +1081,6 @@ "dev": true, "license": "ISC" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -1329,22 +1319,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1479,7 +1453,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2849,15 +2822,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/os-shim": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz", - "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2984,78 +2948,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pre-commit": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz", - "integrity": "sha512-qokTiqxD6GjODy5ETAIgzsRgnBWWQHQH2ghy86PU7mIn/wuWeTwF3otyNQZxWBwVn8XNr8Tdzj/QfUXpH+gRZA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^5.0.1", - "spawn-sync": "^1.0.15", - "which": "1.2.x" - } - }, - "node_modules/pre-commit/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/pre-commit/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/pre-commit/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pre-commit/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pre-commit/node_modules/which": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz", - "integrity": "sha512-16uPglFkRPzgiUXYMi1Jf8Z5EzN1iB4V0ZtMXcHZnwsBtQhhHeCqoWw7tsUY42hJGNDWtUsVLTjakIa5BgAxCw==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3073,13 +2965,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true, - "license": "ISC" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3294,18 +3179,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/spawn-sync": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", - "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "concat-stream": "^1.4.7", - "os-shim": "^0.1.2" - } - }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -3569,20 +3442,12 @@ "node": ">= 0.8.0" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3846,13 +3711,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true, - "license": "ISC" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 06c90ad..6323b74 100644 --- a/package.json +++ b/package.json @@ -248,7 +248,6 @@ "eslint": "^9.34.0", "glob": "^11.1.0", "mocha": "^10.0.0", - "pre-commit": "^1.2.2", "typescript": "^5.9.2" }, "dependencies": { From f223244f24cb0a1f53f4bd79b30545c5fe238a20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 18:48:16 +0000 Subject: [PATCH 04/10] chore: prepare release v1.0.4 --- CHANGELOG.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++-- package-lock.json | 4 ++-- package.json | 4 ++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ddada..c7fcae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ All notable changes to the Second Life External Scripting Extension will be docu The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.4] - 2026-05-27 + +## What's Changed +* Fix for incorrect luau-lsp config use by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/2 +* Bump the npm_and_yarn group across 1 directory with 2 updates by @dependabot[bot] in https://github.com/secondlife/sl-vscode-plugin/pull/1 +* Switch from class to extern syntax for luau-lsp defs and move DetecteEvent by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/4 +* Fix selene yaml gen and toml config by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/5 +* Fix for selene yaml self on `:` calls by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/6 +* Fix default data to have eventname for LLEvents:off by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/7 +* Make saves of included/required files trigger 'saves' on actively sync'd file by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/12 +* Fix precomp require generating invalid code for files with no trailing line ending by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/10 +* Add fallback lookup for matching file, if the default glob finds nothing by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/9 +* Add config to enable/disable the extension, and a command to do it quickly by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/8 +* Update Package Name Across Project to fix README Links and be Consistent with Repo Name by @GalaxyLittlepaws in https://github.com/secondlife/sl-vscode-plugin/pull/11 +* Implement a check to prevent saving over a file with identicle content by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/14 +* Add __UNIXTIME__ macro for lsl by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/15 +* Add LLEvents:once do data for default generation by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/18 +* Add support to lexer for luau [[ style strings by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/24 +* Selene warnign about list type in ll funcs by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/21 +* Alter path in selene.toml to be relative by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/26 +* Add support to slua require for aliases and default init files by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/29 +* Add extra filemeta to the output and support using it to match files by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/30 +* LSL Preproc drop comments after defines by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/35 +* Add support to lsl preprocessing for <> style includes by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/34 +* Fix #define #undef and #include being executed inside false conditional blocks by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/36 +* Perform a dry run of the preprocessor when initializing a sync by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/31 +* Add slencode and sldecode functions for lljson by @mikelittman in https://github.com/secondlife/sl-vscode-plugin/pull/40 +* Delay initial definition generation to opening of first luau file by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/42 +* Switch require to use new `dangerouslyexecuterequiredmodule` function by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/45 +* Add support for alternative LLEvents event subscription style by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/41 +* Add support for preproc style macro's as system constants by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/46 +* Fix non relative paths in @line and @module comments by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/47 +* Fix casing of learn more links in luau-lsp docs by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/48 +* Reduce config switches for meta output by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/50 +* Linux-specific instructions by @tapple in https://github.com/secondlife/sl-vscode-plugin/pull/52 +* Add the option to treat the viewer file as master by @tapple in https://github.com/secondlife/sl-vscode-plugin/pull/55 +* Spelling corrections by @FelixWolf in https://github.com/secondlife/sl-vscode-plugin/pull/53 +* Fix default config, and support for custom port by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/51 +* Luau type fixes for luau-lsp by @tapple in https://github.com/secondlife/sl-vscode-plugin/pull/57 +* Always try to match a master file by `@file` meta by @tapple in https://github.com/secondlife/sl-vscode-plugin/pull/61 +* Disable-auto-language-update by @tapple in https://github.com/secondlife/sl-vscode-plugin/pull/62 +* Switch support for lsl preproc by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/59 +* Notecard link and `@file` meta linking improvements by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/64 +* Change how file sync's are ended, and add icons to indicate files that are synced by @WolfGangS in https://github.com/secondlife/sl-vscode-plugin/pull/65 +* Retrieve pregenerated tool config files from cache on viewer. by @Rider-Linden in https://github.com/secondlife/sl-vscode-plugin/pull/66 +* Rider test by @Rider-Linden in https://github.com/secondlife/sl-vscode-plugin/pull/73 + +## New Contributors +* @WolfGangS made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/2 +* @dependabot[bot] made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/1 +* @GalaxyLittlepaws made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/11 +* @mikelittman made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/40 +* @tapple made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/52 +* @FelixWolf made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/53 +* @Rider-Linden made their first contribution in https://github.com/secondlife/sl-vscode-plugin/pull/66 + +**Full Changelog**: https://github.com/secondlife/sl-vscode-plugin/commits/v1.0.4 + + ## [1.0.0] - 2025-11-18 ### Added diff --git a/README.md b/README.md index eb64e02..6bb85b5 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Enhance your Second Life scripting workflow with advanced preprocessing and external editing capabilities!** -[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/secondlife/sl-vscode-plugin) +[![Version](https://img.shields.io/badge/version-1.0.4-blue.svg)](https://github.com/secondlife/sl-vscode-plugin) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![VS Code](https://img.shields.io/badge/VS%20Code-1.85.0+-red.svg)](https://code.visualstudio.com/) +[![VS Code](https://img.shields.io/badge/VS%20Code-1.85.0%2B-red.svg)](https://code.visualstudio.com/) The Second Life External Scripting Extension transforms VS Code into a development environment for Second Life scripts, supporting both **LSL (Linden Scripting Language)** and **SLua (Second Life Lua)** with preprocessing capabilities and viewer integration. diff --git a/package-lock.json b/package-lock.json index a4cc9ea..860a333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sl-vscode-plugin", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sl-vscode-plugin", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 6323b74..a0b48cc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sl-vscode-plugin", "displayName": "Second Life VSCode Plugin", "description": "Binding extension for external LSL and SLua editing from the Second Life viewer.", - "version": "1.0.3", + "version": "1.0.4", "publisher": "lindenlab", "icon": "sl-logo-vscode.png", "extensionDependencies": [], @@ -147,7 +147,7 @@ }, "slVscodeEdit.sync.notecardComment": { "type": "string", - "default" : null, + "default": null, "description": "String to use as a comment for notecards to allow syncing to external files of notecards with a file comment" } } From 622a68c127960cd4fe3a9ad68b8c92dcd8599492 Mon Sep 17 00:00:00 2001 From: WolfGang Date: Fri, 5 Jun 2026 17:41:25 +0100 Subject: [PATCH 05/10] Update README.md with marketplace url --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6bb85b5..0020cad 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,13 @@ The Second Life External Scripting Extension transforms VS Code into a developme ## Installation -### From VS Code Marketplace (Coming Soon) +### From VS Code Marketplace +Use this link - https://marketplace.visualstudio.com/items?itemName=lindenlab.sl-vscode-plugin +Or 1. Open VS Code 2. Go to Extensions (Ctrl+Shift+X) -3. Search for "Second Life External Scripting" +3. Search for "Second Life VSCode Plugin" 4. Click Install ### Manual Installation From bf36af27193d83c84feba33c5349966bc2f660dc Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 18 Jun 2026 13:44:06 -0700 Subject: [PATCH 06/10] forced syntax update now uses the correct API --- src/synchservice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/synchservice.ts b/src/synchservice.ts index 6a89be3..465cb9d 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -643,7 +643,7 @@ export class SynchService implements vscode.Disposable { showWarningMessage("Failed to get syntax ID from viewer."); return; } - const success = await service.changeSyntaxVersion(syntaxId, socket, true); + const success = await service.changeSyntaxVersion(syntaxId, socket, true, this.syntaxCacheSupported); if (!success) { showWarningMessage("Failed to update syntax."); } From bd582eee98b6205c99b3088ec2eb6808f2f4368a Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 18 Jun 2026 14:36:01 -0700 Subject: [PATCH 07/10] Request correct files. --- src/shared/languageservice.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/shared/languageservice.ts b/src/shared/languageservice.ts index 78cc48a..126999c 100644 --- a/src/shared/languageservice.ts +++ b/src/shared/languageservice.ts @@ -153,27 +153,27 @@ export class LanguageService implements DisposableLike { const cacheFiles = this.repository.syntaxCacheFiles; const selene = new SelenePlugin(this.host); - if (cacheFiles.includes("slua_selene.yml")) { - const content = await this.repository.requestSyntaxCacheFile(socket, "slua_selene.yml"); + if (cacheFiles.includes("secondlife_selene.yml")) { + const content = await this.repository.requestSyntaxCacheFile(socket, "secondlife_selene.yml"); if (typeof content === "string") { await selene.configureFromViewerCache(syntaxId, content); } else { - console.warn("syntax_cache: slua_selene.yml missing or invalid, skipping Selene configuration"); + console.warn("syntax_cache: secondlife_selene.yml missing or invalid, skipping Selene configuration"); } } else { - console.warn("syntax_cache: slua_selene.yml not in viewer cache, skipping Selene configuration"); + console.warn("syntax_cache: secondlife_selene.yml not in viewer cache, skipping Selene configuration"); } const luauLSP = new LuaLSPPlugin(this.host); - const hasDLuau = cacheFiles.includes("slua_default.d.luau"); - const hasDocs = cacheFiles.includes("slua_default.docs.json"); + const hasDLuau = cacheFiles.includes("secondlife.d.luau"); + const hasDocs = cacheFiles.includes("secondlife.docs.json"); if (hasDLuau && hasDocs) { - const dLuau = await this.repository.requestSyntaxCacheFile(socket, "slua_default.d.luau"); - const docs = await this.repository.requestSyntaxCacheFile(socket, "slua_default.docs.json"); + const dLuau = await this.repository.requestSyntaxCacheFile(socket, "secondlife.d.luau"); + const docs = await this.repository.requestSyntaxCacheFile(socket, "secondlife.docs.json"); if (typeof dLuau === "string" && typeof docs === "string") { await luauLSP.configureFromViewerCache(syntaxId, dLuau, docs); } else { - console.warn("syntax_cache: slua_default.d.luau or slua_default.docs.json missing or invalid, skipping Luau-LSP configuration"); + console.warn("syntax_cache: secondlife.d.luau or secondlife.docs.json missing or invalid, skipping Luau-LSP configuration"); } } else { console.warn("syntax_cache: Luau-LSP files not in viewer cache, skipping Luau-LSP configuration"); From 22ffedfd4bdcd3182b7c7a1025fa35767ee4bbce Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Thu, 7 May 2026 09:03:02 -0700 Subject: [PATCH 08/10] [WIP] Publishing and editing object scripts directly from the viewer. --- doc/Message_Interfaces.md | 402 +++++++++++++++++++++++++++++++++++++- package.json | 12 ++ src/extension.ts | 93 +++++++++ src/synchservice.ts | 87 ++++++++- src/utils.ts | 11 ++ src/viewereditwsclient.ts | 61 ++++++ 6 files changed, 661 insertions(+), 5 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 52cb53f..45b725d 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -5,6 +5,7 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents - [Usage Flow](#usage-flow) +- [VS Code Launch URI](#vs-code-launch-uri) - [JSON-RPC Method Summary](#json-rpc-method-summary) - [Session Management Interfaces](#session-management-interfaces) - [SessionHandshake](#sessionhandshake) @@ -31,6 +32,17 @@ This document describes all the message interfaces defined for WebSocket communi - [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - [WebSocketHandlers](#websockethandlers) - [ClientInfo](#clientinfo) +- [Object Content Interfaces](#object-content-interfaces) + - [Core Data Types](#core-data-types) + - [ObjectPublish](#objectpublish) + - [ObjectUnpublish](#objectunpublish) + - [ObjectUpdate](#objectupdate) + - [ObjectContentGet](#objectcontentget) + - [ObjectContentSave](#objectcontentsave) + - [ObjectItemCreate](#objectitemcreate) + - [ObjectItemDelete](#objectitemdelete) + - [ObjectScriptSetRunning](#objectscriptsetrunning) + - [ObjectRequest](#objectrequest) ## Usage Flow @@ -53,17 +65,84 @@ This document describes all the message interfaces defined for WebSocket communi - When subscription needs to be terminated, viewer sends `script.unsubscribe` notification with `ScriptUnsubscribe` data - Extension handles unsubscription by cleaning up local script tracking -4. **Runtime Events:** +4. **Object Content Publishing:** + + - Viewer sends `object.publish` notification when an in-world object's contents are made available for editing + - Viewer sends `object.unpublish` notification when an object is removed or the owner stops publishing + - Viewer sends `object.update` notification when object inventory changes (full replacement or delta) + - Extension calls `object.content.get` to fetch an item's content on demand + - Extension calls `object.content.save` to write modified content back to the viewer + - Extension calls `object.item.create` / `object.item.delete` to manage inventory items + - Extension calls `object.script.set_running` to start or stop a script + +5. **Runtime Events:** - Viewer sends `language.syntax.change` notification with `SyntaxChange` when language changes - Viewer sends `script.compiled` notification with `CompilationResult` after script compilation - Viewer sends `runtime.debug` notification with `RuntimeDebug` for debug messages during script execution - Viewer sends `runtime.error` notification with `RuntimeError` when runtime errors occur -5. **Connection Termination:** +6. **Connection Termination:** - Either side can send `session.disconnect` notification with `SessionDisconnect` data - Connection is closed gracefully +## VS Code Launch URI + +The viewer can launch VS Code and trigger an automatic WebSocket connection by opening a `vscode://` URI via the operating system's default URI handler. The extension registers a URI handler for this scheme; VS Code will launch itself if not already running and deliver the URI to the extension. + +### URI Format + +``` +vscode://lindenlab.sl-vscode-plugin/connect[?port=][&object=][&script=] +``` + +### Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `port` | No | Port number the viewer's WebSocket server is listening on. Overrides the user's configured port for this session. Defaults to the configured `slVscodeEdit.network.websocketPort` (default `9020`) if absent. Must be in range 1024–65535. | +| `object` | No | UUID of a root prim. After the handshake completes the extension calls `object.request` to ask the viewer to publish this object. The viewer then sends an `object.publish` notification and the object appears as a workspace folder in the Explorer. | +| `script` | No | UUID of a script. After the handshake completes the extension locates the corresponding temp file via `script.list` and opens it, triggering the normal `script.subscribe` + live-sync flow. | + +`object` and `script` are mutually exclusive in typical use but both may be supplied; the extension will process both. + +### Examples + +``` +# Open VS Code and connect on default port +vscode://lindenlab.sl-vscode-plugin/connect + +# Connect on a custom port +vscode://lindenlab.sl-vscode-plugin/connect?port=9021 + +# Connect and immediately publish a specific object +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&object=550e8400-e29b-41d4-a716-446655440000 + +# Connect and open a specific script for editing +vscode://lindenlab.sl-vscode-plugin/connect?port=9020&script=6ba7b810-9dad-11d1-80b4-00c04fd430c8 +``` + +### Post-connection sequence + +When the URI contains an `object` or `script` parameter the extension acts only **after** the handshake is fully complete (`session.ok` received): + +``` +URI received by extension + │ + ▼ +WebSocket connects → session.handshake → session.ok + │ + ├─ object= → object.request({ object_id }) call + │ │ + │ ▼ (async, when viewer is ready) + │ object.publish notification + │ + └─ script= → script.list call → open temp file + │ + ▼ + script.subscribe + live-sync +``` + ## JSON-RPC Method Summary | Method | Direction | Type | Interface/Parameters | @@ -89,6 +168,21 @@ This document describes all the message interfaces defined for WebSocket communi | `script.compiled` | Viewer → Extension | Notification | `CompilationResult` | | `runtime.debug` | Viewer → Extension | Notification | `RuntimeDebug` | | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | +| `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | +| `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | +| `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | +| `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | +| `object.content.save` | Extension → Viewer | Call | `ObjectContentSaveParams` | +| `object.content.save` (response)| Viewer → Extension | Response | `ObjectContentSaveResponse`| +| `object.item.create` | Extension → Viewer | Call | `ObjectItemCreateParams` | +| `object.item.create` (response) | Viewer → Extension | Response | `ObjectItemCreateResponse` | +| `object.item.delete` | Extension → Viewer | Call | `ObjectItemDeleteParams` | +| `object.item.delete` (response) | Viewer → Extension | Response | `ObjectItemDeleteResponse` | +| `object.script.set_running` | Extension → Viewer | Call | `ObjectScriptSetRunningParams` | +| `object.script.set_running` (response) | Viewer → Extension | Response | `ObjectScriptSetRunningResponse` | +| `object.request` | Extension → Viewer | Call | `ObjectRequestParams` | +| `object.request` (response) | Viewer → Extension | Response | `ObjectRequestResponse` | ## Session Management Interfaces @@ -577,3 +671,307 @@ interface ClientInfo { - `scriptName`: Name of the script being edited - `scriptId`: Unique identifier for the script - `extension`: File extension or script type + +--- + +## Object Content Interfaces + +These interfaces support publishing in-world object inventories (scripts and notecards) to the external editor as a browseable virtual filesystem. The extension exposes published objects under the `sl://objects/` URI scheme. + +### Core Data Types + +```typescript +type InventoryItemType = "script" | "notecard"; + +type ScriptVM = "lso" | "mono" | "luau"; + +/** Permission mask fields. Only owner and next_owner are transmitted. */ +interface ItemPermissions { + owner: number; // e.g. PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + next_owner: number; +} + +/** + * Inventory item within an object or linked prim. + * asset_id is intentionally never transmitted. + */ +interface ObjectInventoryItem { + item_id: string; // Inventory item UUID + name: string; // Display name (no file extension) + description?: string; + type: InventoryItemType; + subtype?: number; // Scripts only: language from II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) + vm?: ScriptVM; // Scripts only: which VM the script targets + running?: boolean; // Scripts only: whether the script is running + permissions?: ItemPermissions; + creator_id?: string; +} + +/** A linked (child) prim within a linkset */ +interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (root=1, children≥2) + link_name: string; + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** Root of a linkset, as published to the extension */ +interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; + object_description?: string; + region?: string; + owner_id?: string; + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim's scripts and notecards + linked_objects?: LinkedObject[]; // Child prims +} +``` + +**Script display extensions** (synthetic, derived from `subtype`): + +| `subtype` | Extension | +| --------- | --------- | +| `0` (LSL) | `.lsl` | +| `1` (Luau)| `.luau` | +| notecard | `.txt` | + +--- + +### ObjectPublish + +**JSON-RPC Method:** `object.publish` (notification from viewer) + +Sent when the viewer publishes an in-world object's inventory for external editing. Triggers creation of a virtual filesystem workspace folder in the extension. + +```typescript +interface ObjectPublishMessage { + object: PublishedObject; +} +``` + +**Fields:** + +- `object`: The full published object tree, including root prim inventory and all linked prim inventories. + +--- + +### ObjectUnpublish + +**JSON-RPC Method:** `object.unpublish` (notification from viewer) + +Sent when the viewer removes a previously published object — for example when the owner deselects it, moves away, or the object is deleted. + +```typescript +interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim that is being unpublished +- `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) + +--- + +### ObjectUpdate + +**JSON-RPC Method:** `object.update` (notification from viewer) + +Sent when the inventory of a published object changes. Supports two modes: +- **Full replacement**: `inventory` and/or `linked_objects` fields replace the entire prior state. +- **Delta update**: `changes` field describes only what changed. Takes precedence over full replacement fields when present. + +```typescript +interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids removed + modified?: ObjectInventoryItem[]; // metadata-only changes + content_changed?: string[]; // item_ids whose content changed (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; // running state toggled +} + +interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids removed + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement (used when changes is absent) + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta (takes precedence when present) + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} +``` + +--- + +### ObjectContentGet + +**JSON-RPC Method:** `object.content.get` (call from extension to viewer) + +Requests the text content of a script or notecard. The extension calls this lazily when the user opens a file in the virtual filesystem. + +```typescript +interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) — no object_id + link_id needed + item_id: string; +} + +interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} +``` + +**Fields:** + +- `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. +- `item_id`: Inventory item UUID. +- `content`: The raw text content of the item. +- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. + +--- + +### ObjectContentSave + +**JSON-RPC Method:** `object.content.save` (call from extension to viewer) + +Writes modified content back to the viewer. For scripts, the viewer will attempt to compile the updated source. + +```typescript +interface ObjectContentSaveParams { + prim_id: string; + item_id: string; + content: string; +} + +interface ObjectContentSaveResponse { + success: boolean; + message?: string; +} +``` + +**Fields:** + +- `success`: Whether the save (and compilation, if applicable) succeeded. +- `message` (optional): Error description on failure. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +Creates a new script or notecard in a prim's inventory. + +```typescript +interface ObjectItemCreateParams { + prim_id: string; + name: string; // Pure SL inventory name — no file extension + type: InventoryItemType; // "script" | "notecard" + vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. + content?: string; // Initial content. Empty item created if absent. +} + +interface ObjectItemCreateResponse { + success: boolean; + item_id?: string; // UUID of the created item (on success) + item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) + message?: string; // Error description (on failure) +} +``` + +--- + +### ObjectItemDelete + +**JSON-RPC Method:** `object.item.delete` (call from extension to viewer) + +Deletes a script or notecard from a prim's inventory. Requires `PERM_MODIFY` on the item. + +```typescript +interface ObjectItemDeleteParams { + prim_id: string; + item_id: string; +} + +interface ObjectItemDeleteResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectScriptSetRunning + +**JSON-RPC Method:** `object.script.set_running` (call from extension to viewer) + +Starts or stops a script within a prim. + +```typescript +interface ObjectScriptSetRunningParams { + prim_id: string; + item_id: string; + running: boolean; // true = start, false = stop +} + +interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} +``` + +--- + +### ObjectRequest + +**JSON-RPC Method:** `object.request` (call from extension to viewer) + +Requests the viewer to publish a specific in-world object. The viewer responds synchronously to confirm the request was accepted, then asynchronously sends an `object.publish` notification with the full object tree. + +This is typically called immediately after the handshake completes when the extension was launched by the viewer with an `object=` URI parameter. + +```typescript +interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +interface ObjectRequestResponse { + success: boolean; + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim of the linkset to publish. +- `success`: Whether the viewer accepted the request. A `true` response does not mean `object.publish` has been sent yet — it means the viewer will send it. +- `message` (optional): Human-readable failure reason. Only present when `success` is `false`. + +**Sequence:** +1. Extension calls `object.request` +2. Viewer responds with `{ success: true }` (or error) +3. Viewer sends `object.publish` notification (asynchronously, when ready) diff --git a/package.json b/package.json index a0b48cc..27cb881 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,18 @@ "url": "https://github.com/secondlife/sl-vscode-plugin.git" }, "contributes": { + "resourceLabelFormatters": [ + { + "scheme": "sl", + "authority": "objects", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": false, + "workspaceSuffix": "Second Life" + } + } + ], "commands": [ { "command": "second-life-scripting.enable", diff --git a/src/extension.ts b/src/extension.ts index 4ad5f35..e5c92ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,16 @@ import * as vscode from "vscode"; import { SynchService } from "./synchservice"; import { LanguageService } from "./shared/languageservice"; +import { ObjectContentService } from "./vscode/objectcontentservice"; +import { ObjectContentProvider, SL_SCHEME, SL_AUTHORITY, rootUri } from "./vscode/objectcontentprovider"; +import { ObjectContentDecorator } from "./vscode/ObjectContentDecorator"; import { ConfigService, configPrefix } from "./configservice"; import { VSCodeHost, getOutputChannel, showOutputChannel, logInfo, + logDebug, showStatusMessage, hasWorkspace, showErrorMessage @@ -28,6 +32,95 @@ export function activate(context: vscode.ExtensionContext): void { // Initialize the file sync functionality const synchService = SynchService.getInstance(context); + // Initialize object content service and register the sl:// FileSystemProvider + const objectContentService = ObjectContentService.getInstance(); + const objectContentProvider = new ObjectContentProvider( + objectContentService, + () => synchService.getWebSocket(), + ); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(SL_SCHEME, objectContentProvider, { + isCaseSensitive: true, + }), + objectContentProvider, + objectContentService, + ); + + // Register file decoration provider for sl:// URIs (shows disconnected state) + const objectContentDecorator = new ObjectContentDecorator( + () => synchService.isConnected(), + (listener) => synchService.onDidChangeConnectionState(listener), + ); + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(objectContentDecorator), + objectContentDecorator, + ); + + // Manage workspace folders for published objects so Explorer shows friendly names + context.subscriptions.push( + objectContentService.onDidChangeObjects(({ type, object_id }) => { + const folders = vscode.workspace.workspaceFolders ?? []; + const slIdx = folders.findIndex( + (f) => f.uri.scheme === SL_SCHEME && f.uri.authority === SL_AUTHORITY && f.uri.path === `/${object_id}` + ); + if (type === "added") { + const entry = objectContentService.getObject(object_id); + if (entry && slIdx === -1) { + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } else if (type === "removed") { + if (slIdx !== -1) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1); + } + } + }) + ); + + // Register URI handler so the viewer can launch VS Code and trigger a connection. + // URI format: vscode://lindenlab.sl-vscode-plugin/connect?port=9020[&object=][&script=] + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): void { + + logDebug(`Received URI: ${uri.toString()}`); + + if (uri.path !== "/connect") { return; } + + // Decode the query string first - some viewers incorrectly encode the delimiters + const decodedQuery = decodeURIComponent(uri.query); + logDebug(`Decoded query: ${decodedQuery}`); + + const query = Object.fromEntries( + decodedQuery.split("&").filter(Boolean).map(p => { + const eq = p.indexOf("="); + return eq === -1 + ? [p, ""] + : [p.slice(0, eq), p.slice(eq + 1)]; + }) + ); + + logDebug(`Parsed query: ${JSON.stringify(query)}`); + + const rawPort = query["port"] as string | undefined; + const port = rawPort !== undefined ? parseInt(rawPort, 10) : undefined; + if (port !== undefined && (isNaN(port) || port < 1024 || port > 65535)) { + showErrorMessage(`Second Life: Invalid port in launch URI: ${rawPort}`); + return; + } + + const object_id = query["object"] as string | undefined; + const script_id = query["script"] as string | undefined; + + logInfo(`Connecting with port=${port}, object_id=${object_id ?? "(none)"}, script_id=${script_id ?? "(none)"}`); + + synchService.connectToViewer({ port, object_id, script_id }); + } + }) + ); + // Register output channel for disposal context.subscriptions.push(getOutputChannel()); diff --git a/src/synchservice.ts b/src/synchservice.ts index 465cb9d..df80ade 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -20,6 +20,11 @@ import { RuntimeDebug, RuntimeError, } from "./viewereditwsclient"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, +} from "./vscode/objectcontentinterfaces"; import { hasWorkspace, showInfoMessage, @@ -35,6 +40,7 @@ import { ScriptSync } from "./scriptsync"; import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; +import { ObjectContentService } from "./vscode/objectcontentservice"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -50,6 +56,8 @@ export class SynchService implements vscode.Disposable { private activeSync: ScriptSync | undefined; private host: HostInterface; private initialGenerationDone: boolean = false; + private pendingLaunchObjectId?: string; + private pendingLaunchScriptId?: string; public viewerName?: string; public viewerVersion?: string; @@ -62,12 +70,17 @@ export class SynchService implements vscode.Disposable { private syncedFileDecorator : SyncedFileDecorator; + private _onDidChangeConnectionState = new vscode.EventEmitter(); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private disposables: vscode.Disposable[] = []; private constructor(context: vscode.ExtensionContext) { this.context = context; this.host = new VSCodeHost(); this.syncedFileDecorator = new SyncedFileDecorator(this); + // Note: _onDidChangeConnectionState is NOT added to disposables + // because it must survive activate/deactivate cycles } public static getInstance(context?: vscode.ExtensionContext): SynchService { @@ -337,7 +350,7 @@ export class SynchService implements vscode.Disposable { //==================================================================== //#region WebSocket connection management and handlers - private async setupConnection(): Promise { + private async setupConnection(portOverride?: number): Promise { const handlers = { onHandshake: (message: SessionHandshake): any => this.onHandshake(message), onHandshakeOk: (): any => this.onHandshakeOk(), @@ -348,7 +361,9 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - + onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), + onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), }; if (this.websocket && this.websocket.isConnected()) { @@ -359,9 +374,11 @@ export class SynchService implements vscode.Disposable { this.getHandshakePromise(); showStatusMessage("Connecting to Second Life viewer...", handshake); + const port = portOverride + ?? this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020); this.websocket = new ViewerEditWSClient( this.context, - `ws://localhost:${this.host.config.getConfig(ConfigKey.NetworkWebsocketPort, 9020)}` + `ws://localhost:${port}` ); this.websocket.setup(handlers); let connected = await this.websocket.connect(); @@ -424,6 +441,7 @@ export class SynchService implements vscode.Disposable { error_reporting: true, debugging: false, breakpoints: false, + object_publish: true, }, }; return response; @@ -456,6 +474,10 @@ export class SynchService implements vscode.Disposable { if (this.handshakeResolve) { this.handshakeResolve(true, "Connected"); } + + this._onDidChangeConnectionState.fire(true); + + await this.handleLaunchParams(); } private onDisconnect(params: SessionDisconnect): void { @@ -472,8 +494,11 @@ export class SynchService implements vscode.Disposable { ); } + this._onDidChangeConnectionState.fire(false); + // Don't dispose immediately - let the connection close handler do the cleanup // The websocket will be closed by the server, triggering our close handler + ObjectContentService.getInstance().clear(); } private onScriptUnsubscribe(message: ScriptUnsubscribe): void { @@ -872,6 +897,10 @@ export class SynchService implements vscode.Disposable { public getWebSocket(): ViewerEditWSClient | undefined { return this.websocket; } + + public isConnected(): boolean { + return this.websocket?.isConnected() ?? false; + } //#endregion //==================================================================== @@ -954,6 +983,58 @@ export class SynchService implements vscode.Disposable { } //#endregion + public async connectToViewer(params: { port?: number; object_id?: string; script_id?: string }): Promise { + this.pendingLaunchObjectId = params.object_id; + this.pendingLaunchScriptId = params.script_id; + + if (this.websocket?.isConnected()) { + // Already connected — act on params immediately + await this.handleLaunchParams(); + return; + } + + await this.setupConnection(params.port); + // handleLaunchParams is called from onHandshakeOk + } + + private async handleLaunchParams(): Promise { + const objectId = this.pendingLaunchObjectId; + const scriptId = this.pendingLaunchScriptId; + this.pendingLaunchObjectId = undefined; + this.pendingLaunchScriptId = undefined; + + if (objectId && this.websocket?.isConnected()) { + const result = await this.websocket.requestObject({ object_id: objectId }); + if (!result.success) { + showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } + } + + if (scriptId && this.websocket?.isConnected()) { + await this.openScriptById(scriptId); + } + } + + private async openScriptById(scriptId: string): Promise { + if (!this.websocket) { return; } + const list = await this.websocket.getScriptList(); + if (!list.success) { return; } + + try { + const files = await fs.promises.readdir(list.temp_dir); + const match = files.find(f => f.includes(scriptId)); + if (match) { + const tempPath = path.join(list.temp_dir, match); + await vscode.window.showTextDocument(vscode.Uri.file(tempPath)); + // onOpenTextDocument fires and handles the normal subscribe + sync flow + } else { + showWarningMessage(`Script ${scriptId} not found in viewer temp directory`); + } + } catch { + showWarningMessage(`Could not open script ${scriptId} from temp directory`); + } + } + public activate(): void { this.deactivate(); this.initialize(); diff --git a/src/utils.ts b/src/utils.ts index bea5205..e346133 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -36,6 +36,17 @@ export function logInfo(message: string): void { channel.appendLine(`[${timestamp}] INFO: ${message}`); } +/** + * Log a debug message to the output channel (only when debug logging is enabled) + */ +export function logDebug(message: string): void { + // TODO: Check a debug setting to conditionally log + // For now, always log debug messages + const channel = getOutputChannel(); + const timestamp = new Date().toISOString(); + channel.appendLine(`[${timestamp}] DEBUG: ${message}`); +} + /** * Log a warning message to the output channel */ diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 90722b2..9748d2c 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -7,6 +7,23 @@ import { JSONRPCClient } from "./websockclient"; import { ConfigService } from "./configservice"; import { ConfigKey } from "./interfaces/configinterface"; import { showStatusMessage } from "./utils"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + ObjectContentGetParams, + ObjectContentGetResponse, + ObjectContentSaveParams, + ObjectContentSaveResponse, + ObjectItemCreateParams, + ObjectItemCreateResponse, + ObjectItemDeleteParams, + ObjectItemDeleteResponse, + ObjectScriptSetRunningParams, + ObjectScriptSetRunningResponse, + ObjectRequestParams, + ObjectRequestResponse, +} from "./vscode/objectcontentinterfaces"; //#region Message Formats @@ -67,6 +84,12 @@ export interface SyntaxCacheList { success: boolean; } +export interface ScriptList { + temp_dir: string; + script_ids: string[]; + success: boolean; +} + export interface SyntaxCacheGetRequest { filename: string; as_json?: boolean; @@ -124,6 +147,9 @@ export interface WebSocketHandlers { onRuntimeDebug?: (message: RuntimeDebug) => void; onRuntimeError?: (message: RuntimeError) => void; onConnectionClosed?: () => void; + onObjectPublish?: (message: ObjectPublishMessage) => void; + onObjectUnpublish?: (message: ObjectUnpublishMessage) => void; + onObjectUpdate?: (message: ObjectUpdateMessage) => void; } /** @@ -186,6 +212,9 @@ export class ViewerEditWSClient extends JSONRPCClient { this.on("script.compiled", this.handlers.onCompilationResult); this.on("runtime.debug", this.handlers.onRuntimeDebug); this.on("runtime.error", this.handlers.onRuntimeError); + this.on("object.publish", this.handlers.onObjectPublish); + this.on("object.unpublish", this.handlers.onObjectUnpublish); + this.on("object.update", this.handlers.onObjectUpdate); // Setup connection close handler this.setupConnectionCloseHandler(); @@ -226,6 +255,38 @@ export class ViewerEditWSClient extends JSONRPCClient { } } + // ============================================ + // Object Content Calls (Extension → Viewer) + // ============================================ + + public getObjectContent(params: ObjectContentGetParams): Promise { + return this.call("object.content.get", params); + } + + public saveObjectContent(params: ObjectContentSaveParams): Promise { + return this.call("object.content.save", params); + } + + public createObjectItem(params: ObjectItemCreateParams): Promise { + return this.call("object.item.create", params); + } + + public deleteObjectItem(params: ObjectItemDeleteParams): Promise { + return this.call("object.item.delete", params); + } + + public setScriptRunning(params: ObjectScriptSetRunningParams): Promise { + return this.call("object.script.set_running", params); + } + + public requestObject(params: ObjectRequestParams): Promise { + return this.call("object.request", params); + } + + public getScriptList(): Promise { + return this.call("script.list", {}); + } + private setupConnectionCloseHandler(): void { // Instead of overriding dispose, use a periodic check for connection state const checkConnectionInterval = setInterval(() => { From 83ffd934264763b2a912d923f7d5faee6aec2593 Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Tue, 19 May 2026 08:57:10 -0700 Subject: [PATCH 09/10] [WIP] Act as a consumer for objects published from the viewer. Treat them as virtual file systems, so that they appear as folders in the explorer tab. Not yet in a stable state. --- doc/Message_Interfaces.md | 144 ++++--- src/synchservice.ts | 24 +- src/vscode/ObjectContentDecorator.ts | 86 ++++ src/vscode/objectcontentinterfaces.ts | 238 +++++++++++ src/vscode/objectcontentprovider.ts | 587 ++++++++++++++++++++++++++ src/vscode/objectcontentservice.ts | 332 +++++++++++++++ src/websockclient.ts | 38 +- 7 files changed, 1382 insertions(+), 67 deletions(-) create mode 100644 src/vscode/ObjectContentDecorator.ts create mode 100644 src/vscode/objectcontentinterfaces.ts create mode 100644 src/vscode/objectcontentprovider.ts create mode 100644 src/vscode/objectcontentservice.ts diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 45b725d..022378a 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -4,45 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [VS Code Launch URI](#vs-code-launch-uri) -- [JSON-RPC Method Summary](#json-rpc-method-summary) -- [Session Management Interfaces](#session-management-interfaces) - - [SessionHandshake](#sessionhandshake) - - [SessionHandshakeResponse](#sessionhandshakeresponse) - - [Session OK](#session-ok) - - [SessionDisconnect](#sessiondisconnect) -- [Language and Syntax Interfaces](#language-and-syntax-interfaces) - - [SyntaxChange](#syntaxchange) - - [Language Syntax ID Request](#language-syntax-id-request) - - [Language Syntax Request](#language-syntax-request) - - [Language Syntax Cache List](#language-syntax-cache-list) - - [Language Syntax Cache Get](#language-syntax-cache-get) -- [Script Subscription Interfaces](#script-subscription-interfaces) - - [ScriptSubscribe](#scriptsubscribe) - - [ScriptSubscribeResponse](#scriptsubscriberesponse) - - [ScriptUnsubscribe](#scriptunsubscribe) - - [ScriptList](#scriptlist) -- [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) - - [CompilationResult](#compilationresult) -- [Runtime Event Interfaces](#runtime-event-interfaces) - - [RuntimeDebug](#runtimedebug) - - [RuntimeError](#runtimeerror) -- [Handler and Configuration Interfaces](#handler-and-configuration-interfaces) - - [WebSocketHandlers](#websockethandlers) - - [ClientInfo](#clientinfo) -- [Object Content Interfaces](#object-content-interfaces) - - [Core Data Types](#core-data-types) - - [ObjectPublish](#objectpublish) - - [ObjectUnpublish](#objectunpublish) - - [ObjectUpdate](#objectupdate) - - [ObjectContentGet](#objectcontentget) - - [ObjectContentSave](#objectcontentsave) - - [ObjectItemCreate](#objectitemcreate) - - [ObjectItemDelete](#objectitemdelete) - - [ObjectScriptSetRunning](#objectscriptsetrunning) - - [ObjectRequest](#objectrequest) +- [Usage Flow](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#usage-flow) +- [VS Code Launch URI](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#vs-code-launch-uri) +- [JSON-RPC Method Summary](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#json-rpc-method-summary) +- [Session Management Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-management-interfaces) + - [SessionHandshake](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshake) + - [SessionHandshakeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessionhandshakeresponse) + - [Session OK](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#session-ok) + - [SessionDisconnect](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#sessiondisconnect) +- [Language and Syntax Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-and-syntax-interfaces) + - [SyntaxChange](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#syntaxchange) + - [Language Syntax ID Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-id-request) + - [Language Syntax Request](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-request) + - [Language Syntax Cache List](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-list) + - [Language Syntax Cache Get](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#language-syntax-cache-get) +- [Script Subscription Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#script-subscription-interfaces) + - [ScriptSubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscribe) + - [ScriptSubscribeResponse](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptsubscriberesponse) + - [ScriptUnsubscribe](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptunsubscribe) + - [ScriptList](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#scriptlist) +- [Compilation Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilation-interfaces) + - [CompilationError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationerror) + - [CompilationResult](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#compilationresult) +- [Runtime Event Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtime-event-interfaces) + - [RuntimeDebug](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimedebug) + - [RuntimeError](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#runtimeerror) +- [Handler and Configuration Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#handler-and-configuration-interfaces) + - [WebSocketHandlers](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#websockethandlers) + - [ClientInfo](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#clientinfo) +- [Object Content Interfaces](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#object-content-interfaces) + - [Core Data Types](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#core-data-types) + - [ObjectPublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectpublish) + - [ObjectUnpublish](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectunpublish) + - [ObjectUpdate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectupdate) + - [ObjectContentGet](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentget) + - [ObjectContentSave](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectcontentsave) + - [ObjectItemCreate](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemcreate) + - [ObjectItemDelete](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectitemdelete) + - [ObjectScriptSetRunning](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectscriptsetrunning) + - [ObjectRequest](../../../VSCode/sl-vscode-edit/doc/Message_Interfaces.md#objectrequest) ## Usage Flow @@ -378,14 +378,14 @@ interface SyntaxCacheList { | -------------------------------- | ---------------------------------------------------- | | `builtins.txt` | LSL built-in keyword list in plain text format | | `lsl_definitions.yaml` | LSL language definitions in YAML format | -| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format | +| `lsl_keywords.xml` | LSL keyword definitions in LLSD XML format. Used by the viewer's script editor | | `lsl_keywords_pretty.xml` | LSL keyword definitions in formatted LLSD XML format | -| `slua_default.d.luau` | Luau type definition file for editor tooling | -| `slua_default.docs.json` | Luau documentation data in JSON format | +| `secondlife.d.luau` | Luau type definition file. Used by luau-lsp | +| `secondlife.docs.json` | Luau documentation data in JSON format. Used by luau-lsp | | `slua_definitions.yaml` | Luau language definitions in YAML format | -| `slua_keywords.xml` | Luau keyword definitions in LLSD XML format | -| `slua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | -| `slua_selene.yml` | Luau Selene linter configuration in YAML format | +| `lua_keywords.xml` | Luau keyword definitions in LLSD XML format. Used by the viewer's script editor | +| `lua_keywords_pretty.xml` | Luau keyword definitions in formatted LLSD XML format | +| `secondlife_selene.yml` | Luau Selene linter configuration in YAML format | Not all files may be present in every cache — the actual list returned by `language.syntax.cache` reflects only what is available on the viewer's local filesystem at the time of the request. @@ -683,7 +683,7 @@ These interfaces support publishing in-world object inventories (scripts and not ```typescript type InventoryItemType = "script" | "notecard"; -type ScriptVM = "lso" | "mono" | "luau"; +type ScriptVM = "lsl2" | "mono" | "luau"; /** Permission mask fields. Only owner and next_owner are transmitted. */ interface ItemPermissions { @@ -838,10 +838,10 @@ interface ObjectContentGetParams { } interface ObjectContentGetResponse { + success: boolean; prim_id: string; item_id: string; - content: string; - encoding?: "utf-8" | "base64"; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. } ``` @@ -849,8 +849,8 @@ interface ObjectContentGetResponse { - `prim_id`: UUID of the prim that owns the item. Child prims are addressable directly by UUID without knowing the root object_id. - `item_id`: Inventory item UUID. -- `content`: The raw text content of the item. -- `encoding` (optional): Encoding used for `content`. Defaults to `"utf-8"` if absent. +- `success`: `true` on success. +- `content`: The raw text content of the item. For notecards, the `Linden text version 2` envelope is stripped — only the body text is returned. --- @@ -865,17 +865,28 @@ interface ObjectContentSaveParams { prim_id: string; item_id: string; content: string; + vm?: "mono" | "lsl2" | "luau"; } interface ObjectContentSaveResponse { success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; message?: string; } ``` **Fields:** -- `success`: Whether the save (and compilation, if applicable) succeeded. +- `prim_id`: UUID of the prim that owns the saved item. +- `item_id`: UUID of the saved inventory item. +- `content`: Raw script/notecard source text to store. +- `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. +- `success`: Whether the upload/save operation succeeded. +- `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. +- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. - `message` (optional): Error description on failure. --- @@ -884,25 +895,33 @@ interface ObjectContentSaveResponse { **JSON-RPC Method:** `object.item.create` (call from extension to viewer) -Creates a new script or notecard in a prim's inventory. +Creates a new script in a prim's inventory. The call is asynchronous — the viewer sends +`RezScript` to the simulator and waits for the inventory-changed callback before returning +the created item's details. The simulator may rename the item if a duplicate name exists. + +Notecard creation is not yet supported and will return an error. ```typescript interface ObjectItemCreateParams { - prim_id: string; + prim_id: string; // UUID of the prim to create the item in name: string; // Pure SL inventory name — no file extension - type: InventoryItemType; // "script" | "notecard" - vm?: ScriptVM; // Scripts only: target VM. Defaults to viewer default if absent. - content?: string; // Initial content. Empty item created if absent. + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "lsl" } -interface ObjectItemCreateResponse { - success: boolean; - item_id?: string; // UUID of the created item (on success) - item?: ObjectInventoryItem; // Full item metadata including assigned subtype (on success) - message?: string; // Error description (on failure) +// On success, returns an ObjectInventoryItem with prim_id: +interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID } ``` +**Notes:** +- The response matches the `ObjectInventoryItem` structure (same fields as items in + `object.publish` and `object.update` notifications). +- The `name` in the response may differ from the request if the simulator renamed it. +- An `object.update` notification will also fire for the prim (since inventory changed). +- Timeout: 30 seconds. Returns a JSON-RPC internal error if the simulator does not respond. + --- ### ObjectItemDelete @@ -919,7 +938,8 @@ interface ObjectItemDeleteParams { interface ObjectItemDeleteResponse { success: boolean; - message?: string; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request } ``` diff --git a/src/synchservice.ts b/src/synchservice.ts index df80ade..e5270cd 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -30,6 +30,7 @@ import { showInfoMessage, showStatusMessage, showWarningMessage, + logDebug, logInfo, VSCodeHost, closeTextDocument, @@ -361,9 +362,18 @@ export class SynchService implements vscode.Disposable { onCompilationResult: (message: CompilationResult): any => this.onCompilationResult(message), onRuntimeDebug: (message: RuntimeDebug): any => this.onRuntimeDebug(message), onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), - onObjectPublish: (msg: ObjectPublishMessage): any => ObjectContentService.getInstance().handlePublish(msg), - onObjectUnpublish: (msg: ObjectUnpublishMessage): any => ObjectContentService.getInstance().handleUnpublish(msg), - onObjectUpdate: (msg: ObjectUpdateMessage): any => ObjectContentService.getInstance().handleUpdate(msg), + onObjectPublish: (msg: ObjectPublishMessage): any => { + logDebug(`[object.publish] object_id=${msg.object.object_id}`); + ObjectContentService.getInstance().handlePublish(msg); + }, + onObjectUnpublish: (msg: ObjectUnpublishMessage): any => { + logDebug(`[object.unpublish] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUnpublish(msg); + }, + onObjectUpdate: (msg: ObjectUpdateMessage): any => { + logDebug(`[object.update] object_id=${msg.object_id}`); + ObjectContentService.getInstance().handleUpdate(msg); + }, }; if (this.websocket && this.websocket.isConnected()) { @@ -1005,8 +1015,14 @@ export class SynchService implements vscode.Disposable { if (objectId && this.websocket?.isConnected()) { const result = await this.websocket.requestObject({ object_id: objectId }); - if (!result.success) { + if (result.object) { + logDebug(`[object.request] response contained object_id=${result.object.object_id}`); + ObjectContentService.getInstance().handlePublish({ object: result.object }); + } else if (result.success === false) { showWarningMessage(`Failed to request object: ${result.message ?? "unknown error"}`); + } else { + // Keep this visible while we support mixed viewer versions. + logDebug("[object.request] response contained no object payload; waiting for object.publish notification"); } } diff --git a/src/vscode/ObjectContentDecorator.ts b/src/vscode/ObjectContentDecorator.ts new file mode 100644 index 0000000..c23585a --- /dev/null +++ b/src/vscode/ObjectContentDecorator.ts @@ -0,0 +1,86 @@ +/** + * @file ObjectContentDecorator.ts + * File decoration provider for sl:// virtual filesystem entries. + * Shows visual indicators for connection state, script running state, etc. + * Copyright (C) 2025, Linden Research, Inc. + */ +import { + FileDecorationProvider, + Uri, + FileDecoration, + EventEmitter, + ProviderResult, + CancellationToken, + ThemeColor, + Disposable, +} from "vscode"; +import { SL_SCHEME } from "./objectcontentprovider"; +import { logDebug } from "../utils"; + +/** + * Provides file decorations for sl:// URIs based on connection state. + * When disconnected from the viewer, shows a red badge and tooltip. + */ +export class ObjectContentDecorator implements FileDecorationProvider, Disposable { + private _onDidChangeFileDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private isConnected: boolean = false; + private disposables: Disposable[] = []; + + constructor( + private readonly getConnectionState: () => boolean, + onConnectionChange: (listener: (connected: boolean) => void) => Disposable, + ) { + this.isConnected = getConnectionState(); + logDebug(`[ObjectContentDecorator] Initial connection state: ${this.isConnected}`); + + // Listen for connection state changes + const subscription = onConnectionChange((connected) => { + logDebug(`[ObjectContentDecorator] Connection state changed: ${connected}`); + this.isConnected = connected; + // Refresh all sl:// decorations (deferred to ensure processing when not focused) + setTimeout(() => { + this._onDidChangeFileDecorations.fire(undefined); + }, 0); + }); + this.disposables.push( + subscription, + this._onDidChangeFileDecorations, + ); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + provideFileDecoration(uri: Uri, _token: CancellationToken): ProviderResult { + // Only decorate sl:// URIs + if (uri.scheme !== SL_SCHEME) { + return undefined; + } + + // When disconnected, show red badge with warning + if (!this.isConnected) { + return { + badge: "⚠", + tooltip: "Not connected to Second Life viewer", + color: new ThemeColor("errorForeground"), + }; + } + + // Connected state - no special decoration for now + // Future: could show script running state, dirty state, etc. + return undefined; + } + + /** + * Manually trigger a refresh of decorations for specific URIs or all sl:// URIs. + */ + public refresh(uri?: Uri | Uri[]): void { + this._onDidChangeFileDecorations.fire(uri); + } +} diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts new file mode 100644 index 0000000..d4ee775 --- /dev/null +++ b/src/vscode/objectcontentinterfaces.ts @@ -0,0 +1,238 @@ +/** + * @file objectcontentinterfaces.ts + * Interfaces for object content publishing feature + * Copyright (C) 2025, Linden Research, Inc. + */ + +// ============================================ +// Core Data Types +// ============================================ + +/** + * Types of inventory items supported by the object content provider. + * Only scripts and notecards are supported; other item types are not exposed. + */ +export type InventoryItemType = "script" | "notecard"; + +/** Script virtual machine / compiler target */ +export type ScriptVM = "lsl2" | "mono" | "luau"; + +/** Save-time compile target accepted by object.content.save */ +export type ObjectContentSaveVM = "mono" | "lsl2" | "luau"; + +/** + * Permission masks from viewer's LLPermissions. + * Only owner and next_owner masks are transmitted. + * Bit flags: PERM_MODIFY=0x4000, PERM_COPY=0x8000, PERM_TRANSFER=0x2000 + */ +export interface ItemPermissions { + owner: number; // Current owner's permission mask + next_owner: number; // Applied on transfer +} + +/** + * Inventory item within an object or linked prim. + * Only scripts and notecards are supported; other item types are not exposed. + * Note: asset_id is intentionally not transmitted for security. + */ +export interface ObjectInventoryItem { + item_id: string; // Inventory item UUID (unique within container) + name: string; // Display name + description?: string; // Item description + type: InventoryItemType; + /** Scripts only: script language subtype from viewer's II_FLAGS_SUBTYPE_MASK (0=LSL, 1=Luau) */ + subtype?: number; + /** Scripts only: which VM the script targets/was compiled for (from script metadata) */ + vm?: ScriptVM; + /** For scripts only: whether the script is currently running */ + running?: boolean; + permissions?: ItemPermissions; + creator_id?: string; // Creator UUID +} + +/** + * A linked prim within a linkset + */ +export interface LinkedObject { + link_id: string; // UUID of the linked prim + link_number: number; // Link number (2+ = children; root is 1) + link_name: string; // Display name of the linked prim + link_description?: string; + inventory: ObjectInventoryItem[]; +} + +/** + * Permission masks for the object itself + */ +export interface ObjectPermissions { + owner: number; + next_owner: number; +} + +/** + * Published object container (root of a linkset) + */ +export interface PublishedObject { + object_id: string; // UUID of the root prim + object_name: string; // Display name of root prim + object_description?: string; + region?: string; // Region where object exists + owner_id?: string; // Owner UUID + permissions?: ObjectPermissions; + inventory: ObjectInventoryItem[]; // Root prim inventory (scripts and notecards only) + linked_objects?: LinkedObject[]; // Child prims in linkset +} + +// ============================================ +// WebSocket Message Interfaces +// ============================================ + +/** object.publish — Viewer → Extension (notification) */ +export interface ObjectPublishMessage { + object: PublishedObject; +} + +/** object.unpublish — Viewer → Extension (notification) */ +export interface ObjectUnpublishMessage { + object_id: string; + reason?: string; +} + +/** Delta changes for inventory items */ +export interface InventoryChanges { + added?: ObjectInventoryItem[]; + removed?: string[]; // item_ids + modified?: ObjectInventoryItem[]; // metadata changes + content_changed?: string[]; // item_ids (invalidates cache) + running_changed?: { item_id: string; running: boolean }[]; +} + +/** Delta changes for linked objects */ +export interface LinkedObjectChanges { + added?: LinkedObject[]; + removed?: string[]; // link_ids + modified?: { + link_id: string; + link_name?: string; + inventory?: InventoryChanges; + }[]; +} + +/** + * object.update — Viewer → Extension (notification) + * Supports full replacement or delta-based updates. + * If `changes` is present it takes precedence over full replacement fields. + */ +export interface ObjectUpdateMessage { + object_id: string; + object_name?: string; + // Full replacement + inventory?: ObjectInventoryItem[]; + linked_objects?: LinkedObject[]; + // Delta + changes?: { + inventory?: InventoryChanges; + linked_objects?: LinkedObjectChanges; + }; +} + +/** object.content.get — Extension → Viewer (call) */ +export interface ObjectContentGetParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.content.get response */ +export interface ObjectContentGetResponse { + prim_id: string; + item_id: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +/** object.content.save — Extension → Viewer (call) */ +export interface ObjectContentSaveParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + content: string; + vm?: ObjectContentSaveVM; // Scripts only: explicit compile target +} + +/** object.content.save response */ +export interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; // Scripts only: true if compilation succeeded + errors?: string[]; // Scripts only: compiler diagnostics when compiled is false + message?: string; +} + +/** object.item.create — Extension → Viewer (call) */ +export interface ObjectItemCreateParams { + prim_id: string; // UUID of any prim (root or child) + name: string; // Item name (no extension — pure SL inventory name) + type: InventoryItemType; // "script" for current create flow ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "mono" | "lsl2" +} + +/** object.item.create response */ +export interface ObjectItemCreateResponse extends ObjectInventoryItem { + prim_id: string; // Echoed prim UUID +} + +/** object.item.delete — Extension → Viewer (call) */ +export interface ObjectItemDeleteParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; +} + +/** object.item.delete response */ +export interface ObjectItemDeleteResponse { + success: boolean; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} + +/** object.script.set_running — Extension → Viewer (call) */ +export interface ObjectScriptSetRunningParams { + prim_id: string; // UUID of any prim (root or child) + item_id: string; + running: boolean; // true = start, false = stop +} + +/** object.script.set_running response */ +export interface ObjectScriptSetRunningResponse { + success: boolean; + message?: string; +} + +/** object.request — Extension → Viewer (call) */ +export interface ObjectRequestParams { + object_id: string; // UUID of the root prim to request publishing for +} + +/** object.request response */ +export interface ObjectRequestResponse { + object?: PublishedObject; // Primary response payload for requested object + success?: boolean; // Legacy compatibility for older viewers + message?: string; // reason on failure (e.g. "object not found", "permission denied") +} + +// ============================================ +// Internal Service Types (not transmitted) +// ============================================ + +/** Cached content for an inventory item */ +export interface CachedItemContent { + content: Uint8Array; + mtime: number; + dirty: boolean; +} + +/** Internal representation of a published object with content cache */ +export interface ObjectEntry { + object: PublishedObject; + contentCache: Map; // keyed by item_id + publishedAt: number; +} diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts new file mode 100644 index 0000000..aaddf2a --- /dev/null +++ b/src/vscode/objectcontentprovider.ts @@ -0,0 +1,587 @@ +/** + * @file objectcontentprovider.ts + * VS Code FileSystemProvider for the sl:// virtual filesystem. + * Presents published Second Life in-world objects as browseable directories. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { ObjectContentService } from "./objectcontentservice"; +import { ViewerEditWSClient } from "../viewereditwsclient"; +import { + InventoryItemType, + ObjectContentSaveVM, + ObjectInventoryItem, + ScriptVM, +} from "./objectcontentinterfaces"; + +// ============================================ +// Constants +// ============================================ + +export const SL_SCHEME = "sl"; +export const SL_AUTHORITY = "objects"; + +/** PERM_MODIFY bit from viewer LLPermissions */ +const PERM_MODIFY = 0x4000; + +// JSON-RPC error codes used by the viewer +const JSONRPC_INVALID_PARAMS = -32602; +const JSONRPC_FORBIDDEN = -32003; +const JSONRPC_TIMEOUT = -32001; +const JSONRPC_INTERNAL_ERROR = -32603; + +/** + * Extract JSON-RPC error code from error message. + * The websocket client formats errors as "JSON-RPC Error {code}: {message}" + */ +function extractJsonRpcErrorCode(error: Error): number | undefined { + const match = error.message.match(/^JSON-RPC Error (-?\d+):/); + return match ? parseInt(match[1], 10) : undefined; +} + +/** + * Map JSON-RPC error to appropriate FileSystemError. + */ +function mapRpcErrorToFileSystemError(error: unknown, uri: vscode.Uri): Error { + if (!(error instanceof Error)) { + return vscode.FileSystemError.Unavailable(uri); + } + + const code = extractJsonRpcErrorCode(error); + switch (code) { + case JSONRPC_INVALID_PARAMS: + // Prim not found, item not found, or invalid item type + return vscode.FileSystemError.FileNotFound(uri); + case JSONRPC_FORBIDDEN: + // Object not published or insufficient permissions + return vscode.FileSystemError.NoPermissions(uri); + case JSONRPC_TIMEOUT: + // Simulator didn't respond in time + return vscode.FileSystemError.Unavailable(`Request timed out: ${uri}`); + case JSONRPC_INTERNAL_ERROR: + // Asset cache issue or other internal error + return vscode.FileSystemError.Unavailable(error.message); + default: + // Pass through the original error message + return vscode.FileSystemError.Unavailable(error.message); + } +} + +// ============================================ +// URI Helpers +// ============================================ + +interface ParsedObjectUri { + /** Root prim UUID (always present) */ + root_id: string; + /** Child prim UUID if path has 2+ segments, otherwise undefined */ + link_id?: string; + /** Item UUID if this is a file URI, otherwise undefined */ + item_id?: string; + /** Unresolved leaf filename for create flows */ + pending_name?: string; + /** Whether this URI refers to a directory */ + isDirectory: boolean; +} + +interface ParseUriOptions { + allowMissingLeaf?: boolean; +} + +/** Check if a string looks like a UUID */ +function isUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Find an item in an inventory list by its display name. + * Returns the item_id if found, undefined otherwise. + */ +function findItemByDisplayName( + inventory: ObjectInventoryItem[] | undefined, + filename: string +): string | undefined { + if (!inventory) return undefined; + const item = inventory.find((i) => displayName(i) === filename); + return item?.item_id; +} + +/** + * Parse an sl://objects/... URI into its components. + * + * URI shapes: + * sl://objects/{root_id} → root directory + * sl://objects/{root_id}/{seg} → file in root (item_id or display name) + * OR child prim directory (link_id = seg) + * sl://objects/{root_id}/{link_id}/{seg} → file in child prim (item_id or display name) + * + * For file URIs, the segment can be either: + * - A UUID (item_id directly) — used by API calls + * - A display name (e.g., "Hello World.lsl") — used by Explorer + * + * Directories are distinguished from files by checking the object tree. + */ +function parseUri( + uri: vscode.Uri, + service: ObjectContentService, + options?: ParseUriOptions +): ParsedObjectUri { + // Strip leading slash and split + const parts = uri.path.replace(/^\//, "").split("/").filter((p) => p.length > 0); + + if (parts.length === 0) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const root_id = parts[0]; + const entry = service.getObject(root_id); + + if (parts.length === 1) { + return { root_id, isDirectory: true }; + } + + const seg1 = parts[1]; + + if (parts.length === 2) { + // Is seg1 a linked prim (directory) or an item (file)? + const isLinkedPrim = + entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + + if (isLinkedPrim) { + return { root_id, link_id: seg1, isDirectory: true }; + } + + // seg1 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg1)) { + item_id = seg1; + } else { + item_id = findItemByDisplayName(entry?.object.inventory, seg1); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, pending_name: seg1, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, item_id, isDirectory: false }; + } + + if (parts.length === 3) { + const link_id = parts[1]; + const seg2 = parts[2]; + + // seg2 is either a UUID or a display name + let item_id: string | undefined; + if (isUUID(seg2)) { + item_id = seg2; + } else { + const linkedObj = entry?.object.linked_objects?.find((lo) => lo.link_id === link_id); + item_id = findItemByDisplayName(linkedObj?.inventory, seg2); + } + if (!item_id) { + if (options?.allowMissingLeaf) { + return { root_id, link_id, pending_name: seg2, isDirectory: false }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + return { root_id, link_id, item_id, isDirectory: false }; + } + + throw vscode.FileSystemError.FileNotFound(uri); +} + +/** Build a URI for a root prim directory */ +export function rootUri(root_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}` }); +} + +/** Build a URI for a linked prim directory */ +export function linkedPrimUri(root_id: string, link_id: string): vscode.Uri { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${link_id}` }); +} + +/** Build a URI for a file (item in root or linked prim) */ +export function itemUri(root_id: string, prim_id: string, item_id: string): vscode.Uri { + if (prim_id === root_id) { + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${item_id}` }); + } + return vscode.Uri.from({ scheme: SL_SCHEME, authority: SL_AUTHORITY, path: `/${root_id}/${prim_id}/${item_id}` }); +} + +// ============================================ +// Display Name Helpers +// ============================================ + +/** Map item subtype to the appropriate file extension for display */ +function extensionForItem(item: ObjectInventoryItem): string { + if (item.type === "notecard") return ".txt"; + return item.subtype === 1 ? ".luau" : ".lsl"; +} + +/** Returns the display filename (name + synthetic extension) */ +function displayName(item: ObjectInventoryItem): string { + return item.name + extensionForItem(item); +} + +/** + * Derive type and vm from a synthetic display extension. + * Used when creating new items from a filename the user typed. + */ +function typeAndVmFromExtension(ext: string): { type: InventoryItemType; vm: ScriptVM } | undefined { + switch (ext.toLowerCase()) { + case ".luau": return { type: "script", vm: "luau" }; + case ".lsl": return { type: "script", vm: "lsl2" }; + default: return undefined; + } +} + +/** + * Derive object.content.save vm from current script metadata. + * Save API accepts: mono, lsl2, luau. + */ +function saveVmForItem(item: ObjectInventoryItem): ObjectContentSaveVM | undefined { + if (item.type !== "script") { + return undefined; + } + + // Prefer explicit VM from metadata. + if (item.vm === "luau") { + return "luau"; + } + + if (item.vm === "lsl2") { + return "lsl2"; + } + + if (item.vm === "mono") { + return "mono"; + } + + // Compatibility fallback when VM metadata is absent. + if (item.subtype === 1) { + return "luau"; + } + + return "mono"; +} + +/** Strip the synthetic display extension to recover the raw SL inventory name */ +function stripExtension(filename: string): { name: string; ext: string } { + const dot = filename.lastIndexOf("."); + if (dot === -1) return { name: filename, ext: "" }; + return { name: filename.slice(0, dot), ext: filename.slice(dot) }; +} + +// ============================================ +// FileSystemProvider +// ============================================ + +export class ObjectContentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private _onDidChangeFile = new vscode.EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly service: ObjectContentService, + private readonly getClient: () => ViewerEditWSClient | undefined, + ) { + // Forward content invalidations to VS Code as Changed events + this.disposables.push( + service.onDidChangeContent(({ object_id, prim_id, item_id }) => { + const uri = itemUri(object_id, prim_id, item_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + }, 0); + }), + + // Forward tree changes (added/removed objects) as directory changes + service.onDidChangeObjects(({ type, object_id }) => { + const uri = rootUri(object_id); + // Defer to ensure VS Code processes even when not focused + setTimeout(() => { + if (type === "added") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); + } else if (type === "removed") { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); + } else { + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + }, 0); + }), + + this._onDidChangeFile, + ); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + this.disposables = []; + } + + // ============================================ + // FileSystemProvider — required methods + // ============================================ + + watch(_uri: vscode.Uri): vscode.Disposable { + // Change notifications are pushed from the service; no polling needed. + return new vscode.Disposable(() => { }); + } + + stat(uri: vscode.Uri): vscode.FileStat { + const parsed = parseUri(uri, this.service); + + if (parsed.isDirectory) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: 0, + size: 0, + }; + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const cached = this.service.getCachedContent(root_id, item_id!); + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + + return { + type: vscode.FileType.File, + ctime: this.service.getObject(root_id)?.publishedAt ?? 0, + mtime: cached?.mtime ?? 0, + size: cached?.content.byteLength ?? 0, + permissions: canModify ? undefined : vscode.FilePermission.Readonly, + }; + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const parsed = parseUri(uri, this.service); + if (!parsed.isDirectory) { + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + const { root_id, link_id } = parsed; + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const results: [string, vscode.FileType][] = []; + + if (link_id) { + // Listing a child prim directory + const lo = this.service.getLinkedObject(root_id, link_id); + if (!lo) { + throw vscode.FileSystemError.FileNotFound(uri); + } + for (const item of lo.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + } else { + // Listing the root prim directory + for (const item of entry.object.inventory) { + results.push([displayName(item), vscode.FileType.File]); + } + for (const lo of entry.object.linked_objects ?? []) { + // Use link_id (UUID) as the directory name so URIs remain stable. + // link_name is used as display label via workspace folder naming. + results.push([lo.link_id, vscode.FileType.Directory]); + } + } + + return results; + } + + async readFile(uri: vscode.Uri): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + // Return cached content if available + const cached = this.service.getCachedContent(root_id, item_id!); + if (cached) { + return cached.content; + } + + // Fetch from viewer + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const response = await client.getObjectContent({ prim_id, item_id: item_id! }); + const text = response.content ?? ""; + const bytes = Buffer.from(text, "utf-8"); + this.service.cacheContent(root_id, item_id!, bytes); + return bytes; + } catch (error) { + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const parsed = parseUri(uri, this.service, { allowMissingLeaf: options.create }); + if (parsed.isDirectory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + const { root_id, link_id } = parsed; + const prim_id = link_id ?? root_id; + + // Permission check (only meaningful for existing items) + const entry = this.service.getObject(root_id); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + if (parsed.item_id) { + // item_id known — existing item + const item = this.service.getItem(root_id, prim_id, parsed.item_id); + if (item) { + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const text = Buffer.from(content).toString("utf-8"); + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const vm = saveVmForItem(item); + const result = await client.saveObjectContent({ + prim_id, + item_id: parsed.item_id, + content: text, + vm, + }); + + if (!result.success) { + throw vscode.FileSystemError.Unavailable( + result.message ?? "Save failed" + ); + } + + if (result.compiled === false) { + const diagnostics = (result.errors ?? []).slice(0, 5).join("\n"); + const details = diagnostics.length > 0 + ? `\n${diagnostics}` + : ""; + void vscode.window.showWarningMessage( + `Second Life: Saved, but compilation failed.${details}` + ); + } + + this.service.cacheContent(root_id, parsed.item_id, content); + this.service.markContentSaved(root_id, parsed.item_id); + return; + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + } + + // No existing item — create new + if (!options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const filename = parsed.pending_name + ?? uri.path.replace(/^\//, "").split("/").slice(-1)[0]; + const { name, ext } = stripExtension(filename); + const createParams = typeAndVmFromExtension(ext); + if (!createParams) { + throw vscode.FileSystemError.NoPermissions( + "Only script creation is currently supported (.lsl, .luau)." + ); + } + + const { type, vm } = createParams; + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.createObjectItem({ + prim_id, + name, + type, + vm, + }); + + if (!result.item_id) { + throw vscode.FileSystemError.Unavailable("Create failed: missing item_id"); + } + + const response = await client.getObjectContent({ prim_id, item_id: result.item_id }); + const text = response.content ?? ""; + // Cache under the real item_id returned by the viewer + this.service.cacheContent(root_id, result.item_id, Buffer.from(text, "utf-8")); + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + async delete(uri: vscode.Uri, _options: { recursive: boolean }): Promise { + const parsed = parseUri(uri, this.service); + if (parsed.isDirectory) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const { root_id, link_id, item_id } = parsed; + const prim_id = link_id ?? root_id; + + const item = this.service.getItem(root_id, prim_id, item_id!); + if (!item) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + const canModify = (item.permissions?.owner ?? PERM_MODIFY) & PERM_MODIFY; + if (!canModify) { + throw vscode.FileSystemError.NoPermissions(uri); + } + + const client = this.getClient(); + if (!client) throw vscode.FileSystemError.Unavailable("Not connected to viewer"); + + try { + const result = await client.deleteObjectItem({ prim_id, item_id: item_id! }); + if (!result.success) { + throw vscode.FileSystemError.Unavailable("Delete failed"); + } + } catch (error) { + if (error instanceof vscode.FileSystemError) { + throw error; + } + throw mapRpcErrorToFileSystemError(error, uri); + } + } + + // Creating directories (linked prims) and renaming are not supported + createDirectory(_uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_uri); + } + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(_oldUri); + } +} diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts new file mode 100644 index 0000000..c0aa513 --- /dev/null +++ b/src/vscode/objectcontentservice.ts @@ -0,0 +1,332 @@ +/** + * @file objectcontentservice.ts + * Singleton service managing published in-world object content. + * Copyright (C) 2025, Linden Research, Inc. + */ +import * as vscode from "vscode"; +import { + ObjectInventoryItem, + LinkedObject, + ObjectEntry, + CachedItemContent, + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, + InventoryChanges, +} from "./objectcontentinterfaces"; + +// ============================================ +// Event Types +// ============================================ + +/** Fired when objects are added, removed, or have metadata/inventory changes */ +export interface ObjectTreeChangeEvent { + type: "added" | "removed" | "updated"; + object_id: string; +} + +/** Fired when cached content for a specific item is invalidated or needs refresh */ +export interface ObjectContentChangeEvent { + object_id: string; + prim_id: string; + item_id: string; +} + +// ============================================ +// Service +// ============================================ + +export class ObjectContentService implements vscode.Disposable { + private static instance: ObjectContentService | undefined; + + private objects: Map = new Map(); + + private _onDidChangeObjects = new vscode.EventEmitter(); + readonly onDidChangeObjects = this._onDidChangeObjects.event; + + private _onDidChangeContent = new vscode.EventEmitter(); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private disposables: vscode.Disposable[] = []; + + private constructor() { + this.disposables.push(this._onDidChangeObjects, this._onDidChangeContent); + } + + static getInstance(): ObjectContentService { + if (!ObjectContentService.instance) { + ObjectContentService.instance = new ObjectContentService(); + } + return ObjectContentService.instance; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + if (ObjectContentService.instance === this) { + ObjectContentService.instance = undefined; + } + } + + // ============================================ + // Message Handlers (Viewer → Extension) + // ============================================ + + handlePublish(msg: ObjectPublishMessage): void { + const entry: ObjectEntry = { + object: msg.object, + contentCache: new Map(), + publishedAt: Date.now(), + }; + this.objects.set(msg.object.object_id, entry); + this._onDidChangeObjects.fire({ type: "added", object_id: msg.object.object_id }); + } + + handleUnpublish(msg: ObjectUnpublishMessage): void { + if (this.objects.delete(msg.object_id)) { + this._onDidChangeObjects.fire({ type: "removed", object_id: msg.object_id }); + } + } + + handleUpdate(msg: ObjectUpdateMessage): void { + const entry = this.objects.get(msg.object_id); + if (!entry) { + return; + } + + if (msg.object_name !== undefined) { + entry.object.object_name = msg.object_name; + } + + if (msg.changes) { + // Delta update + if (msg.changes.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + msg.object_id, + entry.object.inventory, + msg.changes.inventory + ); + } + if (msg.changes.linked_objects) { + const lc = msg.changes.linked_objects; + if (lc.added) { + entry.object.linked_objects = [ + ...(entry.object.linked_objects ?? []), + ...lc.added, + ]; + } + if (lc.removed) { + const removedSet = new Set(lc.removed); + entry.object.linked_objects = (entry.object.linked_objects ?? []).filter( + (lo) => !removedSet.has(lo.link_id) + ); + // Evict cached content for removed linked prims + for (const link_id of lc.removed) { + this._evictPrimCache(entry, msg.object_id, link_id); + } + } + if (lc.modified) { + for (const mod of lc.modified) { + const lo = (entry.object.linked_objects ?? []).find( + (l) => l.link_id === mod.link_id + ); + if (!lo) continue; + if (mod.link_name !== undefined) { + lo.link_name = mod.link_name; + } + if (mod.inventory) { + this._applyInventoryChanges( + entry, + msg.object_id, + mod.link_id, + lo.inventory, + mod.inventory + ); + } + } + } + } + } else { + // Full replacement + if (msg.inventory !== undefined) { + entry.object.inventory = msg.inventory; + // Evict root prim content cache entirely + this._evictPrimCache(entry, msg.object_id, msg.object_id); + } + if (msg.linked_objects !== undefined) { + // Evict cache for all replaced linked prims + for (const lo of entry.object.linked_objects ?? []) { + this._evictPrimCache(entry, msg.object_id, lo.link_id); + } + entry.object.linked_objects = msg.linked_objects; + } + } + + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + } + + // ============================================ + // Tree Queries + // ============================================ + + getObjects(): readonly ObjectEntry[] { + return Array.from(this.objects.values()); + } + + getObject(object_id: string): ObjectEntry | undefined { + return this.objects.get(object_id); + } + + hasObject(object_id: string): boolean { + return this.objects.has(object_id); + } + + /** + * Returns inventory for any prim in the linkset. + * Pass prim_id === object_id for root prim, or a link_id for a child prim. + */ + getInventory(object_id: string, prim_id: string): ObjectInventoryItem[] | undefined { + const entry = this.objects.get(object_id); + if (!entry) return undefined; + if (prim_id === object_id) { + return entry.object.inventory; + } + return entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory; + } + + getItem(object_id: string, prim_id: string, item_id: string): ObjectInventoryItem | undefined { + return this.getInventory(object_id, prim_id)?.find((i) => i.item_id === item_id); + } + + getLinkedObject(object_id: string, link_id: string): LinkedObject | undefined { + return this.objects.get(object_id)?.object.linked_objects?.find( + (lo) => lo.link_id === link_id + ); + } + + // ============================================ + // Content Cache + // ============================================ + + getCachedContent(object_id: string, item_id: string): CachedItemContent | undefined { + return this.objects.get(object_id)?.contentCache.get(item_id); + } + + cacheContent(object_id: string, item_id: string, content: Uint8Array): void { + const entry = this.objects.get(object_id); + if (!entry) return; + const existing = entry.contentCache.get(item_id); + entry.contentCache.set(item_id, { + content, + mtime: Date.now(), + dirty: existing?.dirty ?? false, + }); + } + + markContentDirty(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = true; + } + } + + markContentSaved(object_id: string, item_id: string): void { + const cached = this.objects.get(object_id)?.contentCache.get(item_id); + if (cached) { + cached.dirty = false; + } + } + + isContentDirty(object_id: string, item_id: string): boolean { + return this.objects.get(object_id)?.contentCache.get(item_id)?.dirty ?? false; + } + + // ============================================ + // Lifecycle + // ============================================ + + /** Remove all published objects (e.g. on viewer disconnect). */ + clear(): void { + const ids = Array.from(this.objects.keys()); + this.objects.clear(); + for (const object_id of ids) { + this._onDidChangeObjects.fire({ type: "removed", object_id }); + } + } + + // ============================================ + // Private Helpers + // ============================================ + + /** + * Apply delta inventory changes to an item list. + * Fires onDidChangeContent for any content_changed or removed items. + */ + private _applyInventoryChanges( + entry: ObjectEntry, + object_id: string, + prim_id: string, + inventory: ObjectInventoryItem[], + changes: InventoryChanges + ): void { + if (changes.added) { + inventory.push(...changes.added); + } + if (changes.removed) { + const removedSet = new Set(changes.removed); + for (let i = inventory.length - 1; i >= 0; i--) { + if (removedSet.has(inventory[i].item_id)) { + inventory.splice(i, 1); + } + } + for (const item_id of changes.removed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.modified) { + for (const mod of changes.modified) { + const idx = inventory.findIndex((i) => i.item_id === mod.item_id); + if (idx !== -1) { + inventory[idx] = mod; + } + } + } + if (changes.content_changed) { + for (const item_id of changes.content_changed) { + entry.contentCache.delete(item_id); + this._onDidChangeContent.fire({ object_id, prim_id, item_id }); + } + } + if (changes.running_changed) { + for (const rc of changes.running_changed) { + const item = inventory.find((i) => i.item_id === rc.item_id); + if (item) { + item.running = rc.running; + } + } + } + } + + /** + * Evict all cached content belonging to a specific prim from the entry's cache. + * Used when inventory is fully replaced or a linked prim is removed. + * Fires onDidChangeContent for each evicted item. + */ + private _evictPrimCache(entry: ObjectEntry, object_id: string, prim_id: string): void { + const inventory = + prim_id === object_id + ? entry.object.inventory + : entry.object.linked_objects?.find((lo) => lo.link_id === prim_id)?.inventory ?? []; + + for (const item of inventory) { + if (entry.contentCache.delete(item.item_id)) { + this._onDidChangeContent.fire({ object_id, prim_id, item_id: item.item_id }); + } + } + } +} diff --git a/src/websockclient.ts b/src/websockclient.ts index c2974e8..a7a51c7 100644 --- a/src/websockclient.ts +++ b/src/websockclient.ts @@ -64,6 +64,7 @@ import * as vscode from "vscode"; import WebSocket from "ws"; +import { logDebug } from "./utils"; /** * JSON-RPC 2.0 message types @@ -448,13 +449,47 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { super(context, url); } + private logIncomingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] <- request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] <- notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] <- response id=${String(message.id)} status=${status}`); + } + } + + private logOutgoingMessage(message: JSONRPCMessage): void { + if (this.isJSONRPCRequest(message)) { + logDebug(`[JSON-RPC] -> request method=${message.method} id=${String(message.id)}`); + return; + } + + if (this.isJSONRPCNotification(message)) { + logDebug(`[JSON-RPC] -> notification method=${message.method}`); + return; + } + + if (this.isJSONRPCResponse(message)) { + const status = message.error ? "error" : "result"; + logDebug(`[JSON-RPC] -> response id=${String(message.id)} status=${status}`); + } + } + /** * Handles incoming WebSocket messages with JSON-RPC support */ protected handleMessage(data: WebSocket.RawData): void { try { const message = JSON.parse(data.toString()) as JSONRPCMessage; - // console.log("Received JSON-RPC message:", message); + this.logIncomingMessage(message); if (this.isJSONRPCResponse(message)) { this.handleJSONRPCResponse(message); @@ -674,6 +709,7 @@ export class JSONRPCClient extends WebsockClient implements JSONRPCInterface { * Sends a JSON-RPC message */ private sendJSONRPCMessage(message: JSONRPCMessage): boolean { + this.logOutgoingMessage(message); return this.sendMessage(message); } From 497173ef709e9c245b5a8b355a643a6cb0289bba Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Wed, 1 Jul 2026 11:37:20 -0700 Subject: [PATCH 10/10] object publishing checkpoint. --- doc/Message_Interfaces.md | 24 ++++ src/extension.ts | 10 ++ src/synchservice.ts | 32 ++++++ src/viewereditwsclient.ts | 6 + src/vscode/objectcontentinterfaces.ts | 10 ++ src/vscode/objectcontentprovider.ts | 160 ++++++++++++++++++++++++-- src/vscode/objectcontentservice.ts | 20 +++- 7 files changed, 248 insertions(+), 14 deletions(-) diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 022378a..605e6a9 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -170,6 +170,8 @@ WebSocket connects → session.handshake → session.ok | `runtime.error` | Viewer → Extension | Notification | `RuntimeError` | | `object.publish` | Viewer → Extension | Notification | `ObjectPublishMessage` | | `object.unpublish` | Viewer → Extension | Notification | `ObjectUnpublishMessage` | +| `object.unpublish` | Extension → Viewer | Call | `ObjectUnpublishParams` | +| `object.unpublish` (response) | Viewer → Extension | Response | `ObjectUnpublishResponse` | | `object.update` | Viewer → Extension | Notification | `ObjectUpdateMessage` | | `object.content.get` | Extension → Viewer | Call | `ObjectContentGetParams` | | `object.content.get` (response) | Viewer → Extension | Response | `ObjectContentGetResponse` | @@ -780,6 +782,28 @@ interface ObjectUnpublishMessage { - `object_id`: UUID of the root prim that is being unpublished - `reason` (optional): Human-readable explanation (e.g. `"object deleted"`, `"out of range"`) +**JSON-RPC Method:** `object.unpublish` (call from extension to viewer) + +The extension may also call `object.unpublish` to manually stop tracking an object. The viewer will stop publishing it and send a corresponding `object.unpublish` notification back to the caller. + +```typescript +interface ObjectUnpublishParams { + object_id: string; // UUID of the root prim to unpublish +} + +interface ObjectUnpublishResponse { + success: boolean; + object_id?: string; +} +``` + +**Fields:** + +- `object_id`: UUID of the root prim to unpublish. +- `success`: `true` if the object was published and has been removed. + +**Note:** The viewer also sends an `object.unpublish` notification to the caller immediately after responding. Extensions should handle that notification idempotently. + --- ### ObjectUpdate diff --git a/src/extension.ts b/src/extension.ts index e5c92ce..0180cf2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -75,6 +75,16 @@ export function activate(context: vscode.ExtensionContext): void { if (slIdx !== -1) { vscode.workspace.updateWorkspaceFolders(slIdx, 1); } + } else if (type === "updated") { + if (slIdx !== -1) { + const entry = objectContentService.getObject(object_id); + if (entry) { + vscode.workspace.updateWorkspaceFolders(slIdx, 1, { + uri: rootUri(object_id), + name: entry.object.object_name, + }); + } + } } }) ); diff --git a/src/synchservice.ts b/src/synchservice.ts index e5270cd..4ac65f8 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -42,6 +42,7 @@ import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; import { ObjectContentService } from "./vscode/objectcontentservice"; +import { SL_SCHEME, SL_AUTHORITY } from "./vscode/objectcontentprovider"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -372,6 +373,7 @@ export class SynchService implements vscode.Disposable { }, onObjectUpdate: (msg: ObjectUpdateMessage): any => { logDebug(`[object.update] object_id=${msg.object_id}`); + logDebug(`[object.update] object_name=${msg.object_name}`); ObjectContentService.getInstance().handleUpdate(msg); }, }; @@ -488,6 +490,7 @@ export class SynchService implements vscode.Disposable { this._onDidChangeConnectionState.fire(true); await this.handleLaunchParams(); + await this.requestWorkspaceObjects(); } private onDisconnect(params: SessionDisconnect): void { @@ -1007,6 +1010,35 @@ export class SynchService implements vscode.Disposable { // handleLaunchParams is called from onHandshakeOk } + private async requestWorkspaceObjects(): Promise { + if (!this.websocket?.isConnected()) { return; } + + const service = ObjectContentService.getInstance(); + const folders = vscode.workspace.workspaceFolders ?? []; + + for (const folder of folders) { + if (folder.uri.scheme !== SL_SCHEME || folder.uri.authority !== SL_AUTHORITY) { + continue; + } + + const object_id = folder.uri.path.slice(1); // strip leading "/" + if (!object_id || service.hasObject(object_id)) { + continue; // already published + } + + try { + const result = await this.websocket.requestObject({ object_id }); + if (result.object) { + service.handlePublish({ object: result.object }); + } else if (result.success === false) { + logDebug(`[requestWorkspaceObjects] viewer rejected ${object_id}: ${result.message ?? "unknown"}`); + } + } catch (err) { + logDebug(`[requestWorkspaceObjects] error requesting ${object_id}: ${err}`); + } + } + } + private async handleLaunchParams(): Promise { const objectId = this.pendingLaunchObjectId; const scriptId = this.pendingLaunchScriptId; diff --git a/src/viewereditwsclient.ts b/src/viewereditwsclient.ts index 9748d2c..ca00e61 100644 --- a/src/viewereditwsclient.ts +++ b/src/viewereditwsclient.ts @@ -21,6 +21,8 @@ import { ObjectItemDeleteResponse, ObjectScriptSetRunningParams, ObjectScriptSetRunningResponse, + ObjectUnpublishParams, + ObjectUnpublishResponse, ObjectRequestParams, ObjectRequestResponse, } from "./vscode/objectcontentinterfaces"; @@ -279,6 +281,10 @@ export class ViewerEditWSClient extends JSONRPCClient { return this.call("object.script.set_running", params); } + public unpublishObject(params: ObjectUnpublishParams): Promise { + return this.call("object.unpublish", params); + } + public requestObject(params: ObjectRequestParams): Promise { return this.call("object.request", params); } diff --git a/src/vscode/objectcontentinterfaces.ts b/src/vscode/objectcontentinterfaces.ts index d4ee775..e0307ee 100644 --- a/src/vscode/objectcontentinterfaces.ts +++ b/src/vscode/objectcontentinterfaces.ts @@ -98,6 +98,16 @@ export interface ObjectUnpublishMessage { reason?: string; } +/** object.unpublish — Extension → Viewer (call) */ +export interface ObjectUnpublishParams { + object_id: string; +} + +export interface ObjectUnpublishResponse { + success: boolean; + object_id?: string; +} + /** Delta changes for inventory items */ export interface InventoryChanges { added?: ObjectInventoryItem[]; diff --git a/src/vscode/objectcontentprovider.ts b/src/vscode/objectcontentprovider.ts index aaddf2a..222757f 100644 --- a/src/vscode/objectcontentprovider.ts +++ b/src/vscode/objectcontentprovider.ts @@ -9,6 +9,7 @@ import { ObjectContentService } from "./objectcontentservice"; import { ViewerEditWSClient } from "../viewereditwsclient"; import { InventoryItemType, + LinkedObject, ObjectContentSaveVM, ObjectInventoryItem, ScriptVM, @@ -93,6 +94,46 @@ function isUUID(s: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); } +/** + * Returns the display name to use for a linked prim's directory entry in readDirectory. + * Uses link_name when unique among siblings; disambiguates with link_number when names collide. + * Falls back to link_id when link_name is empty. + */ +function linkedPrimDirName(lo: LinkedObject, allLinked: LinkedObject[]): string { + if (!lo.link_name) return lo.link_id; + const hasDuplicate = allLinked.some( + (other) => other.link_id !== lo.link_id && other.link_name === lo.link_name + ); + return hasDuplicate ? `${lo.link_name} (${lo.link_number})` : lo.link_name; +} + +/** + * Resolves a linked prim directory segment (as returned by readDirectory) back to its link_id. + * Accepts: plain UUID, "Name (link_number)" disambiguated format, or plain name. + */ +function resolveLinkedPrimId( + segment: string, + linkedObjects: LinkedObject[] +): string | undefined { + // UUID — used by internal API calls and content-change notifications + const byId = linkedObjects.find((lo) => lo.link_id === segment); + if (byId) return byId.link_id; + + // "Name (N)" — disambiguated display name + const m = segment.match(/^(.*) \((\d+)\)$/); + if (m) { + const baseName = m[1]; + const linkNum = parseInt(m[2], 10); + const found = linkedObjects.find( + (lo) => lo.link_name === baseName && lo.link_number === linkNum + ); + if (found) return found.link_id; + } + + // Plain name — for unique names + return linkedObjects.find((lo) => lo.link_name === segment)?.link_id; +} + /** * Find an item in an inventory list by its display name. * Returns the item_id if found, undefined otherwise. @@ -144,11 +185,11 @@ function parseUri( if (parts.length === 2) { // Is seg1 a linked prim (directory) or an item (file)? - const isLinkedPrim = - entry?.object.linked_objects?.some((lo) => lo.link_id === seg1) ?? false; + const linkedObjects = entry?.object.linked_objects ?? []; + const resolvedLinkId = resolveLinkedPrimId(seg1, linkedObjects); - if (isLinkedPrim) { - return { root_id, link_id: seg1, isDirectory: true }; + if (resolvedLinkId !== undefined) { + return { root_id, link_id: resolvedLinkId, isDirectory: true }; } // seg1 is either a UUID or a display name @@ -168,7 +209,8 @@ function parseUri( } if (parts.length === 3) { - const link_id = parts[1]; + const linkedObjects = entry?.object.linked_objects ?? []; + const link_id = resolveLinkedPrimId(parts[1], linkedObjects) ?? parts[1]; const seg2 = parts[2]; // seg2 is either a UUID or a display name @@ -281,6 +323,9 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. private _onDidChangeFile = new vscode.EventEmitter(); readonly onDidChangeFile = this._onDidChangeFile.event; + /** Maps object_id → (link_id → last-known dir segment name) for change notification lookup */ + private _linkedDirNames = new Map>(); + private disposables: vscode.Disposable[] = []; constructor( @@ -290,7 +335,22 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. // Forward content invalidations to VS Code as Changed events this.disposables.push( service.onDidChangeContent(({ object_id, prim_id, item_id }) => { - const uri = itemUri(object_id, prim_id, item_id); + // Build the URI using the same display-name segment that readDirectory exposes, + // so VS Code can match the notification to the correct open editor. + let uri: vscode.Uri; + if (prim_id === object_id) { + uri = itemUri(object_id, prim_id, item_id); + } else { + const entry2 = service.getObject(object_id); + const allLinked = entry2?.object.linked_objects ?? []; + const lo = allLinked.find((l) => l.link_id === prim_id); + const linkSegment = lo ? linkedPrimDirName(lo, allLinked) : prim_id; + uri = vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${linkSegment}/${item_id}`, + }); + } // Defer to ensure VS Code processes even when not focused setTimeout(() => { this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); @@ -298,16 +358,83 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. }), // Forward tree changes (added/removed objects) as directory changes - service.onDidChangeObjects(({ type, object_id }) => { + service.onDidChangeObjects((event) => { + const { type, object_id } = event; const uri = rootUri(object_id); // Defer to ensure VS Code processes even when not focused setTimeout(() => { if (type === "added") { this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Created, uri }]); } else if (type === "removed") { + this._linkedDirNames.delete(object_id); this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); } else { - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + const { added_link_ids, removed_link_ids } = event; + const fileEvents: vscode.FileChangeEvent[] = []; + + if (added_link_ids?.length || removed_link_ids?.length) { + // Structural change: fire Deleted for removed dirs (using cached + // names) and Created for added dirs. + const nameMap = this._linkedDirNames.get(object_id) ?? new Map(); + if (!this._linkedDirNames.has(object_id)) { + this._linkedDirNames.set(object_id, nameMap); + } + + for (const link_id of removed_link_ids ?? []) { + const segment = nameMap.get(link_id); + if (segment) { + fileEvents.push({ + type: vscode.FileChangeType.Deleted, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${segment}`, + }), + }); + nameMap.delete(link_id); + } + } + + const entry = service.getObject(object_id); + const allLinked = entry?.object.linked_objects ?? []; + for (const link_id of added_link_ids ?? []) { + const lo = allLinked.find((l) => l.link_id === link_id); + if (lo) { + const segment = linkedPrimDirName(lo, allLinked); + nameMap.set(link_id, segment); + fileEvents.push({ + type: vscode.FileChangeType.Created, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${segment}`, + }), + }); + } + } + + // Changed on root so VS Code re-calls readDirectory + fileEvents.push({ type: vscode.FileChangeType.Changed, uri }); + } else { + // Non-structural update — refresh root and all current child dirs + fileEvents.push({ type: vscode.FileChangeType.Changed, uri }); + const entry = service.getObject(object_id); + if (entry?.object.linked_objects?.length) { + const allLinked = entry.object.linked_objects; + for (const lo of allLinked) { + fileEvents.push({ + type: vscode.FileChangeType.Changed, + uri: vscode.Uri.from({ + scheme: SL_SCHEME, + authority: SL_AUTHORITY, + path: `/${object_id}/${linkedPrimDirName(lo, allLinked)}`, + }), + }); + } + } + } + + this._onDidChangeFile.fire(fileEvents); } }, 0); }), @@ -389,10 +516,19 @@ export class ObjectContentProvider implements vscode.FileSystemProvider, vscode. for (const item of entry.object.inventory) { results.push([displayName(item), vscode.FileType.File]); } - for (const lo of entry.object.linked_objects ?? []) { - // Use link_id (UUID) as the directory name so URIs remain stable. - // link_name is used as display label via workspace folder naming. - results.push([lo.link_id, vscode.FileType.Directory]); + const allLinked = entry.object.linked_objects ?? []; + // Cache dir names so the change-notification handler can fire Deleted + // events with the correct URI even after the entry has been updated. + let nameMap = this._linkedDirNames.get(root_id); + if (!nameMap) { + nameMap = new Map(); + this._linkedDirNames.set(root_id, nameMap); + } + for (const lo of allLinked) { + // Use a human-readable display name; disambiguate duplicates with link_number. + const segment = linkedPrimDirName(lo, allLinked); + nameMap.set(lo.link_id, segment); + results.push([segment, vscode.FileType.Directory]); } } diff --git a/src/vscode/objectcontentservice.ts b/src/vscode/objectcontentservice.ts index c0aa513..23410af 100644 --- a/src/vscode/objectcontentservice.ts +++ b/src/vscode/objectcontentservice.ts @@ -23,6 +23,10 @@ import { export interface ObjectTreeChangeEvent { type: "added" | "removed" | "updated"; object_id: string; + /** link_ids of prims newly added to the linkset (only set when type === "updated") */ + added_link_ids?: string[]; + /** link_ids of prims removed from the linkset (only set when type === "updated") */ + removed_link_ids?: string[]; } /** Fired when cached content for a specific item is invalidated or needs refresh */ @@ -100,6 +104,9 @@ export class ObjectContentService implements vscode.Disposable { entry.object.object_name = msg.object_name; } + let added_link_ids: string[] | undefined; + let removed_link_ids: string[] | undefined; + if (msg.changes) { // Delta update if (msg.changes.inventory) { @@ -158,15 +165,24 @@ export class ObjectContentService implements vscode.Disposable { this._evictPrimCache(entry, msg.object_id, msg.object_id); } if (msg.linked_objects !== undefined) { + const oldLinks = entry.object.linked_objects ?? []; + const newIdSet = new Set(msg.linked_objects.map((lo) => lo.link_id)); + const oldIdSet = new Set(oldLinks.map((lo) => lo.link_id)); + removed_link_ids = oldLinks + .filter((lo) => !newIdSet.has(lo.link_id)) + .map((lo) => lo.link_id); + added_link_ids = msg.linked_objects + .filter((lo) => !oldIdSet.has(lo.link_id)) + .map((lo) => lo.link_id); // Evict cache for all replaced linked prims - for (const lo of entry.object.linked_objects ?? []) { + for (const lo of oldLinks) { this._evictPrimCache(entry, msg.object_id, lo.link_id); } entry.object.linked_objects = msg.linked_objects; } } - this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id }); + this._onDidChangeObjects.fire({ type: "updated", object_id: msg.object_id, added_link_ids, removed_link_ids }); } // ============================================