diff --git a/doc/Message_Interfaces.md b/doc/Message_Interfaces.md index 52cb53f..022378a 100644 --- a/doc/Message_Interfaces.md +++ b/doc/Message_Interfaces.md @@ -4,33 +4,45 @@ This document describes all the message interfaces defined for WebSocket communi ## Table of Contents -- [Usage Flow](#usage-flow) -- [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) +- [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 @@ -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 @@ -284,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. @@ -577,3 +671,327 @@ 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 = "lsl2" | "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 { + success: boolean; + prim_id: string; + item_id: string; + content: string; // Raw text content (UTF-8). Notecard envelope is unwrapped automatically. +} +``` + +**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. +- `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. + +--- + +### 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; + vm?: "mono" | "lsl2" | "luau"; +} + +interface ObjectContentSaveResponse { + success: boolean; + prim_id?: string; + item_id?: string; + compiled?: boolean; + errors?: string[]; + message?: string; +} +``` + +**Fields:** + +- `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. + +--- + +### ObjectItemCreate + +**JSON-RPC Method:** `object.item.create` (call from extension to viewer) + +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; // UUID of the prim to create the item in + name: string; // Pure SL inventory name — no file extension + type: InventoryItemType; // "script" ("notecard" reserved for future) + vm: ScriptVM; // Required for scripts: "luau" | "lsl" +} + +// 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 + +**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; + prim_id: string; // Echoed back from request + item_id: string; // Echoed back from request +} +``` + +--- + +### 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..3778377 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -20,11 +20,17 @@ import { RuntimeDebug, RuntimeError, } from "./viewereditwsclient"; +import { + ObjectPublishMessage, + ObjectUnpublishMessage, + ObjectUpdateMessage, +} from "./vscode/objectcontentinterfaces"; import { hasWorkspace, showInfoMessage, showStatusMessage, showWarningMessage, + logDebug, logInfo, VSCodeHost, closeTextDocument, @@ -35,6 +41,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 +57,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 +71,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 +351,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 +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 => { + 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()) { @@ -359,9 +384,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 +451,7 @@ export class SynchService implements vscode.Disposable { error_reporting: true, debugging: false, breakpoints: false, + object_publish: true, }, }; return response; @@ -456,6 +484,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 +504,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 +907,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 +993,64 @@ 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.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"); + } + } + + 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(() => { 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); }