Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
488 changes: 453 additions & 35 deletions doc/Message_Interfaces.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 93 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<uuid>][&script=<uuid>]
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);
Comment on lines +92 to +93
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());

Expand Down
103 changes: 100 additions & 3 deletions src/synchservice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };

Expand All @@ -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;
Expand All @@ -62,12 +71,17 @@ export class SynchService implements vscode.Disposable {

private syncedFileDecorator : SyncedFileDecorator;

private _onDidChangeConnectionState = new vscode.EventEmitter<boolean>();
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 {
Expand Down Expand Up @@ -337,7 +351,7 @@ export class SynchService implements vscode.Disposable {

//====================================================================
//#region WebSocket connection management and handlers
private async setupConnection(): Promise<boolean> {
private async setupConnection(portOverride?: number): Promise<boolean> {
const handlers = {
onHandshake: (message: SessionHandshake): any => this.onHandshake(message),
onHandshakeOk: (): any => this.onHandshakeOk(),
Expand All @@ -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()) {
Expand All @@ -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<number>(ConfigKey.NetworkWebsocketPort, 9020);
this.websocket = new ViewerEditWSClient(
this.context,
`ws://localhost:${this.host.config.getConfig<number>(ConfigKey.NetworkWebsocketPort, 9020)}`
`ws://localhost:${port}`
);
this.websocket.setup(handlers);
let connected = await this.websocket.connect();
Expand Down Expand Up @@ -424,6 +451,7 @@ export class SynchService implements vscode.Disposable {
error_reporting: true,
debugging: false,
breakpoints: false,
object_publish: true,
},
};
return response;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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

//====================================================================
Expand Down Expand Up @@ -954,6 +993,64 @@ export class SynchService implements vscode.Disposable {
}
//#endregion

public async connectToViewer(params: { port?: number; object_id?: string; script_id?: string }): Promise<void> {
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<void> {
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<void> {
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();
Expand Down
11 changes: 11 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +43 to +44
const channel = getOutputChannel();
const timestamp = new Date().toISOString();
channel.appendLine(`[${timestamp}] DEBUG: ${message}`);
}

/**
* Log a warning message to the output channel
*/
Expand Down
Loading
Loading