From 8a1788a0c1ebe4aa4deb9fc847c7428e6eb938c4 Mon Sep 17 00:00:00 2001 From: Alex Matchneer Date: Thu, 16 Jan 2025 17:44:14 -0500 Subject: [PATCH] Re-write extension to use reactive-vscode, align with Vue extension (#790) --- .prettierignore | 2 + packages/vscode/.gitignore | 2 + .../__tests__/support/launch-from-cli.mts | 12 +- packages/vscode/package.json | 51 ++- packages/vscode/src/compatibility.ts | 63 ++++ packages/vscode/src/config.ts | 7 + packages/vscode/src/extension.ts | 341 +++++++++--------- packages/vscode/src/hybrid-mode.ts | 175 +++++++++ packages/vscode/src/language-client.ts | 217 +++++++++++ packages/vscode/tsconfig.json | 5 +- yarn.lock | 28 +- 11 files changed, 718 insertions(+), 185 deletions(-) create mode 100644 packages/vscode/src/compatibility.ts create mode 100644 packages/vscode/src/config.ts create mode 100644 packages/vscode/src/hybrid-mode.ts create mode 100644 packages/vscode/src/language-client.ts diff --git a/.prettierignore b/.prettierignore index e32674f09..aa03f7a9f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,6 +20,8 @@ dist/ !/packages/environment*/-private/dsl/**/*.d.ts !/packages/environment*/-private/intrinsics/**/*.d.ts +/packages/vscode/src/generated-meta.ts + # Markdown files: the formatting Prettier uses by default *does. not. match.* # the formatting applied by Gitbook. Having both present is a recipe for ongoing # CI problems. diff --git a/packages/vscode/.gitignore b/packages/vscode/.gitignore index 43bb79d6d..0c0c91648 100644 --- a/packages/vscode/.gitignore +++ b/packages/vscode/.gitignore @@ -4,3 +4,5 @@ node_modules lib/ dist/ tsconfig.tsbuildinfo + +generated-meta.ts diff --git a/packages/vscode/__tests__/support/launch-from-cli.mts b/packages/vscode/__tests__/support/launch-from-cli.mts index c4069e6b8..eaa255439 100644 --- a/packages/vscode/__tests__/support/launch-from-cli.mts +++ b/packages/vscode/__tests__/support/launch-from-cli.mts @@ -1,11 +1,9 @@ import * as path from 'node:path'; import * as os from 'node:os'; -import { fileURLToPath } from 'node:url'; import { runTests } from '@vscode/test-electron'; import * as fs from 'node:fs'; -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const packageRoot = path.resolve(dirname, '../../..'); +const packageRoot = path.resolve(process.cwd()); const emptyExtensionsDir = path.join(os.tmpdir(), `extensions-${Math.random()}`); const emptyUserDataDir = path.join(os.tmpdir(), `user-data-${Math.random()}`); @@ -28,13 +26,13 @@ let disableExtensionArgs: string[] = []; let testRunner: string; switch (testType) { case 'language-server': - testRunner = 'vscode-runner-language-server.js'; + testRunner = 'lib/__tests__/support/vscode-runner-language-server.js'; // Disable vanilla TS for full "takeover" mode. disableExtensionArgs = ['--disable-extension', 'vscode.typescript-language-features']; break; case 'ts-plugin': - testRunner = 'vscode-runner-ts-plugin.js'; + testRunner = 'lib/__tests__/support/vscode-runner-ts-plugin.js'; // Note: here, we WANT vanilla TS to be enabled since we're testing the TS Plugin. break; @@ -44,9 +42,9 @@ switch (testType) { } try { - await runTests({ + runTests({ extensionDevelopmentPath: packageRoot, - extensionTestsPath: path.resolve(dirname, testRunner), + extensionTestsPath: path.resolve(process.cwd(), testRunner), launchArgs: [ // Don't show the "hey do you trust this folder?" prompt '--disable-workspace-trust', diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 37761b60c..0f9fc07b9 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -35,7 +35,9 @@ "bundle": "node ./scripts/build.mjs", "bundle:watch": "node ./scripts/build.mjs -- --watch", "extension:package": "vsce package --no-dependencies", - "extension:publish": "vsce publish --no-dependencies" + "extension:publish": "vsce publish --no-dependencies", + "postinstall": "vscode-ext-gen --scope glint", + "prebuild": "vscode-ext-gen --scope glint" }, "engines": { "vscode": "^1.68.1" @@ -188,6 +190,7 @@ ], "configuration": [ { + "type": "object", "title": "Glint", "properties": { "glint.libraryPath": { @@ -205,18 +208,44 @@ "verbose" ] }, - "glint.server.typescriptMode": { - "type": "string", - "default": "languageServer", + "glint.server.hybridMode": { + "type": [ + "boolean", + "string" + ], + "default": "auto", "enum": [ - "languageServer", - "typescriptPlugin" + "auto", + "typeScriptPluginOnly", + true, + false ], "enumDescriptions": [ - "(Old) use Glint Language Server for typechecking", - "(New) Use TypeScript server plugin for typechecking" + "Automatically detect and enable TypeScript Plugin/Hybrid Mode in a safe environment.", + "Only enable Glint TypeScript Plugin but disable the Glint language server.", + "Enable TypeScript Plugin/Hybrid Mode.", + "Disable TypeScript Plugin/Hybrid Mode." ], - "description": "Glint 2 shifts typechecking to a TypeScript server plugin. This setting allows you to opt into the new behavior." + "description": "Hybrid mode means that Glint will use a TypeScript server plugin for typechecking and use the Glint language server for other features. This is the recommended mode." + }, + "glint.server.compatibleExtensions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Set compatible extensions to skip automatic detection of Hybrid Mode." + }, + "glint.server.includeLanguages": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "glimmer-js", + "glimmer-ts", + "handlebars" + ] } } } @@ -241,7 +270,9 @@ "esbuild": "^0.15.16", "expect": "^29.5.0", "glob": "^10.2.4", - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "reactive-vscode": "0.2.7-beta.1", + "vscode-ext-gen": "^0.5.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/vscode/src/compatibility.ts b/packages/vscode/src/compatibility.ts new file mode 100644 index 000000000..40fa0a9d3 --- /dev/null +++ b/packages/vscode/src/compatibility.ts @@ -0,0 +1,63 @@ +import { computed, useAllExtensions } from 'reactive-vscode'; +import * as semver from 'semver'; +import * as vscode from 'vscode'; +import { config } from './config'; + +// TODO: does Glint need this concept of "compatible" extensions? + +const defaultCompatibleExtensions = new Set([ + 'astro-build.astro-vscode', + 'bierner.lit-html', + 'Divlo.vscode-styled-jsx-languageserver', + 'GitHub.copilot-chat', + 'ije.esm-vscode', + 'jenkey2011.string-highlight', + 'johnsoncodehk.vscode-tsslint', + 'kimuson.ts-type-expand', + 'miaonster.vscode-tsx-arrow-definition', + 'ms-dynamics-smb.al', + 'mxsdev.typescript-explorer', + 'nrwl.angular-console', + 'p42ai.refactor', + 'runem.lit-plugin', + 'ShenQingchuan.vue-vine-extension', + 'styled-components.vscode-styled-components', + 'unifiedjs.vscode-mdx', + 'VisualStudioExptTeam.vscodeintellicode', + 'Vue.volar', +]); + +const extensions = useAllExtensions(); + +export const incompatibleExtensions = computed(() => { + return extensions.value + .filter((ext) => isExtensionCompatibleWithHybridMode(ext) === false) + .map((ext) => ext.id); +}); + +export const unknownExtensions = computed(() => { + return extensions.value + .filter( + (ext) => + isExtensionCompatibleWithHybridMode(ext) === undefined && + !!ext.packageJSON?.contributes?.typescriptServerPlugins, + ) + .map((ext) => ext.id); +}); + +function isExtensionCompatibleWithHybridMode( + extension: vscode.Extension, +): boolean | undefined { + if ( + defaultCompatibleExtensions.has(extension.id) || + config.server.compatibleExtensions.includes(extension.id) + ) { + return true; + } + if (extension.id === 'denoland.vscode-deno') { + return !vscode.workspace.getConfiguration('deno').get('enable'); + } + if (extension.id === 'svelte.svelte-vscode') { + return semver.gte(extension.packageJSON.version, '108.4.0'); + } +} diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts new file mode 100644 index 000000000..47555f27b --- /dev/null +++ b/packages/vscode/src/config.ts @@ -0,0 +1,7 @@ +import { defineConfigObject } from 'reactive-vscode'; +import { NestedScopedConfigs, scopedConfigs } from './generated-meta'; + +export const config = defineConfigObject( + scopedConfigs.scope, + scopedConfigs.defaults, +); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 02d91ed29..81724821d 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,160 +1,193 @@ import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { - ExtensionContext, - WorkspaceFolder, - FileSystemWatcher, - window, - extensions, - commands, - workspace, - WorkspaceConfiguration, -} from 'vscode'; +import * as lsp from '@volar/vscode/node'; +import * as vscode from 'vscode'; import * as languageServerProtocol from '@volar/language-server/protocol.js'; -import { LabsInfo, createLabsInfo, getTsdk } from '@volar/vscode'; - -import { Disposable, LanguageClient, ServerOptions } from '@volar/vscode/node.js'; - -/////////////////////////////////////////////////////////////////////////////// -// Setup and extension lifecycle +import { createLabsInfo } from '@volar/vscode'; -const outputChannel = window.createOutputChannel('Glint Language Server'); -const clients = new Map(); -const fileExtensions = ['.js', '.ts', '.gjs', '.gts', '.hbs']; -const filePattern = `**/*{${fileExtensions.join(',')}}`; - -export function activate(context: ExtensionContext): LabsInfo { - // We need to activate the default VSCode TypeScript extension so that our - // TS Plugin kicks in. We do this because the TS extension is (obviously) not - // configured to activate for, say, .gts files: - // https://github.com/microsoft/vscode/blob/878af07/extensions/typescript-language-features/package.json#L62..L75 - extensions.getExtension('vscode.typescript-language-features')?.activate(); - - // TODO: Volar: i think this happens as part of dynamic registerCapability, i.e. - // I think maybe we can remove this from `activate` and wait for it to happen - // when the server sends the registerCapability questions for all dynamicRegistration=true capabilities. - let fileWatcher = workspace.createFileSystemWatcher(filePattern); +import { + defineExtension, + extensionContext, + onDeactivate, + useWorkspaceFolders, + watch, +} from 'reactive-vscode'; - context.subscriptions.push(fileWatcher, createConfigWatcher()); - context.subscriptions.push( - commands.registerCommand('glint.restart-language-server', restartClients), - ); +import { watchWorkspaceFolderForLanguageClientActivation } from './language-client'; - // TODO: how to each multiple workspace reloads with VolarLabs? +export const { activate, deactivate } = defineExtension(async () => { const volarLabs = createLabsInfo(languageServerProtocol); - workspace.workspaceFolders?.forEach((folder) => - addWorkspaceFolder(context, folder, fileWatcher, volarLabs), - ); - workspace.onDidChangeWorkspaceFolders(({ added, removed }) => { - added.forEach((folder) => addWorkspaceFolder(context, folder, fileWatcher)); - removed.forEach((folder) => removeWorkspaceFolder(folder)); - }); - - workspace.onDidChangeConfiguration((changeEvent) => { - if (changeEvent.affectsConfiguration('glint.libraryPath')) { - reloadAllWorkspaces(context, fileWatcher); - } - }); - - return volarLabs.extensionExports; -} - -export async function deactivate(): Promise { - await Promise.all([...clients.values()].map((client) => client.stop())); -} - -/////////////////////////////////////////////////////////////////////////////// -// Commands - -async function restartClients(): Promise { - outputChannel.appendLine(`Restarting Glint language server...`); - await Promise.all([...clients.values()].map((client) => client.restart())); -} - -/////////////////////////////////////////////////////////////////////////////// -// Workspace folder management - -async function reloadAllWorkspaces( - context: ExtensionContext, - fileWatcher: FileSystemWatcher, -): Promise { - let folders = workspace.workspaceFolders ?? []; - - await Promise.all( - folders.map(async (folder) => { - await removeWorkspaceFolder(folder); - await addWorkspaceFolder(context, folder, fileWatcher); - }), - ); -} - -async function addWorkspaceFolder( - context: ExtensionContext, - workspaceFolder: WorkspaceFolder, - watcher: FileSystemWatcher, - volarLabs?: ReturnType, -): Promise { - let folderPath = workspaceFolder.uri.fsPath; - if (clients.has(folderPath)) return; - - let serverPath = findLanguageServer(folderPath); - if (!serverPath) return; - - let serverOptions: ServerOptions = { module: serverPath }; - - const typescriptFormatOptions = getOptions(workspace.getConfiguration('typescript'), 'format'); - const typescriptUserPreferences = getOptions( - workspace.getConfiguration('typescript'), - 'preferences', - ); - const javascriptFormatOptions = getOptions(workspace.getConfiguration('javascript'), 'format'); - const javascriptUserPreferences = getOptions( - workspace.getConfiguration('javascript'), - 'preferences', - ); - - let client = new LanguageClient('glint', 'Glint', serverOptions, { - workspaceFolder, - outputChannel, - initializationOptions: { - javascript: { - format: javascriptFormatOptions, - preferences: javascriptUserPreferences, - }, - typescript: { - format: typescriptFormatOptions, - preferences: typescriptUserPreferences, - tsdk: (await getTsdk(context))!.tsdk, - }, - }, - documentSelector: [{ scheme: 'file', pattern: `${folderPath}/${filePattern}` }], - synchronize: { fileEvents: watcher }, - }); - - if (volarLabs) { - volarLabs.addLanguageClient(client); + const tsExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); + + if (tsExtension) { + // We need to activate the default VSCode TypeScript extension so that our + // TS Plugin kicks in. We do this because the TS extension is (obviously) not + // configured to activate for, say, .gts files: + // https://github.com/microsoft/vscode/blob/878af07/extensions/typescript-language-features/package.json#L62..L75 + + await tsExtension.activate(); + } else { + // TODO: we may decide to commit fully to TS Plugin mode, in which case it might be nice + // to have the message displayed below to guide the user. + // NOTE: Vue language tooling will continue to display this message even when willfully + // setting hybrid mode to false (i.e. using old LS approach). If we want to continue to support + // LS mode then we should leave this message commented out. + // vscode.window + // .showWarningMessage( + // 'Takeover mode is no longer needed since v2. Please enable the "TypeScript and JavaScript Language Features" extension.', + // 'Show Extension', + // ) + // .then((selected) => { + // if (selected) { + // executeCommand('workbench.extensions.search', '@builtin typescript-language-features'); + // } + // }); } - clients.set(folderPath, client); + const context = extensionContext.value!; + + const clients = new Map(); + + const reactiveWorkspaceFolders = useWorkspaceFolders(); + + let oldWorkspaceFolders: readonly vscode.WorkspaceFolder[] | undefined; + + // NOTE: I tried to use the `watch` callback API to provide the old and new values + // for workspace folders but for some reason they kept coming in as undefined + // so I'm tracking new and old values menually. + watch( + reactiveWorkspaceFolders, + () => { + const newWorkspaceFolders = reactiveWorkspaceFolders.value; + + // TODO: handle removed folders. + const removedFolders = + oldWorkspaceFolders?.filter( + (oldFolder) => + !newWorkspaceFolders?.some( + (newFolder) => newFolder.uri.fsPath === oldFolder.uri.fsPath, + ), + ) ?? []; + const addedFolders = + newWorkspaceFolders?.filter( + (newFolder) => + !oldWorkspaceFolders?.some( + (oldFolder) => oldFolder.uri.fsPath === newFolder.uri.fsPath, + ), + ) ?? []; + + oldWorkspaceFolders = newWorkspaceFolders; + + addedFolders.forEach((workspaceFolder) => { + const teardownClient = watchWorkspaceFolderForLanguageClientActivation( + context, + workspaceFolder, + (id, name, documentSelector, initOptions, port, outputChannel) => { + class _LanguageClient extends lsp.LanguageClient { + override fillInitializeParams(params: lsp.InitializeParams): void { + // fix https://github.com/vuejs/language-tools/issues/1959 + params.locale = vscode.env.language; + } + } + + let folderPath = workspaceFolder.uri.fsPath; + if (clients.has(folderPath)) return null; + + let serverPath = findLanguageServer(folderPath, outputChannel); + if (!serverPath) return null; + + const runOptions: lsp.ForkOptions = {}; + + // if (config.server.maxOldSpaceSize) { + // runOptions.execArgv ??= []; + // runOptions.execArgv.push('--max-old-space-size=' + config.server.maxOldSpaceSize); + // } + + const debugOptions: lsp.ForkOptions = { + execArgv: ['--nolazy', '--inspect=' + port], + }; + const serverOptions: lsp.ServerOptions = { + run: { + module: serverPath, + transport: lsp.TransportKind.ipc, + options: runOptions, + }, + debug: { + module: serverPath, + transport: lsp.TransportKind.ipc, + options: debugOptions, + }, + }; + + const clientOptions: lsp.LanguageClientOptions = { + // middleware, + documentSelector: documentSelector, + initializationOptions: initOptions, + markdown: { + isTrusted: true, + supportHtml: true, + }, + outputChannel, + }; + const client = new _LanguageClient(id, name, serverOptions, clientOptions); + client.start(); + + volarLabs.addLanguageClient(client); + + updateProviders(client); + + return client; + }, + ); + + onDeactivate(() => { + teardownClient(); + }); + }); + }, + { + immediate: true, // causes above callback to be run immediately (i.e. not lazily) + }, + ); - await client.start(); -} + // workspaceFolders.value.forEach((workspaceFolder) => -async function removeWorkspaceFolder(workspaceFolder: WorkspaceFolder): Promise { - let folderPath = workspaceFolder.uri.fsPath; - let client = clients.get(folderPath); - if (client) { - clients.delete(folderPath); - await client.stop(); - } + return volarLabs.extensionExports; +}); + +function updateProviders(client: lsp.LanguageClient): void { + const initializeFeatures = (client as any).initializeFeatures; + + (client as any).initializeFeatures = (...args: any) => { + const capabilities = (client as any)._capabilities as lsp.ServerCapabilities; + + // NOTE: these are legacy config for Language Server and hence the VSCode options + // for Vanilla TS; TS Plugin won't use these options but will rather the same + // Vanilla TS options. + // + // if (!config.codeActions.enabled) { + // capabilities.codeActionProvider = undefined; + // } + // if (!config.codeLens.enabled) { + // capabilities.codeLensProvider = undefined; + // } + // if ( + // !config.updateImportsOnFileMove.enabled && + // capabilities.workspace?.fileOperations?.willRename + // ) { + // capabilities.workspace.fileOperations.willRename = undefined; + // } + + return initializeFeatures.call(client, ...args); + }; } -/////////////////////////////////////////////////////////////////////////////// -// Utilities - -function findLanguageServer(workspaceDir: string): string | null { - let userLibraryPath = workspace.getConfiguration().get('glint.libraryPath', '.'); +function findLanguageServer( + workspaceDir: string, + outputChannel: vscode.OutputChannel, +): string | null { + let userLibraryPath = vscode.workspace.getConfiguration().get('glint.libraryPath', '.'); let resolutionDir = path.resolve(workspaceDir, userLibraryPath); let require = createRequire(path.join(resolutionDir, 'package.json')); try { @@ -172,25 +205,3 @@ function findLanguageServer(workspaceDir: string): string | null { return null; } } - -// Automatically restart running servers when config files in the workspace change -function createConfigWatcher(): Disposable { - let configWatcher = workspace.createFileSystemWatcher('**/{ts,js}config*.json'); - - configWatcher.onDidCreate(restartClients); - configWatcher.onDidChange(restartClients); - configWatcher.onDidDelete(restartClients); - - return configWatcher; -} - -// Loads the TypeScript and JavaScript formating options from the workspace and subsets them to -// pass to the language server. -function getOptions(config: WorkspaceConfiguration, key: string): object { - const formatOptions = config.get(key); - if (formatOptions) { - return formatOptions; - } - - return {}; -} diff --git a/packages/vscode/src/hybrid-mode.ts b/packages/vscode/src/hybrid-mode.ts new file mode 100644 index 000000000..85426589d --- /dev/null +++ b/packages/vscode/src/hybrid-mode.ts @@ -0,0 +1,175 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + computed, + executeCommand, + useAllExtensions, + useVscodeContext, + watchEffect, +} from 'reactive-vscode'; +import * as semver from 'semver'; +import * as vscode from 'vscode'; +import { incompatibleExtensions, unknownExtensions } from './compatibility'; +import { config } from './config'; + +const extensions = useAllExtensions(); + +export const enabledHybridMode = computed(() => { + if (config.server.hybridMode === 'typeScriptPluginOnly') { + return false; + } else if (config.server.hybridMode === 'auto') { + if (incompatibleExtensions.value.length || unknownExtensions.value.length) { + return false; + } else if ( + (vscodeTsdkVersion.value && !semver.gte(vscodeTsdkVersion.value, '5.3.0')) || + (workspaceTsdkVersion.value && !semver.gte(workspaceTsdkVersion.value, '5.3.0')) + ) { + return false; + } + return true; + } + return config.server.hybridMode; +}); + +export const enabledTypeScriptPlugin = computed(() => { + return enabledHybridMode.value || config.server.hybridMode === 'typeScriptPluginOnly'; +}); + +const vscodeTsdkVersion = computed(() => { + const nightly = extensions.value.find(({ id }) => id === 'ms-vscode.vscode-typescript-next'); + if (nightly) { + const libPath = path.join( + nightly.extensionPath.replace(/\\/g, '/'), + 'node_modules/typescript/lib', + ); + return getTsVersion(libPath); + } + + if (vscode.env.appRoot) { + const libPath = path.join( + vscode.env.appRoot.replace(/\\/g, '/'), + 'extensions/node_modules/typescript/lib', + ); + return getTsVersion(libPath); + } +}); + +const workspaceTsdkVersion = computed(() => { + const libPath = vscode.workspace + .getConfiguration('typescript') + .get('tsdk') + ?.replace(/\\/g, '/'); + if (libPath) { + return getTsVersion(libPath); + } +}); + +export function useHybridModeTips(): void { + useVscodeContext('glintHybridMode', enabledHybridMode); + + watchEffect(() => { + if (config.server.hybridMode === 'auto') { + if (incompatibleExtensions.value.length || unknownExtensions.value.length) { + vscode.window + .showInformationMessage( + `Hybrid Mode is disabled automatically because there is a potentially incompatible ${[ + ...incompatibleExtensions.value, + ...unknownExtensions.value, + ].join(', ')} TypeScript plugin installed.`, + 'Open Settings', + 'Report a false positive', + ) + .then((value) => { + if (value === 'Open Settings') { + executeCommand('workbench.action.openSettings', 'glint.server.hybridMode'); + } else if (value == 'Report a false positive') { + vscode.env.openExternal( + vscode.Uri.parse('https://github.com/vuejs/language-tools/pull/4206'), + ); + } + }); + } else if ( + (vscodeTsdkVersion.value && !semver.gte(vscodeTsdkVersion.value, '5.3.0')) || + (workspaceTsdkVersion.value && !semver.gte(workspaceTsdkVersion.value, '5.3.0')) + ) { + let msg = `Hybrid Mode is disabled automatically because TSDK >= 5.3.0 is required (VSCode TSDK: ${vscodeTsdkVersion.value}`; + if (workspaceTsdkVersion.value) { + msg += `, Workspace TSDK: ${workspaceTsdkVersion.value}`; + } + msg += `).`; + vscode.window.showInformationMessage(msg, 'Open Settings').then((value) => { + if (value === 'Open Settings') { + executeCommand('workbench.action.openSettings', 'glint.server.hybridMode'); + } + }); + } + } else if (config.server.hybridMode && incompatibleExtensions.value.length) { + vscode.window + .showWarningMessage( + `You have explicitly enabled Hybrid Mode, but you have installed known incompatible extensions: ${incompatibleExtensions.value.join( + ', ', + )}. You may want to change glint.server.hybridMode to "auto" to avoid compatibility issues.`, + 'Open Settings', + 'Report a false positive', + ) + .then((value) => { + if (value === 'Open Settings') { + executeCommand('workbench.action.openSettings', 'glint.server.hybridMode'); + } else if (value == 'Report a false positive') { + vscode.env.openExternal( + vscode.Uri.parse('https://github.com/vuejs/language-tools/pull/4206'), + ); + } + }); + } + }); +} + +export function useHybridModeStatusItem(): void { + const item = vscode.languages.createLanguageStatusItem( + 'vue-hybrid-mode', + config.server.includeLanguages, + ); + + item.text = 'Hybrid Mode'; + item.detail = + (enabledHybridMode.value ? 'Enabled' : 'Disabled') + + (config.server.hybridMode === 'auto' ? ' (Auto)' : ''); + item.command = { + title: 'Open Setting', + command: 'workbench.action.openSettings', + arguments: ['glint.server.hybridMode'], + }; + + if (!enabledHybridMode.value) { + item.severity = vscode.LanguageStatusSeverity.Warning; + } +} + +function getTsVersion(libPath: string): string | undefined { + try { + const p = libPath.toString().split('/'); + const p2 = p.slice(0, -1); + const modulePath = p2.join('/'); + const filePath = modulePath + '/package.json'; + const contents = fs.readFileSync(filePath, 'utf-8'); + + if (contents === undefined) { + return; + } + + let desc: any = null; + try { + desc = JSON.parse(contents); + } catch (err) { + return; + } + if (!desc || !desc.version) { + return; + } + + return desc.version as string; + } catch { + // Ignore + } +} diff --git a/packages/vscode/src/language-client.ts b/packages/vscode/src/language-client.ts new file mode 100644 index 000000000..09dac39e7 --- /dev/null +++ b/packages/vscode/src/language-client.ts @@ -0,0 +1,217 @@ +import * as lsp from '@volar/vscode'; +import { + executeCommand, + nextTick, + useActiveTextEditor, + useCommand, + useOutputChannel, + useVisibleTextEditors, + useVscodeContext, + watch, +} from 'reactive-vscode'; +import * as vscode from 'vscode'; +import { config } from './config'; +// import { activate as activateDoctor } from './features/doctor'; +// import { activate as activateNameCasing } from './features/nameCasing'; +// import { activate as activateSplitEditors } from './features/splitEditors'; +import { + enabledHybridMode, + enabledTypeScriptPlugin, + useHybridModeStatusItem, + useHybridModeTips, +} from './hybrid-mode'; +import { NullLiteral } from 'typescript'; +// import { useInsidersStatusItem } from './insiders'; + +let client: lsp.BaseLanguageClient; + +type GlintInitializationOptions = any; + +type CreateLanguageClient = ( + id: string, + name: string, + langs: lsp.DocumentSelector, + initOptions: GlintInitializationOptions, + port: number, + outputChannel: vscode.OutputChannel, +) => lsp.BaseLanguageClient | null; + +/** + * A workspace consists of 1+ open folders. This function will watch one of + * those folders to see if file has been opened with a known language ID + * (e.g. 'glimmer-ts', 'handlebars', etc.). When that happens we + * invoke the `createLanguageClient` function to create a language server + * client. + */ +export function watchWorkspaceFolderForLanguageClientActivation( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder, + createLanguageClient: CreateLanguageClient, +): () => void { + // for each + const activeTextEditor = useActiveTextEditor(); + const visibleTextEditors = useVisibleTextEditors(); + + let clientPromise: Promise | null = null; + + useHybridModeTips(); + + const { stop } = watch( + activeTextEditor, + () => { + if ( + visibleTextEditors.value.some((editor) => + config.server.includeLanguages.includes(editor.document.languageId), + ) + ) { + if (!clientPromise) { + clientPromise = activateLanguageClient(context, createLanguageClient, workspaceFolder); + + // disable this watcher so that we don't keep activation language client + nextTick(() => { + stop(); + }); + } + } + }, + { + immediate: true, // causes above callback to be run immediately (i.e. not lazily) + }, + ); + + return () => { + // Stop the watcher. + stop(); + + // Tear down the client if it exists. + if (clientPromise) { + clientPromise.then((client) => client?.stop()); + clientPromise = null; + } + }; +} + +let hasInitialized = false; + +async function activateLanguageClient( + context: vscode.ExtensionContext, + createLanguageClient: CreateLanguageClient, + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + // This is not used now but can be used to conditionally reveal commands that should + // only be visible when glint has been activated. + useVscodeContext('glint.activated', true); + + const outputChannel = useOutputChannel('Glint Language Server'); + const documentSelectors = config.server.includeLanguages.map((language) => ({ + language, + scheme: 'file', + pattern: `${workspaceFolder.uri.fsPath}/**/*`, + })); + + // This might return null if there is no... + const client = createLanguageClient( + 'glint', + 'Glint', + documentSelectors, + await getInitializationOptions(context, enabledHybridMode.value), + 6009, + outputChannel, + ); + + if (!client) { + return null; + } + + if (!hasInitialized) { + watch([enabledHybridMode, enabledTypeScriptPlugin], (newValues, oldValues) => { + if (newValues[0] !== oldValues[0]) { + requestReloadVscode( + `Please reload VSCode to ${newValues[0] ? 'enable' : 'disable'} Hybrid Mode.`, + ); + } else if (newValues[1] !== oldValues[1]) { + requestReloadVscode( + `Please reload VSCode to ${newValues[1] ? 'enable' : 'disable'} Glint TypeScript Plugin.`, + ); + } + }); + + watch( + () => config.server.includeLanguages, + () => { + if (enabledHybridMode.value) { + requestReloadVscode('Please reload VSCode to apply the new language settings.'); + } + }, + ); + + // NOTE: this will fire when `glint.libraryPath` is changed, among others + // (leaving this note here so I don't re-implement the `affectsConfiguration` logic we used + // to have when changing this config value) + watch( + () => config.server, + () => { + if (!enabledHybridMode.value) { + executeCommand('glint.restart-language-server', false); + } + }, + { deep: true }, + ); + + useCommand('glint.restart-language-server', async (restartTsServer = true) => { + if (restartTsServer) { + await executeCommand('typescript.restartTsServer'); + } + await client.stop(); + outputChannel.clear(); + client.clientOptions.initializationOptions = await getInitializationOptions( + context, + enabledHybridMode.value, + ); + await client.start(); + }); + + // activateDoctor(client); + // activateNameCasing(client, selectors); + // activateSplitEditors(client); + + lsp.activateAutoInsertion(documentSelectors, client); + lsp.activateDocumentDropEdit(documentSelectors, client); + lsp.activateWriteVirtualFiles('glint.action.writeVirtualFiles', client); + + if (!enabledHybridMode.value) { + lsp.activateTsConfigStatusItem(documentSelectors, 'glint.tsconfig', client); + lsp.activateTsVersionStatusItem( + documentSelectors, + 'glint.tsversion', + context, + (text) => 'TS ' + text, + ); + lsp.activateFindFileReferences('glint.findAllFileReferences', client); + } + + useHybridModeStatusItem(); + // useInsidersStatusItem(context); + } + + hasInitialized = true; + + async function requestReloadVscode(msg: string): Promise { + const reload = await vscode.window.showInformationMessage(msg, 'Reload Window'); + if (reload) { + executeCommand('workbench.action.reloadWindow'); + } + } + + return client; +} + +async function getInitializationOptions( + context: vscode.ExtensionContext, + hybridMode: boolean, +): Promise { + return { + typescript: { tsdk: (await lsp.getTsdk(context))!.tsdk }, + glint: { hybridMode }, + }; +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json index c258c5e90..f8b0fcb78 100644 --- a/packages/vscode/tsconfig.json +++ b/packages/vscode/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.compileroptions.json", "compilerOptions": { - "module": "Node16", - "outDir": "lib" + "outDir": "lib", + "module": "CommonJS", + "moduleResolution": "Node" }, "include": ["src", "__tests__"], "references": [{ "path": "../core" }] diff --git a/yarn.lock b/yarn.lock index 5470821b6..7ff3f66e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2684,6 +2684,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.24.tgz#58601079e11784d20f82d0585865bb42305c4df3" integrity sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ== +"@reactive-vscode/reactivity@0.2.7-beta.1": + version "0.2.7-beta.1" + resolved "https://registry.yarnpkg.com/@reactive-vscode/reactivity/-/reactivity-0.2.7-beta.1.tgz#a387bf07a420a1b592e4e87353a3159cb6d7994f" + integrity sha512-ma7DOAFSXhB7h2HLiDrus4as5So1rS3u4zNHKoKCRRh4cBxxnQDFZUUQNafsssM15ggxtf8km5IXyW81ZCWnsg== + "@release-it-plugins/lerna-changelog@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@release-it-plugins/lerna-changelog/-/lerna-changelog-5.0.0.tgz#cbbbc47fd40ef212f2d7c0e24867d3fcea4cd232" @@ -12242,6 +12247,13 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +reactive-vscode@0.2.7-beta.1: + version "0.2.7-beta.1" + resolved "https://registry.yarnpkg.com/reactive-vscode/-/reactive-vscode-0.2.7-beta.1.tgz#6d92f14e9d28892391095c295864bb91db56603b" + integrity sha512-7D9sFMA4S6owUNdiuiuiJLOzAuy3y4ZgcNW3bj58n1hME0U8repz3hhYep6VCLbAf89F+TF/bB7u+WNGHndLGg== + dependencies: + "@reactive-vscode/reactivity" "0.2.7-beta.1" + read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" @@ -12874,6 +12886,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +scule@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/scule/-/scule-1.3.0.tgz#6efbd22fd0bb801bdcc585c89266a7d2daa8fbd3" + integrity sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g== + semver-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-4.0.0.tgz#3afcf5ed6d62259f5c72d0d5d50dffbdc9680df5" @@ -14521,6 +14538,15 @@ volar-service-typescript@volar-2.4: vscode-nls "^5.2.0" vscode-uri "^3.0.8" +vscode-ext-gen@^0.5.0: + version "0.5.5" + resolved "https://registry.yarnpkg.com/vscode-ext-gen/-/vscode-ext-gen-0.5.5.tgz#e4f5bb0b237c839c31170da41b6d117afa7dd53e" + integrity sha512-wTwcPvGF9xZ0fN7sPgdUPESH+Aw20Tk1vvgbYnKzWT4sFOqRP54qcpxjPUMdDoDGfiVIoXW87TNxn0yKXq3djw== + dependencies: + cac "^6.7.14" + scule "^1.3.0" + yargs "^17.7.2" + vscode-jsonrpc@8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" @@ -14955,7 +14981,7 @@ yargs@16.2.0, yargs@^16.0.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.1.0, yargs@^17.5.1: +yargs@^17.1.0, yargs@^17.5.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==