Repository navigation
Expand file tree
/
Copy pathextension.ts
More file actions
477 lines (409 loc) · 16.5 KB
/
Copy pathextension.ts
File metadata and controls
477 lines (409 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
import { join } from 'node:path';
import { FileScheme } from '@vscode-adblock-syntax/shared';
import {
commands,
type ExtensionContext,
RelativePattern,
StatusBarAlignment,
type StatusBarItem,
type TextDocument,
ThemeColor,
window as Window,
workspace as Workspace,
type WorkspaceFolder,
} from 'vscode';
import {
LanguageClient,
type LanguageClientOptions,
type ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
import {
CLIENT_ID,
CLIENT_NAME,
CONFIG_FILE_NAMES,
DOCUMENT_SCHEME,
IGNORE_FILE_NAME,
LANGUAGE_ID,
STATUS_BAR_PRIORITY,
SUPPORTED_FILE_EXTENSIONS,
} from './constants';
import { shouldEnableAglintDebug } from './utils/log-level';
import { type AglintStatus, parseStatusParams } from './utils/status-parser';
import { fileInFolder, getOuterMostWorkspaceFolder } from './workspace-folders';
const SERVER_PATH = join('server', 'out', 'server.js');
/**
* Language client instance for the default workspace folder or untitled documents.
* This is used when no specific workspace folder is available.
*/
let defaultClient: LanguageClient | undefined;
/**
* Map of language clients for each workspace folder.
* This allows us to manage multiple clients for different folders in the workspace.
*/
const clients = new Map<string, LanguageClient>();
/**
* Status bar item to show the AGLint status.
* This is shared across all workspace folders and updates based on the active editor's folder.
*/
let statusBarItem: StatusBarItem;
/**
* Map of last-known status per OUTERMOST folder.
* For example, if `project-1` is added to the workspace, but
* `project-1/sub-project-1` is also added, then `project-1` is the OUTERMOST folder.
*
* Keys are the URI of the OUTERMOST folder.
* Values are the last-known status for that folder.
*/
const folderStatus = new Map<string, AglintStatus>();
/**
* Render the status bar from a stored status (or reset if none).
*
* @param status Status to render.
*/
function renderStatus(status: AglintStatus | undefined) {
if (!status) {
statusBarItem.backgroundColor = undefined;
statusBarItem.text = 'AGLint';
return;
}
if (status.error) {
statusBarItem.backgroundColor = new ThemeColor('statusBarItem.warningBackground');
statusBarItem.text = '$(warning) AGLint';
return;
}
statusBarItem.backgroundColor = undefined;
statusBarItem.text = status.aglintEnabled === false
? '$(debug-pause) AGLint'
: 'AGLint';
}
/**
* Update the status bar for a specific workspace folder.
*
* When user switches to a file in a different folder, this function is called to update the status bar
* for the OUTERMOST folder of that workspace.
*
* @param folder Folder whose status we want to update.
* @param params Parameters from the server notification, which may include error status or AGLint enabled state.
*/
function updateStatusBarForFolder(folder: WorkspaceFolder, params: unknown) {
const status = parseStatusParams(params);
// Store last-known status for the OUTERMOST folder of this client
const outer = getOuterMostWorkspaceFolder(folder);
const key = outer.uri.toString();
folderStatus.set(key, status);
// If active editor belongs to the same OUTERMOST folder, render immediately
const activeUri = Window.activeTextEditor?.document?.uri;
if (!activeUri) {
return;
}
const activeFolder = Workspace.getWorkspaceFolder(activeUri);
if (!activeFolder) {
return;
}
const activeOuter = getOuterMostWorkspaceFolder(activeFolder);
if (activeOuter.uri.toString() !== key) {
// different folder: keep it stored for later
return;
}
renderStatus(status);
}
/**
* Creates a language client for a specific workspace folder.
*
* @param folder The workspace folder for which the client is created.
* @param serverModule The path to the server module.
*
* @returns A new instance of LanguageClient for the specified folder.
*/
function createClientForFolder(folder: WorkspaceFolder, serverModule: string): LanguageClient {
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: { module: serverModule, transport: TransportKind.ipc },
};
const rootFs = folder.uri.fsPath;
// Create output channel - this makes the extension appear in "Developer: Set Log Level"
const outputChannel = Window.createOutputChannel(`${CLIENT_NAME} (${folder.name})`, { log: true });
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: DOCUMENT_SCHEME, language: LANGUAGE_ID }],
workspaceFolder: folder,
initializationOptions: {
workspaceFolder: { uri: folder.uri.toString(), name: folder.name },
enableAglintDebug: shouldEnableAglintDebug(outputChannel.logLevel),
},
outputChannel,
synchronize: {
// File watchers are configured to explicitly avoid node_modules.
// VSCode's createFileSystemWatcher respects 'files.watcherExclude' by default,
// but we also structure patterns to be explicit about what we want to watch.
fileEvents: [
// Watch filter list files (but ignore changes, only watch create/delete)
// Pattern avoids node_modules by using VSCode's default exclusions
Workspace.createFileSystemWatcher(
new RelativePattern(
folder,
`**/*.{${Array.from(SUPPORTED_FILE_EXTENSIONS).join(',')}}`,
),
false, // ignoreCreateEvents
true, // ignoreChangeEvents
false, // ignoreDeleteEvents
),
// Watch AGLint config files at workspace root
...Array.from(CONFIG_FILE_NAMES).map((configName) => Workspace.createFileSystemWatcher(
new RelativePattern(folder, configName),
)),
// Watch AGLint config files in subdirectories (VSCode excludes node_modules by default)
...Array.from(CONFIG_FILE_NAMES).map((configName) => Workspace.createFileSystemWatcher(
new RelativePattern(folder, `*/${configName}`),
)),
// Watch .aglintignore file at workspace root
Workspace.createFileSystemWatcher(new RelativePattern(folder, IGNORE_FILE_NAME)),
// Watch .aglintignore in subdirectories
Workspace.createFileSystemWatcher(new RelativePattern(folder, `*/${IGNORE_FILE_NAME}`)),
// Watch package.json ONLY at workspace root for AGLint installation detection
Workspace.createFileSystemWatcher(new RelativePattern(folder, 'package.json')),
// Watch node_modules directory itself (creation/deletion) for package installation detection
Workspace.createFileSystemWatcher(
new RelativePattern(folder, 'node_modules'),
false, // ignoreCreateEvents
false, // ignoreChangeEvents
false, // ignoreDeleteEvents
),
],
},
middleware: {
didOpen: (doc, next) => {
if (fileInFolder(doc.uri.fsPath, rootFs)) {
return next(doc);
}
return Promise.resolve();
},
didChange: (change, next) => {
if (fileInFolder(change.document.uri.fsPath, rootFs)) {
return next(change);
}
return Promise.resolve();
},
willSave: (e, next) => {
if (fileInFolder(e.document.uri.fsPath, rootFs)) {
return next(e);
}
return Promise.resolve();
},
willSaveWaitUntil: async (e, next) => {
if (fileInFolder(e.document.uri.fsPath, rootFs)) {
return next(e);
}
return Promise.resolve([]);
},
didSave: (doc, next) => {
if (fileInFolder(doc.uri.fsPath, rootFs)) {
return next(doc);
}
return Promise.resolve();
},
didClose: (doc, next) => {
if (fileInFolder(doc.uri.fsPath, rootFs)) {
return next(doc);
}
return Promise.resolve();
},
},
progressOnInitialization: true,
};
const id = `${CLIENT_ID}:${folder.uri.toString()}`;
const name = `${CLIENT_NAME} (${folder.name})`;
const client = new LanguageClient(id, name, serverOptions, clientOptions);
client.onNotification('aglint/status', (params: unknown) => {
updateStatusBarForFolder(folder, params);
});
// Listen for log level changes and notify the server
outputChannel.onDidChangeLogLevel((newLogLevel) => {
const enableDebug = shouldEnableAglintDebug(newLogLevel);
// Send notification to server about log level change
client.sendNotification('client/logLevelChanged', { enableAglintDebug: enableDebug }).catch(() => {
// Ignore errors - server might not be ready yet
});
});
return client;
}
/**
* Default client creation for untitled documents or when no workspace folder is available.
*
* @param serverModule The path to the server module.
*/
function ensureDefaultClient(serverModule: string) {
if (defaultClient) {
return;
}
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: { module: serverModule, transport: TransportKind.ipc },
};
// Create output channel - this makes the extension appear in "Developer: Set Log Level"
const outputChannel = Window.createOutputChannel(`${CLIENT_NAME} (untitled)`, { log: true });
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: FileScheme.Untitled, language: LANGUAGE_ID }],
progressOnInitialization: true,
outputChannel,
initializationOptions: {
enableAglintDebug: shouldEnableAglintDebug(outputChannel.logLevel),
},
};
// Give the untitled client a unique ID too
defaultClient = new LanguageClient(
`${CLIENT_ID}:untitled`,
`${CLIENT_NAME} (untitled)`,
serverOptions,
clientOptions,
);
// Listen for log level changes and notify the server
outputChannel.onDidChangeLogLevel((newLogLevel) => {
const enableDebug = shouldEnableAglintDebug(newLogLevel);
// Send notification to server about log level change
defaultClient?.sendNotification('client/logLevelChanged', { enableAglintDebug: enableDebug }).catch(() => {
// Ignore errors - server might not be ready yet
});
});
defaultClient.start();
}
/**
* When a text document is opened, we make sure to create or use an existing client for the folder.
*
* @param document The opened text document.
* @param serverModule The path to the server module.
*/
function didOpenTextDocument(document: TextDocument, serverModule: string): void {
// Only handle documents with the specific language ID and schemes
if (
document.languageId !== LANGUAGE_ID
|| (document.uri.scheme !== FileScheme.File && document.uri.scheme !== FileScheme.Untitled)
) {
return;
}
const { uri } = document;
if (uri.scheme === FileScheme.Untitled) {
ensureDefaultClient(serverModule);
return;
}
const workspaceFolder = Workspace.getWorkspaceFolder(uri);
if (!workspaceFolder) {
return;
}
const mostOuterWorkspaceFolder = getOuterMostWorkspaceFolder(workspaceFolder);
const key = mostOuterWorkspaceFolder.uri.toString();
if (!clients.has(key)) {
const client = createClientForFolder(mostOuterWorkspaceFolder, serverModule);
client.start();
clients.set(key, client);
}
}
/**
* Attaches listeners to workspace folder changes to manage clients.
*
* @param context Extension context.
*/
function attachWorkspaceFolderListeners(context: ExtensionContext) {
// Remove clients AND their stored status when folders are removed
context.subscriptions.push(
Workspace.onDidChangeWorkspaceFolders((event) => {
for (const folder of event.removed) {
const outer = getOuterMostWorkspaceFolder(folder);
const outerKey = outer.uri.toString();
folderStatus.delete(outerKey);
const key = folder.uri.toString();
const client = clients.get(key);
if (client) {
clients.delete(key);
client.stop();
}
}
}),
// On editor change, render the last-known status of that folder (don’t reset blindly)
Window.onDidChangeActiveTextEditor((editor) => {
if (!editor) {
renderStatus(undefined);
return;
}
const folder = Workspace.getWorkspaceFolder(editor.document.uri);
if (!folder) {
renderStatus(undefined);
return;
}
const outer = getOuterMostWorkspaceFolder(folder);
const key = outer.uri.toString();
renderStatus(folderStatus.get(key));
}),
);
}
/**
* Activates the extension.
*
* @param context The extension context.
*/
export function activate(context: ExtensionContext) {
const isVirtualWorkspace = Workspace.workspaceFolders
&& Workspace.workspaceFolders.every((f) => f.uri.scheme !== FileScheme.File);
if (isVirtualWorkspace) {
Window.showWarningMessage(
// eslint-disable-next-line max-len
'AGLint does not support virtual workspaces, since it requires access to the filesystem via Node.js FS API. Only syntax highlighting will be available here.',
);
return;
}
const serverModule = context.asAbsolutePath(join(SERVER_PATH));
// Status bar
statusBarItem = Window.createStatusBarItem(StatusBarAlignment.Right, STATUS_BAR_PRIORITY);
statusBarItem.name = 'AGLint';
statusBarItem.text = 'AGLint';
statusBarItem.tooltip = 'Show AGLint output channel to see more information';
// Add command to show AGLint debug console
statusBarItem.command = { title: 'Open AGLint Output', command: 'aglint.showOutputChannel' };
statusBarItem.show();
context.subscriptions.push(
statusBarItem,
commands.registerCommand('aglint.showOutputChannel', () => {
const activeUri = Window.activeTextEditor?.document?.uri;
if (activeUri) {
const folder = Workspace.getWorkspaceFolder(activeUri);
if (folder) {
const outer = getOuterMostWorkspaceFolder(folder);
const key = outer.uri.toString();
const clientForFolder = clients.get(key)
// fall back to client created with the exact (non-outer) key
?? clients.get(folder.uri.toString());
clientForFolder?.outputChannel.show();
return;
}
}
const firstClient = defaultClient ?? [...clients.values()][0];
firstClient?.outputChannel.show();
}),
);
// Handle already opened documents
Workspace.textDocuments.forEach((doc) => didOpenTextDocument(doc, serverModule));
// Handle newly opened documents
context.subscriptions.push(
Workspace.onDidOpenTextDocument((doc) => didOpenTextDocument(doc, serverModule)),
);
// Handle workspace changes and editor changes
attachWorkspaceFolderListeners(context);
// Info (with the first available client)
const anyClient = defaultClient ?? [...clients.values()][0];
anyClient?.info?.(`AGLint client activated. Extension version: ${context.extension.packageJSON.version}`);
}
/**
* Function called when the extension is deactivated.
*
* @returns An array of promises that resolves when all clients are stopped.
*/
export function deactivate(): Thenable<void> | undefined {
const promises: Thenable<void>[] = [];
if (defaultClient) {
promises.push(defaultClient.stop());
}
for (const client of clients.values()) {
promises.push(client.stop());
}
return Promise.all(promises).then(() => undefined);
}