diff --git a/doc/USER_GUIDE.md b/doc/USER_GUIDE.md index 1277395..c986bce 100644 --- a/doc/USER_GUIDE.md +++ b/doc/USER_GUIDE.md @@ -100,6 +100,7 @@ settings use the `slVscodeEdit.` prefix. | `slVscodeEdit.sync.includeFileMetaInOutput` | Boolean | `false` | Includes file metadata in processed script output. | | `slVscodeEdit.sync.includeCreatorInFileMeta` | Boolean | `false` | Includes the current Second Life user in file metadata. | | `slVscodeEdit.sync.keepViewerFileOpen` | Boolean | `true` | Keeps the viewer's temporary file open during editing. | +| `slVscodeEdit.sync.autoLinkOnPublish` | Boolean | `false` | Automatically links matching workspace files when an object is explored. | | `slVscodeEdit.sync.notecardComment` | String | `null` | Defines the comment text used to match external files with notecards. | ### Preprocessor @@ -232,6 +233,27 @@ and sends the processed result to the viewer. The viewer then compiles the resulting script. For details about includes and requires, macros, conditionals, and other preprocessor behavior, see the [Preprocessor Guide](preprocessor-guide.md). +### Linking all files in an explored object + +To link all matching workspace files for an explored object, open the object's +context menu and select **Link All**. The operation checks the root prim and +every linked prim, including both scripts and notecards. + +The plugin uses the same matching rules as individual file linking. It checks +file metadata when available, then falls back to matching the displayed file +name and extension. Existing links are retained, and one workspace file may be +linked to more than one in-world item. + +**Link All** does not open editor tabs. It reads each object item, establishes +matching links in the current session, and displays one summary when complete. +Items without matching workspace files and items without modify permission are +reported in that summary. Links are ephemeral and must be recreated after the +plugin session ends. + +To run this automatically whenever an object is explored, enable +`slVscodeEdit.sync.autoLinkOnPublish` in the **SL Scripting - Sync** settings. +The setting is disabled by default. + ## Pinning Objects You can pin explored objects in the **Second Life** view so they are restored @@ -283,6 +305,8 @@ The available actions include: - **New File...**: Prompts for a filename. If the name ends with `.lsl`, the new item is created as an LSL script; if it ends with `.luau`, it is created as a Luau script; otherwise, it is created as a notecard. +- **Link All**: Attempts to link every script and notecard in the object, + including items in linked prims, with matching workspace files. - **Unexplore**: Removes the selected object from publication in the viewer. - **Save Back to Contents**: If the object was rezzed directly from another object, it is saved back to that rezzing object's inventory. diff --git a/package.json b/package.json index a35823d..fec17cc 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,11 @@ "title": "Open", "category": "Second Life" }, + { + "command": "slVscodeEdit.autoLinkObject", + "title": "Link All", + "category": "Second Life" + }, { "command": "slVscodeEdit.renameInventoryItem", "title": "Rename...", @@ -209,6 +214,11 @@ "when": "view == slInworldExplorer && viewItem =~ /slObject|slLinkedPrim/", "group": "7_modification" }, + { + "command": "slVscodeEdit.autoLinkObject", + "when": "view == slInworldExplorer && viewItem == slObject", + "group": "navigation" + }, { "command": "slVscodeEdit.teleportToObject", "when": "view == slInworldExplorer && viewItem == slObject", @@ -306,6 +316,11 @@ "default": true, "description": "Should the viewers tempfile be kept open while editing" }, + "slVscodeEdit.sync.autoLinkOnPublish": { + "type": "boolean", + "default": false, + "description": "Automatically link matching workspace files when an object is first published" + }, "slVscodeEdit.sync.notecardComment": { "type": "string", "default": null, diff --git a/src/extension.ts b/src/extension.ts index 722a65e..820b92f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -112,6 +112,7 @@ export function activate(context: vscode.ExtensionContext): void { () => synchService.isConnected(), synchService.onDidChangeConnectionState, () => synchService.getWebSocket(), + synchService, ); context.subscriptions.push( vscode.window.registerWebviewViewProvider(ObjectExplorerWebviewProvider.viewType, objectExplorerWebview), @@ -127,7 +128,16 @@ export function activate(context: vscode.ExtensionContext): void { (uri: vscode.Uri) => { vscode.window.showTextDocument(uri, { preview: false }); } - ) + ), + vscode.commands.registerCommand( + "slVscodeEdit.autoLinkObject", + async (node: ExplorerNode) => { + if (node.kind !== "object") { + return; + } + await synchService.autoLinkObject(node.object_id); + }, + ), ); // Rename commands for context menu actions diff --git a/src/interfaces/configinterface.ts b/src/interfaces/configinterface.ts index 7a6c35f..f8c5224 100644 --- a/src/interfaces/configinterface.ts +++ b/src/interfaces/configinterface.ts @@ -34,6 +34,7 @@ export enum ConfigKey { AskIfViewerScriptMismatchesMaster = 'sync.askIfViewerScriptMismatchesMaster', CompareHashBeforeSync = 'sync.compareHashBeforeSync', KeepViewerFileOpen = 'sync.keepViewerFileOpen', + AutoLinkOnPublish = 'sync.autoLinkOnPublish', NotecardSyncComment = 'sync.notecardComment', FileMetaInfoInOutput ='sync.includeFileMetaInOutput', diff --git a/src/synchservice.ts b/src/synchservice.ts index 6dad0a4..a4dcb06 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -73,6 +73,21 @@ type ParsedTempFile = { itemId?: string; }; +interface SlLinkResult { + outcome: "linked" | "already-linked" | "no-match" | "skipped-no-modify" | "error"; + masterUri?: vscode.Uri; + mismatch?: boolean; +} + +interface AutoLinkSummary { + linked: number; + alreadyLinked: number; + noMatch: number; + skippedNoModify: number; + errors: number; + mismatches: number; +} + function isUuidSegment(segment: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(segment); } @@ -107,6 +122,7 @@ export class SynchService implements vscode.Disposable { private initialGenerationDone: boolean = false; private pendingLaunchObjectId?: string; private pendingLaunchScriptId?: string; + private autoLinkedObjectIds = new Set(); public viewerName?: string; public viewerVersion?: string; @@ -330,7 +346,7 @@ export class SynchService implements vscode.Disposable { } // Look for a file in the workspace with the same name as the master script - let masterUri = await SynchService.findMasterFile(parsed, viewerDocument); + let masterUri = await SynchService.findMasterFile(parsed, viewerDocument.getText()); let masterFound = true; if (!masterUri) { masterFound = false; @@ -428,17 +444,33 @@ export class SynchService implements vscode.Disposable { private async setupSyncForSlUri( slDocument: vscode.TextDocument, ): Promise { + await this.linkSlItem( + slDocument.uri, + slDocument.getText(), + { reveal: true }, + slDocument, + ); + } + + private async linkSlItem( + uri: vscode.Uri, + content: string, + options: { reveal: boolean }, + viewerDocument?: vscode.TextDocument, + ): Promise { if (!hasWorkspace()) { - return; + return { outcome: "error" }; } if (!this.websocket?.isConnected()) { - showWarningMessage(`Cannot link sl:// script: not connected to Second Life viewer.`); - return; + if (options.reveal) { + showWarningMessage(`Cannot link sl:// script: not connected to Second Life viewer.`); + } + return { outcome: "error" }; } - const parsed = SynchService.parseSlFileInfo(slDocument.uri); + const parsed = SynchService.parseSlFileInfo(uri); if (!parsed) { - logInfo(`[setupSyncForSlUri] Could not parse sl:// URI: ${slDocument.uri.toString()}`); - return; + logInfo(`[setupSyncForSlUri] Could not parse sl:// URI: ${uri.toString()}`); + return { outcome: "error" }; } // Skip filesystem linking for no-modify items (they can still be viewed but not synced) const canModify = !parsed.item?.permissions || (parsed.item.permissions.owner & PERM_MODIFY) !== 0; @@ -446,25 +478,26 @@ export class SynchService implements vscode.Disposable { logInfo( `[setupSyncForSlUri] Skipping filesystem link for no-modify item "${parsed.scriptName}.${parsed.extension}"`, ); - return; + return { outcome: "skipped-no-modify" }; } - const masterUri = await SynchService.findMasterFile(parsed, slDocument); + const masterUri = await SynchService.findMasterFile(parsed, content); if (!masterUri) { logInfo( `[setupSyncForSlUri] No master found for "${parsed.scriptName}.${parsed.extension}"; ` + `editing directly via viewer.`, ); - return; + return { outcome: "no-match" }; } - const masterEditor = await SynchService.openMasterScript(masterUri); - const sync = await this.getOrCreateSync(masterEditor.document, parsed.language); - // openTextDocument guarantees readFile has completed for virtual fs documents - const loadedDoc = await vscode.workspace.openTextDocument(slDocument.uri); + const masterDoc = await vscode.workspace.openTextDocument(masterUri); + const masterEditor = options.reveal + ? await vscode.window.showTextDocument(masterDoc, { preview: false }) + : undefined; + const sync = await this.getOrCreateSync(masterDoc, parsed.language); if (!parsed.rootId || !parsed.itemId) { logInfo( - `[setupSyncForSlUri] Missing canonical identity for "${slDocument.uri.toString()}"`, + `[setupSyncForSlUri] Missing canonical identity for "${uri.toString()}"`, ); - return; + return { outcome: "error" }; } const identity: ScriptIdentity = { @@ -472,20 +505,130 @@ export class SynchService implements vscode.Disposable { primId: parsed.primId ?? null, itemId: parsed.itemId, }; + const existingSync = [...this.activeSyncs.values()] + .find((sync) => sync.isTrackingIdentity(identity)); + if (existingSync) { + return { + outcome: "already-linked", + masterUri: existingSync.getMasterUri(), + }; + } + sync.subscribeVirtual( - slDocument.uri, - loadedDoc.getText(), + uri, + content, identity, parsed.item, ); - SynchService.checkAndUpdateMasterDocumentInBackground(masterEditor, slDocument); - this.syncedFileDecorator.refresh(masterEditor.document.uri); + const mismatch = masterDoc.getText() !== content; + if (masterEditor && viewerDocument) { + SynchService.checkAndUpdateMasterDocumentInBackground(masterEditor, viewerDocument); + } + this.syncedFileDecorator.refresh(masterDoc.uri); logInfo( `[setupSyncForSlUri] Linked "${parsed.scriptName}" ` + - `(${slDocument.uri.toString()}) \u2192 ${masterUri.fsPath}`, + `(${uri.toString()}) \u2192 ${masterUri.fsPath}`, ); // Do NOT call setupConnection() — already connected // Do NOT call sendSyncSubscription() — sl:// content travels via object.content.save + return { outcome: "linked", masterUri, mismatch }; + } + + public async autoLinkObject(objectId: string): Promise { + const summary: AutoLinkSummary = { + linked: 0, + alreadyLinked: 0, + noMatch: 0, + skippedNoModify: 0, + errors: 0, + mismatches: 0, + }; + const entry = ObjectContentService.getInstance().getObject(objectId); + if (!entry) { + summary.errors++; + return summary; + } + + const items = [ + { + primId: objectId, + items: entry.object.inventory ?? [], + }, + ...(entry.object.linked_objects ?? []).map((linked) => ({ + primId: linked.link_id, + items: linked.inventory ?? [], + })), + ].flatMap(({ primId, items: inventory }) => + inventory.map((item) => ({ primId, item })), + ); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Linking files in ${entry.object.object_name}`, + cancellable: true, + }, + async (progress, token) => { + for (let index = 0; index < items.length; index++) { + if (token.isCancellationRequested) { + break; + } + + const { primId, item } = items[index]; + const uri = itemUri(objectId, primId, item.item_id); + progress.report({ + message: `${index + 1}/${items.length}: ${displayName(item)}`, + increment: items.length > 0 ? 100 / items.length : 100, + }); + + try { + const content = Buffer.from( + await vscode.workspace.fs.readFile(uri), + ).toString("utf-8"); + const result = await this.linkSlItem( + uri, + content, + { reveal: false }, + ); + + switch (result.outcome) { + case "linked": + summary.linked++; + if (result.mismatch) { + summary.mismatches++; + } + break; + case "already-linked": + summary.alreadyLinked++; + break; + case "no-match": + summary.noMatch++; + break; + case "skipped-no-modify": + summary.skippedNoModify++; + break; + case "error": + summary.errors++; + break; + } + } catch (error) { + summary.errors++; + logWarning( + `[autoLinkObject] Failed to link ${displayName(item)}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + }, + ); + + await showInfoMessage( + `Auto-link complete for ${entry.object.object_name}: ` + + `${summary.linked} linked, ${summary.alreadyLinked} already linked, ` + + `${summary.noMatch} not matched, ${summary.skippedNoModify} skipped ` + + `(no modify), ${summary.errors} errors, ${summary.mismatches} differing.`, + ); + return summary; } public removeSync(filePath: string): void { @@ -545,7 +688,20 @@ export class SynchService implements vscode.Disposable { onRuntimeError: (message: RuntimeError): any => this.onRuntimeError(message), onObjectPublish: (msg: ObjectPublishMessage): any => { logDebug(`[object.publish] object_id=${msg.object.object_id}`); - ObjectContentService.getInstance().handlePublish(msg); + const service = ObjectContentService.getInstance(); + service.handlePublish(msg); + + const autoLinkEnabled = ConfigService.getInstance() + .getConfig(ConfigKey.AutoLinkOnPublish, false); + const objectId = msg.object.object_id; + if ( + autoLinkEnabled && + hasWorkspace() && + !this.autoLinkedObjectIds.has(objectId) + ) { + this.autoLinkedObjectIds.add(objectId); + void this.autoLinkObject(objectId); + } }, onObjectUnpublish: (msg: ObjectUnpublishMessage): any => { logDebug(`[object.unpublish] object_id=${msg.object_id}`); @@ -722,6 +878,7 @@ export class SynchService implements vscode.Disposable { // All cleanup happens here - fires for both graceful and crash disconnects console.log("[SynchService] Connection closed"); this.websocket?.stopPingTimer(); + this.autoLinkedObjectIds.clear(); if (this.handshakeResolve) { this.handshakeResolve(false, "Connection closed"); @@ -1066,7 +1223,16 @@ export class SynchService implements vscode.Disposable { const di = fullName.lastIndexOf('.'); if (di < 0) { if(item.type == "notecard") { - return {scriptName: item.name, scriptId: uri.toString(), extension: "txt", language: "txt", item}; + return { + scriptName: item.name, + scriptId: uri.toString(), + extension: "txt", + language: "txt", + item, + rootId: root_id, + primId: prim_id, + itemId: item.item_id, + }; } return null; } @@ -1134,10 +1300,10 @@ export class SynchService implements vscode.Disposable { private static async findMasterFile( script: ParsedTempFile, - viewerFile: vscode.TextDocument + content: string ): Promise { // Attempt to match by file meta info - const metaMatch = await SynchService.findMasterFileByMetaComment(script, viewerFile); + const metaMatch = await SynchService.findMasterFileByMetaComment(script, content); if(metaMatch) return metaMatch; let files = await vscode.workspace.findFiles(`**/${script.scriptName}.${script.extension}`); @@ -1229,7 +1395,7 @@ export class SynchService implements vscode.Disposable { private static async findMasterFileByMetaComment( script: ParsedTempFile, - viewerFile: vscode.TextDocument + content: string ) : Promise { const config = ConfigService.getInstance() @@ -1238,8 +1404,7 @@ export class SynchService implements vscode.Disposable { if(cmt.length < 1) return null; const lineRegExp = new RegExp(`^[\\s]*${cmt}[\\s]*@file[\\s]+.*$`, "i"); - const range = new vscode.Range(0, 0, 10, 0); - const lines = viewerFile.getText(range).split("\n"); + const lines = content.split("\n").slice(0, 10); const start = lines.filter(line => line.match(lineRegExp))[0] ?? null; if (start) { const pathPart = start.split("@file")[1]?.trim() ?? ""; diff --git a/src/vscode/objectexplorerwebview.ts b/src/vscode/objectexplorerwebview.ts index 7e2f77a..64f2128 100644 --- a/src/vscode/objectexplorerwebview.ts +++ b/src/vscode/objectexplorerwebview.ts @@ -10,6 +10,7 @@ import { PublishedObject } from "./objectcontentinterfaces"; import { ViewerEditWSClient } from "../viewereditwsclient"; import { ObjectPinStore } from "./objectpinstore"; import { displayName, extractJsonRpcErrorCode, JSONRPC_INVALID_PARAMS } from "./objectcontentprovider"; +import { SynchService } from "../synchservice"; interface PinnedObjectView { object_id: string; @@ -45,6 +46,7 @@ export class ObjectExplorerWebviewProvider implements vscode.WebviewViewProvider private readonly isConnected: () => boolean, onConnectionChange: vscode.Event, private readonly getWebSocket: () => ViewerEditWSClient | undefined, + private readonly synchService: SynchService, ) { this._extensionUri = extensionUri; this._service = ObjectContentService.getInstance(); @@ -318,6 +320,11 @@ export class ObjectExplorerWebviewProvider implements vscode.WebviewViewProvider this._service.handleUnpublish({ object_id }); break; } + case "autoLinkObject": { + const { object_id } = message.payload as { object_id: string }; + await this.synchService.autoLinkObject(object_id); + break; + } case "renameItem": { const { prim_id, item_id, newName } = message.payload as { object_id: string; prim_id: string; item_id: string; newName: string; diff --git a/src/webview/explorer/explorer.ts b/src/webview/explorer/explorer.ts index f30a059..b8cc92c 100644 --- a/src/webview/explorer/explorer.ts +++ b/src/webview/explorer/explorer.ts @@ -1159,6 +1159,13 @@ function showObjectMenu(anchor: MenuAnchor, objectEl: HTMLElement): void { label: "New File...", action: () => beginCreateItem(object_id, object_id), }, + { + label: "Link All", + action: () => vscode.postMessage({ + command: "autoLinkObject", + payload: { object_id }, + }), + }, { separator: true }, { label: "Unexplore",