diff --git a/package.json b/package.json index 08c1066..06c90ad 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,40 @@ "command": "second-life-scripting.forceLanguageUpdate", "title": "Force Language Update", "category": "Second Life" + }, + { + "command": "second-life-scripting.stopFileSync", + "shortTitle": "Stop File Sync", + "title": "Stop file sync with SL Viewer", + "category": "Second Life" + } + ], + "menus": { + "explorer/context": [ + { + "command": "second-life-scripting.stopFileSync", + "group": "navigation@99", + "when": "resourceScheme == file && slVscodeEdit:syncsActive" + } + ], + "editor/title/context": [ + { + "command": "second-life-scripting.stopFileSync", + "group": "1_close@99", + "when": "resourceScheme == file && slVscodeEdit:syncsActive" + } + ] + }, + "colors": [ + { + "id": "secondlife.syncedfile", + "description": "Color for files that are being synced with the viewer", + "defaults": { + "dark": "tab.activeBorderTop", + "light": "tab.activeBorderTop", + "highContrast": "tab.activeBorderTop", + "highContrastLight": "tab.activeBorderTop" + } } ], "configuration": [ @@ -106,6 +140,11 @@ "default": false, "description": "Whether the current sl user info should be included in the meta info" }, + "slVscodeEdit.sync.keepViewerFileOpen": { + "type": "boolean", + "default": true, + "description": "Should the viewers tempfile be kept open while editing" + }, "slVscodeEdit.sync.notecardComment": { "type": "string", "default" : null, diff --git a/src/extension.ts b/src/extension.ts index b469d8a..4ad5f35 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import { showErrorMessage } from "./utils"; import { ConfigKey } from "./interfaces/configinterface"; +import path from "path"; // This method is called when your extension is activated // Your extension is activated the very first time the command is executed @@ -34,7 +35,35 @@ export function activate(context: vscode.ExtensionContext): void { showErrorMessage("Second Life Scripting Extension: No workspace is opened.\nPlease open a folder in VSCode to enable full functionality."); } + setupCommands(context); + configService.on(ConfigKey.Enabled, (configService) => { + if(configService.isEnabled()) { + synchService.activate(); + logInfo("Second Life Scripting Extension activated"); + } else { + synchService.deactivate(); + logInfo("Second Life Scripting Extension deactivated"); + } + }); + + if(configService.isEnabled()) { + synchService.activate(); + logInfo("Second Life Scripting Extension activated"); + } + + context.subscriptions.push(configService); + context.subscriptions.push(languageService); + context.subscriptions.push(synchService); +} + +// This method is called when your extension is deactivated +export function deactivate(): void { + const synchService = SynchService.getInstance(); + synchService.deactivate(); +} + +function setupCommands(context: vscode.ExtensionContext) : void { // Register commands context.subscriptions.push( vscode.commands.registerCommand( @@ -89,28 +118,18 @@ export function activate(context: vscode.ExtensionContext): void { ) ); - configService.on(ConfigKey.Enabled, (configService) => { - if(configService.isEnabled()) { - synchService.activate(); - logInfo("Second Life Scripting Extension activated"); - } else { - synchService.deactivate(); - logInfo("Second Life Scripting Extension deactivated"); - } - }); - - if(configService.isEnabled()) { - synchService.activate(); - logInfo("Second Life Scripting Extension activated"); - } - - context.subscriptions.push(configService); - context.subscriptions.push(languageService); - context.subscriptions.push(synchService); -} - -// This method is called when your extension is deactivated -export function deactivate(): void { - const synchService = SynchService.getInstance(); - synchService.deactivate(); + context.subscriptions.push( + vscode.commands.registerCommand( + "second-life-scripting.stopFileSync", + (uri?: vscode.Uri) => { + if(!uri) { + uri = vscode.window.activeTextEditor?.document.uri; + } + if(!uri) { + return; + } + SynchService.getInstance().removeSync(path.normalize(uri.fsPath)); + } + ) + ); } diff --git a/src/interfaces/configinterface.ts b/src/interfaces/configinterface.ts index 692f402..023bc45 100644 --- a/src/interfaces/configinterface.ts +++ b/src/interfaces/configinterface.ts @@ -33,6 +33,7 @@ export enum ConfigKey { LastSyntaxID = 'syntax.lastID', AskIfViewerScriptMismatchesMaster = 'sync.askIfViewerScriptMismatchesMaster', CompareHashBeforeSync = 'sync.compareHashBeforeSync', + KeepViewerFileOpen = 'sync.keepViewerFileOpen', NotecardSyncComment = 'sync.notecardComment', FileMetaInfoInOutput ='sync.includeFileMetaInOutput', diff --git a/src/scriptsync.ts b/src/scriptsync.ts index 2a6ff28..ac42274 100644 --- a/src/scriptsync.ts +++ b/src/scriptsync.ts @@ -25,7 +25,7 @@ import { } from "./utils"; import { ScriptLanguage } from "./shared/languageservice"; import { CompilationResult, RuntimeDebug, RuntimeError } from "./viewereditwsclient"; -import { HostInterface, normalizePath } from "./interfaces/hostinterface"; +import { normalizePath } from "./interfaces/hostinterface"; import { SynchService } from "./synchservice"; import { IncludeInfo } from "./shared/parser"; import { sha256 } from "js-sha256"; @@ -51,18 +51,19 @@ export class ScriptSync implements vscode.Disposable { private diagnosticSources: Set = new Set(); private lineMappings?: LineMapping[]; private config: ConfigService; - private host: HostInterface; + // private host: HostInterface; private includedFiles : IncludeInfo[] = []; + private syncService: SynchService; //==================================================================== public constructor( masterDocument: vscode.TextDocument, language: ScriptLanguage, config: ConfigService, - scriptId?: string, - viewerDocument?: vscode.TextDocument, - host?: HostInterface, + scriptId: string, + viewerDocument: vscode.TextDocument, + syncService: SynchService, ) { this.config = config; @@ -71,12 +72,12 @@ export class ScriptSync implements vscode.Disposable { this.macros = new MacroProcessor(); this.initializeSystemMacros(language); - this.host = host ?? new VSCodeHost(); + this.syncService = syncService; // Initialize preprocessor with macro processor const enabled = config.getConfig(ConfigKey.PreprocessorEnable, true); if (enabled && isProccessedLanguage(this.language)) { - this.preprocessor = new LexingPreprocessor(this.host, config, this.macros); + this.preprocessor = new LexingPreprocessor(this.syncService.getHost(), config, this.macros); } this.masterDocument = masterDocument; @@ -146,6 +147,10 @@ export class ScriptSync implements vscode.Disposable { this.fileMappings = this.fileMappings.filter((m) => m !== mapping); if (close) { closeTextDocument(mapping.viewerDocument); + mapping.watcher?.dispose(); + } + if(!this.hasFilesToTrack()) { + this.syncService.clearEmptySyncs(); } } return this.fileMappings.length; @@ -157,10 +162,7 @@ export class ScriptSync implements vscode.Disposable { (m) => path.normalize(m.viewerDocument.fileName) === viewerFile, ); if (mapping) { - this.fileMappings = this.fileMappings.filter((m) => m !== mapping); - if (close) { - closeTextDocument(mapping.viewerDocument); - } + this.unsubscribeById(mapping.id, close); } return this.fileMappings.length; } @@ -178,6 +180,10 @@ export class ScriptSync implements vscode.Disposable { ); } + public hasFilesToTrack() : boolean { + return this.fileMappings.length > 0; + } + public getMasterDocument(): vscode.TextDocument { return this.masterDocument; } @@ -585,6 +591,7 @@ export class ScriptSync implements vscode.Disposable { try { this.diagnosticCollection.dispose(); + this.fileMappings.forEach(map => map.watcher?.dispose()); } catch (error) { // Log but don't throw during disposal console.warn("Error during ScriptSync disposal:", error); diff --git a/src/synchservice.ts b/src/synchservice.ts index 1d53aa5..3a12474 100644 --- a/src/synchservice.ts +++ b/src/synchservice.ts @@ -25,15 +25,16 @@ import { showInfoMessage, showStatusMessage, showWarningMessage, - closeEditor, logInfo, VSCodeHost, + closeTextDocument, } from "./utils"; import { maybe } from "./shared/sharedutils"; // TODO: migrate needed utilities from sharedutils if required import { ScriptLanguage, LanguageService } from "./shared/languageservice"; import { ScriptSync } from "./scriptsync"; import { getLanguageConfig } from "./shared/lexer"; import { HostInterface } from "./interfaces/hostinterface"; +import { SyncedFileDecorator } from "./vscode/SyncedFileDecorator"; type ParsedTempFile = { scriptName: string; scriptId: string; extension: string, language: ScriptLanguage }; @@ -58,11 +59,14 @@ export class SynchService implements vscode.Disposable { public agentId?: string; public agentName?: string; + private syncedFileDecorator : SyncedFileDecorator; + private disposables: vscode.Disposable[] = []; private constructor(context: vscode.ExtensionContext) { this.context = context; this.host = new VSCodeHost(); + this.syncedFileDecorator = new SyncedFileDecorator(this); } public static getInstance(context?: vscode.ExtensionContext): SynchService { @@ -93,20 +97,29 @@ export class SynchService implements vscode.Disposable { this.disposables = []; } + public getHost() : HostInterface + { + return this.host; + } + public initialize(): void { const onDidOpenListener = vscode.workspace.onDidOpenTextDocument( async (document) => this.onOpenTextDocument(document), ); - const onDidCloseListener = vscode.workspace.onDidCloseTextDocument( - (document: vscode.TextDocument) => this.onCloseTextDocument(document), - ); + // const onDidCloseListener = vscode.workspace.onDidCloseTextDocument( + // (document: vscode.TextDocument) => this.onCloseTextDocument(document), + // ); const onDidDeleteListener = vscode.workspace.onDidDeleteFiles( (event: vscode.FileDeleteEvent) => this.onDeleteFiles(event), ); + const onDidCloseWorkspace = vscode.workspace.onDidChangeWorkspaceFolders((e) => { + e.removed.forEach(folder => this.onCloseWorkspace(folder)); + }); + const onDidSaveListener = vscode.workspace.onDidSaveTextDocument( (document: vscode.TextDocument) => this.onSaveTextDocument(document), ); @@ -131,11 +144,13 @@ export class SynchService implements vscode.Disposable { // showStatusMessage("Initializing syntax...", syntaxInit); this.disposables.push(onDidOpenListener); - this.disposables.push(onDidCloseListener); + // this.disposables.push(onDidCloseListener); + this.disposables.push(onDidCloseWorkspace); this.disposables.push(onDidDeleteListener); this.disposables.push(onDidSaveListener); this.disposables.push(onDidChangeWindowState); this.disposables.push(onDidChangeActiveTextEditor); + this.disposables.push(vscode.window.registerFileDecorationProvider(this.syncedFileDecorator)); const launchDoc = vscode.window.activeTextEditor?.document @@ -171,7 +186,7 @@ export class SynchService implements vscode.Disposable { private async setupSync( viewerDocument: vscode.TextDocument, ): Promise { - const viewerFilePath = path.normalize(viewerDocument.fileName); + const viewerFilePath = path.normalize(viewerDocument.uri.fsPath); const openedBase = path.basename(viewerFilePath); if (!hasWorkspace()) { @@ -189,7 +204,9 @@ 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 masterFound = true; if (!masterUri) { + masterFound = false; // There was no master file found, we are our own master showInfoMessage( `No master script found for: ${parsed.scriptName}.${parsed.extension}`, @@ -202,7 +219,7 @@ export class SynchService implements vscode.Disposable { showInfoMessage(`Opening master script: ${path.basename(masterPath)}`); let masterEditor = await SynchService.openMasterScript(masterUri); let masterDoc = masterEditor.document - SynchService.checkAndUpdateMasterDocumentInBackground(masterEditor, viewerDocument) + SynchService.checkAndUpdateMasterDocumentInBackground(masterEditor, viewerDocument); // Connection goes on in the background let viewerConnecting: Promise = this.setupConnection(); @@ -223,62 +240,80 @@ export class SynchService implements vscode.Disposable { } }); - let sync = this.findSyncByTempFilePath(viewerFilePath) ?? - this.findSyncByMasterFilePath(masterPath); - if (sync) { + const masterSync = this.findSyncByMasterFilePath(masterPath); + const syncs : ScriptSync[] = []; + + if(masterSync) { + syncs.push(masterSync); + } else { + syncs.push(...this.findSyncsByTempFilePath(viewerFilePath)); + } + + if(!this.host.config.getConfig(ConfigKey.KeepViewerFileOpen, true) && masterFound) { + void closeTextDocument(viewerDocument).catch((error) => { + logInfo( + `Failed to auto-close viewer document ${viewerDocument.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + + if(syncs.length) { // Already syncing the master, add another id and viewer file - sync.subscribe(parsed.scriptId, viewerDocument); + syncs.forEach(sync => sync.subscribe(parsed.scriptId, viewerDocument)); } else { const config = ConfigService.getInstance(); - sync = new ScriptSync( + const sync = new ScriptSync( masterDoc, parsed.extension as ScriptLanguage, config, parsed.scriptId, viewerDocument, - this.host, + this, ); await sync.initialize(); - this.activeSyncs.set(masterPath, sync); + const normalizedMasterPath = path.normalize(masterPath); + this.activeSyncs.set(normalizedMasterPath, sync); + syncs.push(sync); } + syncs.forEach(sync => this.syncedFileDecorator.refresh(sync.getMasterDocument().uri)); + if (this.websocket && this.websocket.isConnected()) { - this.sendSyncSubscription(sync); + syncs.forEach(sync => this.sendSyncSubscription(sync)); } else { viewerConnecting.then((connected) => { if (connected) { - this.sendSyncSubscription(sync); + syncs.forEach(sync=>this.sendSyncSubscription(sync)); } }); } + this.clearEmptySyncs(); + return true; } - public removeSync(filePath: string, close: boolean): void { + public removeSync(filePath: string): void { // seeing if we closed a temp file or a master file - let sync = - this.findSyncByTempFilePath(filePath) ?? - this.findSyncByMasterFilePath(filePath); + let sync = this.findSyncByMasterFilePath(filePath); if (!sync) { // No sync found for this file, we are not tracking it return; } - if (sync.getMasterFilePath() !== filePath) { - // We only destroy the sync if the master file is closed - // This is so we can continue to handle preprocessor directives while editing. - this.activeSyncs.delete(sync.getMasterFilePath()); - sync.dispose(); - } else { - // This is not the master file, just remove the tracking links. - const parsed = SynchService.parseTempFile(filePath); - if (parsed) { - // Remove the tracking subscription, if there are no more tracked files we will dispose the sync - sync.unsubscribeById(parsed.scriptId); - if (close) { - closeEditor(filePath); - } + this.activeSyncs.delete(sync.getMasterFilePath()); + this.syncedFileDecorator.refresh(sync.getMasterDocument().uri); + sync.dispose(); + + this.clearEmptySyncs(); + } + + public clearEmptySyncs() : void { + for(const [key,sync] of this.activeSyncs) { + if(!sync.hasFilesToTrack()) { + this.activeSyncs.delete(key); + this.syncedFileDecorator.refresh(sync.getMasterDocument().uri); + sync.dispose(); } } @@ -292,6 +327,11 @@ export class SynchService implements vscode.Disposable { this.websocket = undefined; } } + vscode.commands.executeCommand( + "setContext", + "slVscodeEdit:syncsActive", + this.activeSyncs.size > 0 + ); } //==================================================================== @@ -625,9 +665,9 @@ export class SynchService implements vscode.Disposable { ); } - public findSyncByTempFilePath(filePath: string): ScriptSync | undefined { + public findSyncsByTempFilePath(filePath: string): ScriptSync[] { filePath = path.normalize(filePath); - return [...this.activeSyncs.values()].find((sync) => + return [...this.activeSyncs.values()].filter((sync) => sync.isTrackingFile(filePath), ); } @@ -816,16 +856,25 @@ export class SynchService implements vscode.Disposable { this.initializeSyntax(); } - private onCloseTextDocument(document: vscode.TextDocument): void { - const filePath = path.normalize(document.fileName); - this.removeSync(filePath, false); + private onCloseWorkspace(workspace: vscode.WorkspaceFolder) : void { + const workspacePath = path.normalize(workspace.uri.fsPath); + const workspacePrefix = workspacePath.endsWith(path.sep) + ? workspacePath + : workspacePath + path.sep; + + for (const document of vscode.workspace.textDocuments) { + const filePath = path.normalize(document.fileName); + if (filePath === workspacePath || filePath.startsWith(workspacePrefix)) { + this.removeSync(filePath); + } + } } private onDeleteFiles(event: vscode.FileDeleteEvent): void { const uris = event.files; uris.forEach((uri) => { const filePath = path.normalize(uri.fsPath); - this.removeSync(filePath, false); + this.removeSync(filePath); }); } @@ -861,13 +910,13 @@ export class SynchService implements vscode.Disposable { // if the viewer launched it we will soon get a foucus event (onChangeWindowState) // Find the sync for this file, if any and then record the time. const filePath = path.normalize(editor.document.fileName); - const sync = this.findSyncByTempFilePath(filePath); - if (sync) { + const syncs = this.findSyncsByTempFilePath(filePath); + if (syncs.length) { // We have a sync for this file, record the time // We'll use this to see if a focus event happens very soon after // this event, if so we can assume the viewer launched us this.lastActiveChange = Date.now(); - this.activeSync = sync; + this.activeSync = syncs.pop(); } } //#endregion diff --git a/src/vscode/SyncedFileDecorator.ts b/src/vscode/SyncedFileDecorator.ts new file mode 100644 index 0000000..3f175fb --- /dev/null +++ b/src/vscode/SyncedFileDecorator.ts @@ -0,0 +1,35 @@ +import { + FileDecorationProvider, + Uri, + FileDecoration, + EventEmitter, + ProviderResult, + CancellationToken, + ThemeColor +} from "vscode"; + +import type { SynchService } from "../synchservice"; + +export class SyncedFileDecorator implements FileDecorationProvider { + private syncService: SynchService; + private _onDidChangeFileDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + constructor(syncService:SynchService) { + this.syncService = syncService; + } + + provideFileDecoration(uri: Uri, _token: CancellationToken): ProviderResult { + if(this.syncService.findSyncByMasterFilePath(uri.fsPath)) { + return { + badge: '🔗', + tooltip: 'Synchronized with secondlife viewer', + color: new ThemeColor('secondlife.syncedfile'), + }; + } + } + + public refresh(uri?: Uri | Uri[]) : void { + this._onDidChangeFileDecorations.fire(uri); + } +}