From 531214ea6bb083019b3d23e6133a31c3aa4dcd81 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:00:57 +0800 Subject: [PATCH 01/17] test(vscode): guard extension import boundaries --- editors/vscode/test/import-boundary.test.ts | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 editors/vscode/test/import-boundary.test.ts diff --git a/editors/vscode/test/import-boundary.test.ts b/editors/vscode/test/import-boundary.test.ts new file mode 100644 index 00000000..9d6a9f3f --- /dev/null +++ b/editors/vscode/test/import-boundary.test.ts @@ -0,0 +1,89 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; + +const repoDir = path.resolve(__dirname, '..'); +const srcDir = path.join(repoDir, 'src'); + +const nodeImportPattern = + /\b(?:from\s+['"](?:node:|fs(?:\/promises)?|path|child_process|vscode-languageclient\/node)|import\s*\(\s*['"](?:node:|fs(?:\/promises)?|path|child_process|vscode-languageclient\/node))/; +const browserOnlyClientPattern = + /\b(?:from\s+['"]vscode-languageclient\/browser|import\s*\(\s*['"]vscode-languageclient\/browser)/; +const nodeServerLaunchPattern = + /\b(?:from\s+['"][^'"]*node\/serverLaunch|import\s*\(\s*['"][^'"]*node\/serverLaunch)/; + +describe('extension import boundaries', () => { + it('keeps common code runtime-neutral', () => { + assertBoundary('common', (filePath, text) => { + assertNoMatch(filePath, text, nodeImportPattern, 'common code must not import Node APIs'); + assertNoMatch( + filePath, + text, + browserOnlyClientPattern, + 'common code must not import browser-only language client APIs', + ); + assertNoMatch( + filePath, + text, + nodeServerLaunchPattern, + 'common code must not import node/serverLaunch', + ); + }); + }); + + it('keeps browser code away from Node-only modules', () => { + assertBoundary('browser', (filePath, text) => { + assertNoMatch(filePath, text, nodeImportPattern, 'browser code must not import Node APIs'); + assertNoMatch( + filePath, + text, + nodeServerLaunchPattern, + 'browser code must not import node/serverLaunch', + ); + }); + }); +}); + +function assertBoundary( + subdir: 'browser' | 'common', + assertion: (filePath: string, text: string) => void, +): void { + const dir = path.join(srcDir, subdir); + if (!fs.existsSync(dir)) { + return; + } + + for (const filePath of sourceFiles(dir)) { + assertion(filePath, fs.readFileSync(filePath, 'utf8')); + } +} + +function sourceFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...sourceFiles(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(entryPath); + } + } + + return files; +} + +function assertNoMatch( + filePath: string, + text: string, + pattern: RegExp, + message: string, +): void { + assert.equal( + pattern.test(text), + false, + `${message}: ${path.relative(repoDir, filePath)}`, + ); +} From 8072e2c6878a6c980445c8ca4b088b2c8b9cff3a Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:02:42 +0800 Subject: [PATCH 02/17] refactor(vscode): extract server launch plumbing --- editors/vscode/src/extension.ts | 176 ++---------------------- editors/vscode/src/node/serverLaunch.ts | 176 ++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 168 deletions(-) create mode 100644 editors/vscode/src/node/serverLaunch.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 8298d194..83837fe7 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -11,7 +11,6 @@ import { type ServerOptions, } from 'vscode-languageclient/node'; -import { getBundledServerPath, getPlatformFolder } from './platform'; import { registerDiagnosticActions } from './diagnosticActions'; import { profileDiagnosticsCommand, registerProfilingCommand } from './profiling'; import { serverInitializationOptions } from './initializationOptions'; @@ -32,6 +31,11 @@ import { VideStatusController, } from './videStatus'; import type { ServerStatus } from './status'; +import { + createServerEnv, + readConfiguration, + resolveServerLaunch, +} from './node/serverLaunch'; let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel | undefined; @@ -289,107 +293,6 @@ function registerProjectStatusNotifications(languageClient: LanguageClient): voi }); } -interface ServerConfiguration { - command: string | undefined; - args: string[]; - additionalArgs: string[]; - cwd: string | undefined; - trace: 'off' | 'messages' | 'verbose'; -} - -interface ServerLaunch { - command: string; - args: string[]; - additionalArgs: string[]; - cwd: string; -} - -function asStringArray(value: unknown): string[] | undefined { - return Array.isArray(value) && value.every((item) => typeof item === 'string') - ? value - : undefined; -} - -function getServerPath(context: vscode.ExtensionContext): string | undefined { - const platform = process.platform; - const arch = process.arch; - const platformFolder = getPlatformFolder(platform, arch); - if (!platformFolder) { - log( - `[ERROR] Unsupported platform-architecture combination: ${platform}-${arch}`, - ); - return undefined; - } - - const bundledPath = getBundledServerPath(context.extensionPath, platform, arch); - if (!bundledPath) { - log(`[ERROR] Unsupported platform-architecture combination: ${platformFolder}`); - return undefined; - } - - log(`[INFO] Looking for bundled server at: ${bundledPath}`); - - if (fs.existsSync(bundledPath)) { - if (platform !== 'win32') { - try { - fs.accessSync(bundledPath, fs.constants.X_OK); - log('[INFO] Bundled server binary is executable'); - return bundledPath; - } catch { - log( - '[WARN] Bundled server binary exists but is not executable, attempting to fix...', - ); - try { - fs.chmodSync(bundledPath, 0o755); - log('[INFO] Made bundled server binary executable'); - return bundledPath; - } catch (error) { - log( - `[ERROR] Failed to make bundled binary executable: ${(error as Error).message}`, - ); - } - } - } else { - log('[INFO] Found bundled server binary'); - return bundledPath; - } - } else { - log(`[INFO] Bundled server binary not found at: ${bundledPath}`); - } - - return undefined; -} - -function readConfiguration(): ServerConfiguration { - const config = vscode.workspace.getConfiguration('vide'); - const command = config.get('server.command'); - const args = asStringArray(config.get('server.args')); - const additionalArgs = asStringArray(config.get('server.additionalArgs')); - const cwd = config.get('server.cwd'); - const trace = config.get<'off' | 'messages' | 'verbose'>('trace.server') ?? 'off'; - - if (!args || !additionalArgs) { - vscode.window.showErrorMessage( - vscode.l10n.t('vide server arguments settings must be arrays of strings.'), - ); - return { - command: undefined, - args: [], - additionalArgs: [], - cwd: undefined, - trace, - }; - } - - return { - command: typeof command === 'string' && command.length > 0 ? command : undefined, - args, - additionalArgs, - cwd: typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined, - trace, - }; -} - function includeDeclarationInReferences(document: vscode.TextDocument): boolean { return ( vscode.workspace @@ -529,69 +432,6 @@ async function provideExpandedRenameEdits( return await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit as never, token); } -function resolveWorkingDirectory( - context: vscode.ExtensionContext, - configuredCwd: string | undefined, -): string { - if (configuredCwd) { - log(`[INFO] Using configured working directory: ${configuredCwd}`); - return configuredCwd; - } - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder) { - const workspacePath = workspaceFolder.uri.fsPath; - log(`[INFO] Using workspace folder as working directory: ${workspacePath}`); - return workspacePath; - } - - log(`[INFO] Using extension path as working directory: ${context.extensionPath}`); - return context.extensionPath; -} - -function resolveServerLaunch( - context: vscode.ExtensionContext, - config: ServerConfiguration, -): ServerLaunch { - const cwd = resolveWorkingDirectory(context, config.cwd); - - let serverCommand = config.command; - if (!serverCommand) { - serverCommand = getServerPath(context); - if (!serverCommand) { - const message = vscode.l10n.t( - 'Bundled Vide Language Server binary not found. Install the VSIX that matches your platform or configure "vide.server.command".', - ); - log(`[ERROR] ${message}`); - throw new Error(message); - } - } else { - log(`[INFO] Using custom server command: ${serverCommand}`); - } - - log(`[INFO] Server command: ${serverCommand}`); - log(`[INFO] Server args: ${JSON.stringify([...config.args, ...config.additionalArgs])}`); - log(`[INFO] Working directory: ${cwd}`); - - return { - command: serverCommand, - args: config.args, - additionalArgs: config.additionalArgs, - cwd, - }; -} - -function createServerEnv( - logLevel: 'info' | 'debug' = 'info', - backtrace: '1' | 'full' = '1', -): NodeJS.ProcessEnv { - return { - ...process.env, - RUST_BACKTRACE: backtrace, - RUST_LOG: logLevel, - }; -} - type ProjectConfigTarget = { folderName: string; configPath: string; @@ -762,7 +602,7 @@ async function createClient(context: vscode.ExtensionContext): Promise { async function showServerVersion(context: vscode.ExtensionContext): Promise { try { const config = readConfiguration(); - const launch = resolveServerLaunch(context, config); + const launch = resolveServerLaunch(context, config, log); const versionArgs = [...launch.args, '--version']; log(`[INFO] Checking server version: ${launch.command} ${versionArgs.join(' ')}`); const { stdout, stderr } = await execFileAsync(launch.command, versionArgs, { @@ -1056,7 +896,7 @@ export async function activate(context: vscode.ExtensionContext): Promise if (profileTraceEnabled) { context.subscriptions.push( registerProfilingCommand(context, { - resolveLaunch: () => resolveServerLaunch(context, readConfiguration()), + resolveLaunch: () => resolveServerLaunch(context, readConfiguration(), log), createEnv: createServerEnv, }), ); diff --git a/editors/vscode/src/node/serverLaunch.ts b/editors/vscode/src/node/serverLaunch.ts new file mode 100644 index 00000000..f4a18e6b --- /dev/null +++ b/editors/vscode/src/node/serverLaunch.ts @@ -0,0 +1,176 @@ +import * as fs from 'node:fs'; + +import * as vscode from 'vscode'; + +import { getBundledServerPath, getPlatformFolder } from '../platform'; + +type Logger = (message: string) => void; + +export interface ServerConfiguration { + command: string | undefined; + args: string[]; + additionalArgs: string[]; + cwd: string | undefined; + trace: 'off' | 'messages' | 'verbose'; +} + +export interface ServerLaunch { + command: string; + args: string[]; + additionalArgs: string[]; + cwd: string; +} + +function asStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === 'string') + ? value + : undefined; +} + +export function readConfiguration(): ServerConfiguration { + const config = vscode.workspace.getConfiguration('vide'); + const command = config.get('server.command'); + const args = asStringArray(config.get('server.args')); + const additionalArgs = asStringArray(config.get('server.additionalArgs')); + const cwd = config.get('server.cwd'); + const trace = config.get<'off' | 'messages' | 'verbose'>('trace.server') ?? 'off'; + + if (!args || !additionalArgs) { + vscode.window.showErrorMessage( + vscode.l10n.t('vide server arguments settings must be arrays of strings.'), + ); + return { + command: undefined, + args: [], + additionalArgs: [], + cwd: undefined, + trace, + }; + } + + return { + command: typeof command === 'string' && command.length > 0 ? command : undefined, + args, + additionalArgs, + cwd: typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined, + trace, + }; +} + +export function getServerPath( + context: vscode.ExtensionContext, + log: Logger, +): string | undefined { + const platform = process.platform; + const arch = process.arch; + const platformFolder = getPlatformFolder(platform, arch); + if (!platformFolder) { + log( + `[ERROR] Unsupported platform-architecture combination: ${platform}-${arch}`, + ); + return undefined; + } + + const bundledPath = getBundledServerPath(context.extensionPath, platform, arch); + if (!bundledPath) { + log(`[ERROR] Unsupported platform-architecture combination: ${platformFolder}`); + return undefined; + } + + log(`[INFO] Looking for bundled server at: ${bundledPath}`); + + if (fs.existsSync(bundledPath)) { + if (platform !== 'win32') { + try { + fs.accessSync(bundledPath, fs.constants.X_OK); + log('[INFO] Bundled server binary is executable'); + return bundledPath; + } catch { + log( + '[WARN] Bundled server binary exists but is not executable, attempting to fix...', + ); + try { + fs.chmodSync(bundledPath, 0o755); + log('[INFO] Made bundled server binary executable'); + return bundledPath; + } catch (error) { + log( + `[ERROR] Failed to make bundled binary executable: ${(error as Error).message}`, + ); + } + } + } else { + log('[INFO] Found bundled server binary'); + return bundledPath; + } + } else { + log(`[INFO] Bundled server binary not found at: ${bundledPath}`); + } + + return undefined; +} + +export function resolveWorkingDirectory( + context: vscode.ExtensionContext, + configuredCwd: string | undefined, + log: Logger, +): string { + if (configuredCwd) { + log(`[INFO] Using configured working directory: ${configuredCwd}`); + return configuredCwd; + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + const workspacePath = workspaceFolder.uri.fsPath; + log(`[INFO] Using workspace folder as working directory: ${workspacePath}`); + return workspacePath; + } + + log(`[INFO] Using extension path as working directory: ${context.extensionPath}`); + return context.extensionPath; +} + +export function resolveServerLaunch( + context: vscode.ExtensionContext, + config: ServerConfiguration, + log: Logger, +): ServerLaunch { + const cwd = resolveWorkingDirectory(context, config.cwd, log); + + let serverCommand = config.command; + if (!serverCommand) { + serverCommand = getServerPath(context, log); + if (!serverCommand) { + const message = vscode.l10n.t( + 'Bundled Vide Language Server binary not found. Install the VSIX that matches your platform or configure "vide.server.command".', + ); + log(`[ERROR] ${message}`); + throw new Error(message); + } + } else { + log(`[INFO] Using custom server command: ${serverCommand}`); + } + + log(`[INFO] Server command: ${serverCommand}`); + log(`[INFO] Server args: ${JSON.stringify([...config.args, ...config.additionalArgs])}`); + log(`[INFO] Working directory: ${cwd}`); + + return { + command: serverCommand, + args: config.args, + additionalArgs: config.additionalArgs, + cwd, + }; +} + +export function createServerEnv( + logLevel: 'info' | 'debug' = 'info', + backtrace: '1' | 'full' = '1', +): NodeJS.ProcessEnv { + return { + ...process.env, + RUST_BACKTRACE: backtrace, + RUST_LOG: logLevel, + }; +} From d1102b00ab120eaa96addbec32ccdebdee797de5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:04:51 +0800 Subject: [PATCH 03/17] refactor(vscode): split client options and node rename --- editors/vscode/src/common/clientOptions.ts | 53 ++++++ editors/vscode/src/extension.ts | 171 +------------------- editors/vscode/src/node/renameMiddleware.ts | 146 +++++++++++++++++ 3 files changed, 205 insertions(+), 165 deletions(-) create mode 100644 editors/vscode/src/common/clientOptions.ts create mode 100644 editors/vscode/src/node/renameMiddleware.ts diff --git a/editors/vscode/src/common/clientOptions.ts b/editors/vscode/src/common/clientOptions.ts new file mode 100644 index 00000000..c46f94ad --- /dev/null +++ b/editors/vscode/src/common/clientOptions.ts @@ -0,0 +1,53 @@ +import * as vscode from 'vscode'; +import { + type LanguageClientOptions, + RevealOutputChannelOn, +} from 'vscode-languageclient'; + +import { serverInitializationOptions } from '../initializationOptions'; + +type ClientMiddleware = NonNullable; + +export type NodeClientOptionsParams = { + outputChannel: vscode.OutputChannel; + trace: 'off' | 'messages' | 'verbose'; + provideRenameEdits: NonNullable; +}; + +export const fileDocumentSelector: LanguageClientOptions['documentSelector'] = [ + { scheme: 'file', language: 'verilog' }, + { scheme: 'file', language: 'systemverilog' }, +]; + +export function createNodeClientOptions({ + outputChannel, + trace, + provideRenameEdits, +}: NodeClientOptionsParams): LanguageClientOptions { + return { + documentSelector: fileDocumentSelector, + synchronize: { + configurationSection: ['vide'], + }, + outputChannel, + traceOutputChannel: outputChannel, + revealOutputChannelOn: RevealOutputChannelOn.Never, + initializationOptions: serverInitializationOptions(vscode.workspace.getConfiguration('vide')), + middleware: { + provideReferences: async (document, position, options, token, next) => { + options.includeDeclaration = includeDeclarationInReferences(document); + return await next(document, position, options, token); + }, + provideRenameEdits, + }, + ...(trace !== 'off' && { trace }), + }; +} + +function includeDeclarationInReferences(document: vscode.TextDocument): boolean { + return ( + vscode.workspace + .getConfiguration('vide', document) + .get('references.includeDeclaration') ?? true + ); +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 83837fe7..83044a96 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -6,14 +6,11 @@ import { promisify } from 'node:util'; import * as vscode from 'vscode'; import { LanguageClient, - type LanguageClientOptions, - RevealOutputChannelOn, type ServerOptions, } from 'vscode-languageclient/node'; import { registerDiagnosticActions } from './diagnosticActions'; import { profileDiagnosticsCommand, registerProfilingCommand } from './profiling'; -import { serverInitializationOptions } from './initializationOptions'; import { DEFAULT_PROJECT_CONFIG_TEXT, PROJECT_CONFIG_FILE_NAMES, @@ -31,6 +28,8 @@ import { VideStatusController, } from './videStatus'; import type { ServerStatus } from './status'; +import { createNodeClientOptions } from './common/clientOptions'; +import { createProvideExpandedRenameEdits } from './node/renameMiddleware'; import { createServerEnv, readConfiguration, @@ -49,9 +48,6 @@ const showServerVersionCommand = 'vide.showServerVersion'; const showQiheOutputCommand = 'vide.showQiheOutput'; const runQiheAnalysisCommand = 'vide.runQiheAnalysis'; const runQiheAnalysisRequest = 'vide.server.runQiheAnalysis'; -const renameExpansionInfoRequest = 'vide.server.renameExpansionInfo'; -const expandedRenameRequest = 'vide.server.expandedRename'; -const renameConflictInfoRequest = 'vide.server.renameConflictInfo'; const qiheStatusNotification = 'vide/qiheStatus'; const qiheLogNotification = 'vide/qiheLog'; const qiheAnalysisIcon = '$(beaker)'; @@ -293,145 +289,6 @@ function registerProjectStatusNotifications(languageClient: LanguageClient): voi }); } -function includeDeclarationInReferences(document: vscode.TextDocument): boolean { - return ( - vscode.workspace - .getConfiguration('vide', document) - .get('references.includeDeclaration') ?? true - ); -} - -type RenameExpansionInfo = { - additionalSymbols: number; -}; - -type RenameConflictInfo = { - conflicts: number; -}; - -function emptyRenameEdit(): vscode.WorkspaceEdit { - return new vscode.WorkspaceEdit(); -} - -async function confirmRenameCollision( - textDocumentPosition: unknown, - newName: string, - recursive: boolean, - token: vscode.CancellationToken, -): Promise { - const languageClient = client; - if (!languageClient) { - return true; - } - - const info = await languageClient.sendRequest( - 'workspace/executeCommand', - { - command: renameConflictInfoRequest, - arguments: [{ textDocumentPosition, newName, recursive }], - }, - token, - ); - - if (info.conflicts === 0) { - return true; - } - - const continueAction = vscode.l10n.t('Continue Rename'); - const cancelAction = vscode.l10n.t('Cancel'); - const selected = await vscode.window.showWarningMessage( - vscode.l10n.t( - 'Renaming to "{0}" may collide with {1} existing symbol(s).', - newName, - info.conflicts, - ), - continueAction, - cancelAction, - ); - return selected === continueAction; -} - -async function provideExpandedRenameEdits( - document: vscode.TextDocument, - position: vscode.Position, - newName: string, - token: vscode.CancellationToken, - next: ( - document: vscode.TextDocument, - position: vscode.Position, - newName: string, - token: vscode.CancellationToken, - ) => vscode.ProviderResult, -): Promise { - const languageClient = client; - if (!languageClient) { - return await next(document, position, newName, token); - } - - const textDocumentPosition = { - textDocument: languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), - position: languageClient.code2ProtocolConverter.asPosition(position), - }; - const standardRename = async (): Promise => { - if (!(await confirmRenameCollision(textDocumentPosition, newName, false, token))) { - return emptyRenameEdit(); - } - return await next(document, position, newName, token); - }; - - let info: RenameExpansionInfo | undefined; - try { - info = await languageClient.sendRequest( - 'workspace/executeCommand', - { - command: renameExpansionInfoRequest, - arguments: [{ textDocumentPosition }], - }, - token, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(`[WARN] Falling back to standard rename: ${message}`); - } - - if (!info || info.additionalSymbols === 0) { - return await standardRename(); - } - - const recursiveAction = vscode.l10n.t('Rename Connected Ports/Signals'); - const localAction = vscode.l10n.t('Only This Symbol'); - const selected = await vscode.window.showInformationMessage( - vscode.l10n.t( - 'Rename {0} connected port/signal symbol(s) as well?', - info.additionalSymbols, - ), - recursiveAction, - localAction, - ); - - if (selected === localAction) { - return await standardRename(); - } - - if (selected !== recursiveAction) { - return emptyRenameEdit(); - } - - if (!(await confirmRenameCollision(textDocumentPosition, newName, true, token))) { - return emptyRenameEdit(); - } - - const edit = await languageClient.sendRequest( - 'workspace/executeCommand', - { - command: expandedRenameRequest, - arguments: [{ textDocumentPosition, newName }], - }, - token, - ); - return await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit as never, token); -} - type ProjectConfigTarget = { folderName: string; configPath: string; @@ -625,27 +482,11 @@ async function createClient(context: vscode.ExtensionContext): Promise { - options.includeDeclaration = includeDeclarationInReferences(document); - return await next(document, position, options, token); - }, - provideRenameEdits: provideExpandedRenameEdits, - }, - ...(config.trace !== 'off' && { trace: config.trace }), - }; + trace: config.trace, + provideRenameEdits: createProvideExpandedRenameEdits(() => client, log), + }); log('[INFO] Creating LanguageClient instance...'); return new LanguageClient( diff --git a/editors/vscode/src/node/renameMiddleware.ts b/editors/vscode/src/node/renameMiddleware.ts new file mode 100644 index 00000000..1eae3588 --- /dev/null +++ b/editors/vscode/src/node/renameMiddleware.ts @@ -0,0 +1,146 @@ +import * as vscode from 'vscode'; +import type { LanguageClient, LanguageClientOptions } from 'vscode-languageclient/node'; + +type Logger = (message: string) => void; +type ClientMiddleware = NonNullable; + +const renameExpansionInfoRequest = 'vide.server.renameExpansionInfo'; +const expandedRenameRequest = 'vide.server.expandedRename'; +const renameConflictInfoRequest = 'vide.server.renameConflictInfo'; + +type RenameExpansionInfo = { + additionalSymbols: number; +}; + +type RenameConflictInfo = { + conflicts: number; +}; + +export function createProvideExpandedRenameEdits( + getClient: () => LanguageClient | undefined, + log: Logger, +): NonNullable { + return async (document, position, newName, token, next) => { + const languageClient = getClient(); + if (!languageClient) { + return await next(document, position, newName, token); + } + + const textDocumentPosition = { + textDocument: languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: languageClient.code2ProtocolConverter.asPosition(position), + }; + const standardRename = async (): Promise => { + if ( + !(await confirmRenameCollision( + languageClient, + textDocumentPosition, + newName, + false, + token, + )) + ) { + return emptyRenameEdit(); + } + return await next(document, position, newName, token); + }; + + let info: RenameExpansionInfo | undefined; + try { + info = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: renameExpansionInfoRequest, + arguments: [{ textDocumentPosition }], + }, + token, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[WARN] Falling back to standard rename: ${message}`); + } + + if (!info || info.additionalSymbols === 0) { + return await standardRename(); + } + + const recursiveAction = vscode.l10n.t('Rename Connected Ports/Signals'); + const localAction = vscode.l10n.t('Only This Symbol'); + const selected = await vscode.window.showInformationMessage( + vscode.l10n.t( + 'Rename {0} connected port/signal symbol(s) as well?', + info.additionalSymbols, + ), + recursiveAction, + localAction, + ); + + if (selected === localAction) { + return await standardRename(); + } + + if (selected !== recursiveAction) { + return emptyRenameEdit(); + } + + if ( + !(await confirmRenameCollision( + languageClient, + textDocumentPosition, + newName, + true, + token, + )) + ) { + return emptyRenameEdit(); + } + + const edit = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: expandedRenameRequest, + arguments: [{ textDocumentPosition, newName }], + }, + token, + ); + return await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit as never, token); + }; +} + +function emptyRenameEdit(): vscode.WorkspaceEdit { + return new vscode.WorkspaceEdit(); +} + +async function confirmRenameCollision( + languageClient: LanguageClient, + textDocumentPosition: unknown, + newName: string, + recursive: boolean, + token: vscode.CancellationToken, +): Promise { + const info = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: renameConflictInfoRequest, + arguments: [{ textDocumentPosition, newName, recursive }], + }, + token, + ); + + if (info.conflicts === 0) { + return true; + } + + const continueAction = vscode.l10n.t('Continue Rename'); + const cancelAction = vscode.l10n.t('Cancel'); + const selected = await vscode.window.showWarningMessage( + vscode.l10n.t( + 'Renaming to "{0}" may collide with {1} existing symbol(s).', + newName, + info.conflicts, + ), + continueAction, + cancelAction, + ); + return selected === continueAction; +} From c551649776bf97bf5fad729b8fdd82ce2e542fa0 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:06:21 +0800 Subject: [PATCH 04/17] refactor(vscode): share rename middleware --- editors/vscode/src/browser/client.ts | 144 +----------------- .../src/{node => common}/renameMiddleware.ts | 29 +++- editors/vscode/src/extension.ts | 7 +- 3 files changed, 35 insertions(+), 145 deletions(-) rename editors/vscode/src/{node => common}/renameMiddleware.ts (84%) diff --git a/editors/vscode/src/browser/client.ts b/editors/vscode/src/browser/client.ts index e8d31c60..46175306 100644 --- a/editors/vscode/src/browser/client.ts +++ b/editors/vscode/src/browser/client.ts @@ -20,12 +20,9 @@ import { BROWSER_WORKSPACE_FOLDER_NAME, type BrowserWorkspaceSnapshot, } from "./workspaceSnapshot"; +import { createProvideExpandedRenameEdits } from "../common/renameMiddleware"; const CLIENT_DISPOSED_MESSAGE = "Vide browser client has been disposed."; -const RENAME_EXPANSION_INFO_REQUEST = - "vide.server.renameExpansionInfo"; -const EXPANDED_RENAME_REQUEST = "vide.server.expandedRename"; -const RENAME_CONFLICT_INFO_REQUEST = "vide.server.renameConflictInfo"; export class VideBrowserClient { private readonly worker: Worker; @@ -125,6 +122,11 @@ export class VideBrowserClient { } private clientOptions(): LanguageClientOptions { + const provideRenameEdits = createProvideExpandedRenameEdits( + () => this.requireLanguageClient(), + (message) => this.onLog(message, "warn"), + ); + return { documentSelector: [ { language: "verilog" }, @@ -154,93 +156,7 @@ export class VideBrowserClient { handleDiagnostics: (uri, diagnostics, next) => { next(uri, diagnostics); }, - provideRenameEdits: async (document, position, newName, token, next) => { - const languageClient = this.requireLanguageClient(); - const textDocumentPosition = { - textDocument: - languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), - position: languageClient.code2ProtocolConverter.asPosition(position), - }; - const standardRename = async () => { - if ( - !(await confirmRenameCollision( - languageClient, - textDocumentPosition, - newName, - false, - token, - )) - ) { - return emptyRenameEdit(); - } - return await next(document, position, newName, token); - }; - - let info: RenameExpansionInfo | undefined; - try { - info = await languageClient.sendRequest( - "workspace/executeCommand", - { - command: RENAME_EXPANSION_INFO_REQUEST, - arguments: [{ textDocumentPosition }], - }, - token, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.onLog(`Falling back to standard rename: ${message}`, "warn"); - } - - if (!info || info.additionalSymbols === 0) { - return await standardRename(); - } - - const recursiveAction = vscode.l10n.t( - "Rename Connected Ports/Signals", - ); - const localAction = vscode.l10n.t("Only This Symbol"); - const selected = await vscode.window.showInformationMessage( - vscode.l10n.t( - "Rename {0} connected port/signal symbol(s) as well?", - info.additionalSymbols, - ), - recursiveAction, - localAction, - ); - - if (selected === localAction) { - return await standardRename(); - } - - if (selected !== recursiveAction) { - return emptyRenameEdit(); - } - - if ( - !(await confirmRenameCollision( - languageClient, - textDocumentPosition, - newName, - true, - token, - )) - ) { - return emptyRenameEdit(); - } - - const edit = await languageClient.sendRequest( - "workspace/executeCommand", - { - command: EXPANDED_RENAME_REQUEST, - arguments: [{ textDocumentPosition, newName }], - }, - token, - ); - return await languageClient.protocol2CodeConverter.asWorkspaceEdit( - edit as never, - token, - ); - }, + provideRenameEdits, workspace: { configuration: () => [], }, @@ -298,52 +214,6 @@ export class VideBrowserClient { } } -type RenameExpansionInfo = { - additionalSymbols: number; -}; - -type RenameConflictInfo = { - conflicts: number; -}; - -function emptyRenameEdit(): vscode.WorkspaceEdit { - return new vscode.WorkspaceEdit(); -} - -async function confirmRenameCollision( - languageClient: VideLanguageClient, - textDocumentPosition: unknown, - newName: string, - recursive: boolean, - token: vscode.CancellationToken, -): Promise { - const info = await languageClient.sendRequest( - "workspace/executeCommand", - { - command: RENAME_CONFLICT_INFO_REQUEST, - arguments: [{ textDocumentPosition, newName, recursive }], - }, - token, - ); - - if (info.conflicts === 0) { - return true; - } - - const continueAction = vscode.l10n.t("Continue Rename"); - const cancelAction = vscode.l10n.t("Cancel"); - const selected = await vscode.window.showWarningMessage( - vscode.l10n.t( - 'Renaming to "{0}" may collide with {1} existing symbol(s).', - newName, - info.conflicts, - ), - continueAction, - cancelAction, - ); - return selected === continueAction; -} - class VideLanguageClient extends BaseLanguageClient { constructor( clientOptions: LanguageClientOptions, diff --git a/editors/vscode/src/node/renameMiddleware.ts b/editors/vscode/src/common/renameMiddleware.ts similarity index 84% rename from editors/vscode/src/node/renameMiddleware.ts rename to editors/vscode/src/common/renameMiddleware.ts index 1eae3588..ebf842ab 100644 --- a/editors/vscode/src/node/renameMiddleware.ts +++ b/editors/vscode/src/common/renameMiddleware.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; -import type { LanguageClient, LanguageClientOptions } from 'vscode-languageclient/node'; +import type { LanguageClientOptions } from 'vscode-languageclient'; -type Logger = (message: string) => void; type ClientMiddleware = NonNullable; const renameExpansionInfoRequest = 'vide.server.renameExpansionInfo'; @@ -16,9 +15,27 @@ type RenameConflictInfo = { conflicts: number; }; +export type RenameMiddlewareClient = { + code2ProtocolConverter: { + asTextDocumentIdentifier(document: vscode.TextDocument): unknown; + asPosition(position: vscode.Position): unknown; + }; + protocol2CodeConverter: { + asWorkspaceEdit( + edit: never, + token: vscode.CancellationToken, + ): Promise; + }; + sendRequest( + method: string, + params: unknown, + token: vscode.CancellationToken, + ): Promise; +}; + export function createProvideExpandedRenameEdits( - getClient: () => LanguageClient | undefined, - log: Logger, + getClient: () => RenameMiddlewareClient | undefined, + warn: (message: string) => void, ): NonNullable { return async (document, position, newName, token, next) => { const languageClient = getClient(); @@ -57,7 +74,7 @@ export function createProvideExpandedRenameEdits( ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - log(`[WARN] Falling back to standard rename: ${message}`); + warn(`Falling back to standard rename: ${message}`); } if (!info || info.additionalSymbols === 0) { @@ -112,7 +129,7 @@ function emptyRenameEdit(): vscode.WorkspaceEdit { } async function confirmRenameCollision( - languageClient: LanguageClient, + languageClient: RenameMiddlewareClient, textDocumentPosition: unknown, newName: string, recursive: boolean, diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 83044a96..88465576 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -29,7 +29,7 @@ import { } from './videStatus'; import type { ServerStatus } from './status'; import { createNodeClientOptions } from './common/clientOptions'; -import { createProvideExpandedRenameEdits } from './node/renameMiddleware'; +import { createProvideExpandedRenameEdits } from './common/renameMiddleware'; import { createServerEnv, readConfiguration, @@ -485,7 +485,10 @@ async function createClient(context: vscode.ExtensionContext): Promise client, log), + provideRenameEdits: createProvideExpandedRenameEdits( + () => client, + (message) => log(`[WARN] ${message}`), + ), }); log('[INFO] Creating LanguageClient instance...'); From ed239d1738feda743048bac47d834163eb0dd4fd Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:07:33 +0800 Subject: [PATCH 05/17] refactor(vscode): isolate node version helpers --- editors/vscode/src/extension.ts | 93 +++---------------- editors/vscode/src/node/buildInfo.ts | 39 ++++++++ .../vscode/src/node/profilingIntegration.ts | 30 ++++++ editors/vscode/src/node/serverVersion.ts | 56 +++++++++++ 4 files changed, 139 insertions(+), 79 deletions(-) create mode 100644 editors/vscode/src/node/buildInfo.ts create mode 100644 editors/vscode/src/node/profilingIntegration.ts create mode 100644 editors/vscode/src/node/serverVersion.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 88465576..a9dd932c 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -1,7 +1,5 @@ -import { execFile } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { promisify } from 'node:util'; import * as vscode from 'vscode'; import { @@ -10,7 +8,7 @@ import { } from 'vscode-languageclient/node'; import { registerDiagnosticActions } from './diagnosticActions'; -import { profileDiagnosticsCommand, registerProfilingCommand } from './profiling'; +import { profileDiagnosticsCommand } from './profiling'; import { DEFAULT_PROJECT_CONFIG_TEXT, PROJECT_CONFIG_FILE_NAMES, @@ -35,6 +33,12 @@ import { readConfiguration, resolveServerLaunch, } from './node/serverLaunch'; +import { + extensionBuildLabel, + isProfileTraceEnabled, +} from './node/buildInfo'; +import { registerProfilingIntegration } from './node/profilingIntegration'; +import { showServerVersion } from './node/serverVersion'; let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel | undefined; @@ -42,7 +46,6 @@ let qiheOutputChannel: vscode.OutputChannel | undefined; let videStatusController: VideStatusController | undefined; let qiheStatusBarItem: vscode.StatusBarItem | undefined; -const execFileAsync = promisify(execFile); const restartServerCommand = 'vide.restartServer'; const showServerVersionCommand = 'vide.showServerVersion'; const showQiheOutputCommand = 'vide.showQiheOutput'; @@ -54,14 +57,6 @@ const qiheAnalysisIcon = '$(beaker)'; // Output channel names are stable identifiers in the Output view. const languageServerOutputChannelName = 'Vide Language Server'; const qiheOutputChannelName = 'Vide Qihe'; -const versionTimeoutMs = 5000; - -interface ExtensionBuildInfo { - kind?: string; - commitHash?: string; - buildDate?: string; - profileTrace?: boolean; -} const activeQiheTokens = new Set(); const qiheProgressNotifications = new Map void }>(); @@ -99,34 +94,6 @@ function showQiheOutput(): void { requireQiheOutputChannel().show(true); } -function extensionVersion(context: vscode.ExtensionContext): string { - const packageJson = context.extension.packageJSON as { version?: unknown }; - return typeof packageJson.version === 'string' && packageJson.version.length > 0 - ? packageJson.version - : 'unknown'; -} - -function extensionBuildInfo(context: vscode.ExtensionContext): ExtensionBuildInfo | undefined { - const buildInfoPath = path.join(context.extensionPath, 'build-info.json'); - if (!fs.existsSync(buildInfoPath)) { - return undefined; - } - return JSON.parse(fs.readFileSync(buildInfoPath, 'utf8')) as ExtensionBuildInfo; -} - -function extensionBuildLabel(context: vscode.ExtensionContext): string { - const version = extensionVersion(context); - const buildInfo = extensionBuildInfo(context); - const details = [buildInfo?.kind, buildInfo?.commitHash, buildInfo?.buildDate].filter( - (part): part is string => typeof part === 'string' && part.length > 0, - ); - return details.length > 0 ? `${version} (${details.join(', ')})` : version; -} - -function isProfileTraceEnabled(context: vscode.ExtensionContext): boolean { - return extensionBuildInfo(context)?.profileTrace === true; -} - async function showLanguageServerErrorMessage(message: string): Promise { const showOutputAction = vscode.l10n.t('Show Output'); const selection = await vscode.window.showErrorMessage(message, showOutputAction); @@ -547,37 +514,6 @@ async function restartClient(context: vscode.ExtensionContext): Promise { await startClient(context); } -async function showServerVersion(context: vscode.ExtensionContext): Promise { - try { - const config = readConfiguration(); - const launch = resolveServerLaunch(context, config, log); - const versionArgs = [...launch.args, '--version']; - log(`[INFO] Checking server version: ${launch.command} ${versionArgs.join(' ')}`); - const { stdout, stderr } = await execFileAsync(launch.command, versionArgs, { - cwd: launch.cwd, - env: createServerEnv(), - timeout: versionTimeoutMs, - }); - const output = `${stdout}${stderr}`.trim() || vscode.l10n.t('No version output'); - const firstLine = output.split(/\r?\n/, 1)[0] ?? output; - log(`[INFO] Server version output:\n${output}`); - vscode.window.showInformationMessage( - vscode.l10n.t( - 'Vide extension: {0}; server: {1}', - extensionBuildLabel(context), - firstLine, - ), - ); - } catch (error) { - const message = vscode.l10n.t( - 'Failed to query Vide server version: {0}', - (error as Error).message, - ); - log(`[ERROR] ${message}`); - await showLanguageServerErrorMessage(message); - } -} - async function reloadWorkspace(): Promise { if (!client) { await showLanguageServerErrorMessage(vscode.l10n.t('Vide language server is not running.')); @@ -717,7 +653,7 @@ export async function activate(context: vscode.ExtensionContext): Promise showServerVersionCommand, async () => { log('[INFO] Server version command triggered'); - await showServerVersion(context); + await showServerVersion(context, { log, showLanguageServerErrorMessage }); }, ); context.subscriptions.push(showVersionRegistration); @@ -737,13 +673,12 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ); - if (profileTraceEnabled) { - context.subscriptions.push( - registerProfilingCommand(context, { - resolveLaunch: () => resolveServerLaunch(context, readConfiguration(), log), - createEnv: createServerEnv, - }), - ); + const profilingRegistration = registerProfilingIntegration(context, { + enabled: profileTraceEnabled, + log, + }); + if (profilingRegistration) { + context.subscriptions.push(profilingRegistration); } const reloadWorkspaceRegistration = vscode.commands.registerCommand( diff --git a/editors/vscode/src/node/buildInfo.ts b/editors/vscode/src/node/buildInfo.ts new file mode 100644 index 00000000..d7526da8 --- /dev/null +++ b/editors/vscode/src/node/buildInfo.ts @@ -0,0 +1,39 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +interface ExtensionBuildInfo { + kind?: string; + commitHash?: string; + buildDate?: string; + profileTrace?: boolean; +} + +export function extensionBuildLabel(context: vscode.ExtensionContext): string { + const version = extensionVersion(context); + const buildInfo = extensionBuildInfo(context); + const details = [buildInfo?.kind, buildInfo?.commitHash, buildInfo?.buildDate].filter( + (part): part is string => typeof part === 'string' && part.length > 0, + ); + return details.length > 0 ? `${version} (${details.join(', ')})` : version; +} + +export function isProfileTraceEnabled(context: vscode.ExtensionContext): boolean { + return extensionBuildInfo(context)?.profileTrace === true; +} + +function extensionVersion(context: vscode.ExtensionContext): string { + const packageJson = context.extension.packageJSON as { version?: unknown }; + return typeof packageJson.version === 'string' && packageJson.version.length > 0 + ? packageJson.version + : 'unknown'; +} + +function extensionBuildInfo(context: vscode.ExtensionContext): ExtensionBuildInfo | undefined { + const buildInfoPath = path.join(context.extensionPath, 'build-info.json'); + if (!fs.existsSync(buildInfoPath)) { + return undefined; + } + return JSON.parse(fs.readFileSync(buildInfoPath, 'utf8')) as ExtensionBuildInfo; +} diff --git a/editors/vscode/src/node/profilingIntegration.ts b/editors/vscode/src/node/profilingIntegration.ts new file mode 100644 index 00000000..950476ff --- /dev/null +++ b/editors/vscode/src/node/profilingIntegration.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; + +import { registerProfilingCommand } from '../profiling'; +import { + createServerEnv, + readConfiguration, + resolveServerLaunch, +} from './serverLaunch'; + +type Logger = (message: string) => void; + +export function registerProfilingIntegration( + context: vscode.ExtensionContext, + { + enabled, + log, + }: { + enabled: boolean; + log: Logger; + }, +): vscode.Disposable | undefined { + if (!enabled) { + return undefined; + } + + return registerProfilingCommand(context, { + resolveLaunch: () => resolveServerLaunch(context, readConfiguration(), log), + createEnv: createServerEnv, + }); +} diff --git a/editors/vscode/src/node/serverVersion.ts b/editors/vscode/src/node/serverVersion.ts new file mode 100644 index 00000000..12d5e281 --- /dev/null +++ b/editors/vscode/src/node/serverVersion.ts @@ -0,0 +1,56 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import * as vscode from 'vscode'; + +import { extensionBuildLabel } from './buildInfo'; +import { + createServerEnv, + readConfiguration, + resolveServerLaunch, +} from './serverLaunch'; + +type Logger = (message: string) => void; + +const execFileAsync = promisify(execFile); +const versionTimeoutMs = 5000; + +export async function showServerVersion( + context: vscode.ExtensionContext, + { + log, + showLanguageServerErrorMessage, + }: { + log: Logger; + showLanguageServerErrorMessage: (message: string) => Promise; + }, +): Promise { + try { + const config = readConfiguration(); + const launch = resolveServerLaunch(context, config, log); + const versionArgs = [...launch.args, '--version']; + log(`[INFO] Checking server version: ${launch.command} ${versionArgs.join(' ')}`); + const { stdout, stderr } = await execFileAsync(launch.command, versionArgs, { + cwd: launch.cwd, + env: createServerEnv(), + timeout: versionTimeoutMs, + }); + const output = `${stdout}${stderr}`.trim() || vscode.l10n.t('No version output'); + const firstLine = output.split(/\r?\n/, 1)[0] ?? output; + log(`[INFO] Server version output:\n${output}`); + vscode.window.showInformationMessage( + vscode.l10n.t( + 'Vide extension: {0}; server: {1}', + extensionBuildLabel(context), + firstLine, + ), + ); + } catch (error) { + const message = vscode.l10n.t( + 'Failed to query Vide server version: {0}', + (error as Error).message, + ); + log(`[ERROR] ${message}`); + await showLanguageServerErrorMessage(message); + } +} From 53652d2f07dc80144550a36bf17c8f0d1e562ab3 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:09:30 +0800 Subject: [PATCH 06/17] refactor(vscode): isolate qihe integration --- editors/vscode/src/extension.ts | 272 ++----------------------------- editors/vscode/src/node/qihe.ts | 276 ++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 261 deletions(-) create mode 100644 editors/vscode/src/node/qihe.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index a9dd932c..fe5f967e 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -16,7 +16,6 @@ import { PROJECT_SOURCE_FILE_GLOB, getProjectConfigPath, } from './projectConfig'; -import { registerQiheOptionsCommand } from './qiheOptions'; import { projectStatusNotification, reloadWorkspaceCommand, @@ -39,37 +38,22 @@ import { } from './node/buildInfo'; import { registerProfilingIntegration } from './node/profilingIntegration'; import { showServerVersion } from './node/serverVersion'; +import { QiheController } from './node/qihe'; let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel | undefined; -let qiheOutputChannel: vscode.OutputChannel | undefined; let videStatusController: VideStatusController | undefined; -let qiheStatusBarItem: vscode.StatusBarItem | undefined; +let qiheController: QiheController | undefined; const restartServerCommand = 'vide.restartServer'; const showServerVersionCommand = 'vide.showServerVersion'; -const showQiheOutputCommand = 'vide.showQiheOutput'; -const runQiheAnalysisCommand = 'vide.runQiheAnalysis'; -const runQiheAnalysisRequest = 'vide.server.runQiheAnalysis'; -const qiheStatusNotification = 'vide/qiheStatus'; -const qiheLogNotification = 'vide/qiheLog'; -const qiheAnalysisIcon = '$(beaker)'; // Output channel names are stable identifiers in the Output view. const languageServerOutputChannelName = 'Vide Language Server'; -const qiheOutputChannelName = 'Vide Qihe'; - -const activeQiheTokens = new Set(); -const qiheProgressNotifications = new Map void }>(); -let qiheStatusHideTimer: NodeJS.Timeout | undefined; function log(message: string): void { outputChannel?.appendLine(message); } -function logQihe(message: string): void { - qiheOutputChannel?.appendLine(message); -} - function requireOutputChannel(): vscode.OutputChannel { if (!outputChannel) { throw new Error(vscode.l10n.t('Vide output channel has not been initialized.')); @@ -78,22 +62,10 @@ function requireOutputChannel(): vscode.OutputChannel { return outputChannel; } -function requireQiheOutputChannel(): vscode.OutputChannel { - if (!qiheOutputChannel) { - throw new Error(vscode.l10n.t('Vide Qihe output channel has not been initialized.')); - } - - return qiheOutputChannel; -} - function showOutput(): void { requireOutputChannel().show(true); } -function showQiheOutput(): void { - requireQiheOutputChannel().show(true); -} - async function showLanguageServerErrorMessage(message: string): Promise { const showOutputAction = vscode.l10n.t('Show Output'); const selection = await vscode.window.showErrorMessage(message, showOutputAction); @@ -102,154 +74,10 @@ async function showLanguageServerErrorMessage(message: string): Promise { } } -async function showQiheErrorMessage(message: string): Promise { - const showOutputAction = vscode.l10n.t('Show Qihe Output'); - const selection = await vscode.window.showErrorMessage(message, showOutputAction); - if (selection === showOutputAction) { - showQiheOutput(); - } -} - function updateServerStatus(status: ServerStatus, detail?: string): void { videStatusController?.updateServerStatus(status, detail); } -function clearQiheStatusHideTimer(): void { - if (!qiheStatusHideTimer) { - return; - } - - clearTimeout(qiheStatusHideTimer); - qiheStatusHideTimer = undefined; -} - -function updateQiheStatus( - tooltip: string, - hideAfterMs?: number, - command: string | vscode.Command = runQiheAnalysisCommand, -): void { - if (!qiheStatusBarItem) { - return; - } - - clearQiheStatusHideTimer(); - qiheStatusBarItem.text = `${qiheAnalysisIcon} Qihe`; - qiheStatusBarItem.tooltip = tooltip; - qiheStatusBarItem.command = command; - qiheStatusBarItem.show(); - - if (!hideAfterMs) { - return; - } - - qiheStatusHideTimer = setTimeout(() => { - qiheStatusBarItem?.hide(); - qiheStatusHideTimer = undefined; - }, hideAfterMs); -} - -function createQiheStatusBarItem(): vscode.StatusBarItem { - const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - item.name = vscode.l10n.t('Qihe Analysis Status'); - item.command = runQiheAnalysisCommand; - item.hide(); - return item; -} - -function startQiheNotification(token: string, message?: string): void { - if (qiheProgressNotifications.has(token)) { - return; - } - - let resolveProgress = () => {}; - const progressPromise = new Promise((resolve) => { - resolveProgress = resolve; - }); - - qiheProgressNotifications.set(token, { resolve: resolveProgress }); - - void vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t('Running Qihe analysis'), - }, - async (progress) => { - if (message) { - progress.report({ message }); - } - await progressPromise; - }, - ); -} - -function finishQiheNotification(token: string): void { - const entry = qiheProgressNotifications.get(token); - if (!entry) { - return; - } - - qiheProgressNotifications.delete(token); - entry.resolve(); -} - -function registerQiheNotifications(languageClient: LanguageClient): void { - languageClient.onNotification( - qiheLogNotification, - (params: { token?: unknown; message?: unknown }) => { - const message = - typeof params.message === 'string' ? params.message : undefined; - - if (!message) { - return; - } - - logQihe(message); - }, - ); - - languageClient.onNotification( - qiheStatusNotification, - (params: { token?: unknown; state?: unknown; message?: unknown }) => { - const token = - typeof params.token === 'string' ? params.token : undefined; - const state = - typeof params.state === 'string' ? params.state : undefined; - const message = - typeof params.message === 'string' ? params.message : undefined; - - if (!token || !state) { - return; - } - - switch (state) { - case 'begin': - activeQiheTokens.add(token); - updateQiheStatus(message ?? vscode.l10n.t('Qihe analysis is running')); - startQiheNotification(token, message); - break; - case 'end': - activeQiheTokens.delete(token); - finishQiheNotification(token); - if (activeQiheTokens.size === 0) { - updateQiheStatus(message ?? vscode.l10n.t('Qihe analysis finished'), 4000); - } - break; - case 'failed': - activeQiheTokens.delete(token); - finishQiheNotification(token); - if (activeQiheTokens.size === 0) { - const failureMessage = message ?? vscode.l10n.t('Qihe analysis failed'); - updateQiheStatus(failureMessage, 6000, showQiheOutputCommand); - void showQiheErrorMessage(failureMessage); - } - break; - default: - break; - } - }, - ); -} - function registerProjectStatusNotifications(languageClient: LanguageClient): void { languageClient.onNotification(projectStatusNotification, (params: unknown) => { videStatusController?.handleProjectNotification(params); @@ -473,7 +301,7 @@ async function startClient(context: vscode.ExtensionContext): Promise { log('[INFO] Starting language server...'); client = await createClient(context); registerProjectStatusNotifications(client); - registerQiheNotifications(client); + qiheController?.registerNotifications(client); await client.start(); log('[INFO] Language server started successfully'); updateServerStatus('ready'); @@ -535,62 +363,6 @@ async function reloadWorkspace(): Promise { } } -async function runQiheAnalysis(resource: unknown): Promise { - const targetUri = qiheAnalysisTargetUri(resource); - if (!targetUri) { - vscode.window.showWarningMessage(vscode.l10n.t('Open a Verilog or SystemVerilog file first.')); - return; - } - - if (!isQiheSourceUri(targetUri)) { - vscode.window.showWarningMessage( - vscode.l10n.t('Qihe analysis is only available for Verilog files.'), - ); - return; - } - - if (!client) { - await showLanguageServerErrorMessage(vscode.l10n.t('Vide language server is not running.')); - return; - } - - const workspaceFolder = vscode.workspace.getWorkspaceFolder(targetUri); - const payload = { - uri: targetUri.toString(), - cwd: workspaceFolder?.uri.fsPath, - }; - - const target = workspaceFolder - ? `workspace ${workspaceFolder.uri.fsPath}` - : `file ${targetUri.fsPath}`; - logQihe(`[INFO] Starting Qihe analysis for ${target}`); - - try { - await client.sendRequest('workspace/executeCommand', { - command: runQiheAnalysisRequest, - arguments: [payload], - }); - } catch (error) { - const message = vscode.l10n.t('Failed to run Qihe analysis: {0}', (error as Error).message); - log(`[ERROR] ${message}`); - await showLanguageServerErrorMessage(message); - } -} - -function qiheAnalysisTargetUri(resource: unknown): vscode.Uri | undefined { - if (resource instanceof vscode.Uri) { - return resource; - } - return vscode.window.activeTextEditor?.document.uri; -} - -function isQiheSourceUri(uri: vscode.Uri): boolean { - if (uri.scheme !== 'file') { - return false; - } - return ['.v', '.vh', '.sv', '.svh', '.svi'].includes(path.extname(uri.fsPath).toLowerCase()); -} - function affectsServerLaunchConfiguration(event: vscode.ConfigurationChangeEvent): boolean { return ( event.affectsConfiguration('vide.server.command') || @@ -604,8 +376,12 @@ function affectsServerLaunchConfiguration(event: vscode.ConfigurationChangeEvent export async function activate(context: vscode.ExtensionContext): Promise { outputChannel = vscode.window.createOutputChannel(languageServerOutputChannelName); context.subscriptions.push(outputChannel); - qiheOutputChannel = vscode.window.createOutputChannel(qiheOutputChannelName); - context.subscriptions.push(qiheOutputChannel); + qiheController = new QiheController({ + getClient: () => client, + logLanguageServer: log, + showLanguageServerErrorMessage, + }); + qiheController.register(context); const profileTraceEnabled = isProfileTraceEnabled(context); videStatusController = new VideStatusController({ createManifest: (rootUris) => createProjectConfigsFromRootUris(context, rootUris), @@ -620,8 +396,6 @@ export async function activate(context: vscode.ExtensionContext): Promise log, }); context.subscriptions.push(videStatusController); - qiheStatusBarItem = createQiheStatusBarItem(); - context.subscriptions.push(qiheStatusBarItem); updateServerStatus('stopped'); log('[INFO] Vide extension activating...'); @@ -635,11 +409,6 @@ export async function activate(context: vscode.ExtensionContext): Promise }); context.subscriptions.push(showOutputRegistration); - const showQiheOutputRegistration = vscode.commands.registerCommand(showQiheOutputCommand, () => { - showQiheOutput(); - }); - context.subscriptions.push(showQiheOutputRegistration); - const restartCommandRegistration = vscode.commands.registerCommand( restartServerCommand, async () => { @@ -658,21 +427,6 @@ export async function activate(context: vscode.ExtensionContext): Promise ); context.subscriptions.push(showVersionRegistration); - const runQiheRegistration = vscode.commands.registerCommand( - runQiheAnalysisCommand, - async (resource) => { - await runQiheAnalysis(resource); - }, - ); - context.subscriptions.push(runQiheRegistration); - - context.subscriptions.push( - registerQiheOptionsCommand({ - logQihe, - showQiheErrorMessage, - }), - ); - const profilingRegistration = registerProfilingIntegration(context, { enabled: profileTraceEnabled, log, @@ -726,12 +480,8 @@ export async function activate(context: vscode.ExtensionContext): Promise } export async function deactivate(): Promise { - clearQiheStatusHideTimer(); - for (const { resolve } of qiheProgressNotifications.values()) { - resolve(); - } - qiheProgressNotifications.clear(); - activeQiheTokens.clear(); + qiheController?.dispose(); + qiheController = undefined; if (outputChannel) { log('[INFO] Vide extension deactivating...'); diff --git a/editors/vscode/src/node/qihe.ts b/editors/vscode/src/node/qihe.ts new file mode 100644 index 00000000..06457d91 --- /dev/null +++ b/editors/vscode/src/node/qihe.ts @@ -0,0 +1,276 @@ +import * as path from 'node:path'; + +import * as vscode from 'vscode'; +import type { LanguageClient } from 'vscode-languageclient/node'; + +import { registerQiheOptionsCommand } from '../qiheOptions'; + +type Logger = (message: string) => void; + +const showQiheOutputCommand = 'vide.showQiheOutput'; +const runQiheAnalysisCommand = 'vide.runQiheAnalysis'; +const runQiheAnalysisRequest = 'vide.server.runQiheAnalysis'; +const qiheStatusNotification = 'vide/qiheStatus'; +const qiheLogNotification = 'vide/qiheLog'; +const qiheAnalysisIcon = '$(beaker)'; +const qiheOutputChannelName = 'Vide Qihe'; + +export class QiheController implements vscode.Disposable { + private readonly outputChannel = vscode.window.createOutputChannel(qiheOutputChannelName); + private readonly statusBarItem = createQiheStatusBarItem(); + private readonly activeTokens = new Set(); + private readonly progressNotifications = new Map void }>(); + private statusHideTimer: NodeJS.Timeout | undefined; + private disposed = false; + + constructor( + private readonly options: { + getClient: () => LanguageClient | undefined; + logLanguageServer: Logger; + showLanguageServerErrorMessage: (message: string) => Promise; + }, + ) {} + + register(context: vscode.ExtensionContext): void { + context.subscriptions.push(this); + context.subscriptions.push( + vscode.commands.registerCommand(showQiheOutputCommand, () => { + this.showOutput(); + }), + ); + context.subscriptions.push( + vscode.commands.registerCommand(runQiheAnalysisCommand, async (resource) => { + await this.runAnalysis(resource); + }), + ); + context.subscriptions.push( + registerQiheOptionsCommand({ + logQihe: (message) => this.log(message), + showQiheErrorMessage: (message) => this.showErrorMessage(message), + }), + ); + } + + registerNotifications(languageClient: LanguageClient): void { + languageClient.onNotification( + qiheLogNotification, + (params: { token?: unknown; message?: unknown }) => { + const message = + typeof params.message === 'string' ? params.message : undefined; + + if (!message) { + return; + } + + this.log(message); + }, + ); + + languageClient.onNotification( + qiheStatusNotification, + (params: { token?: unknown; state?: unknown; message?: unknown }) => { + const token = + typeof params.token === 'string' ? params.token : undefined; + const state = + typeof params.state === 'string' ? params.state : undefined; + const message = + typeof params.message === 'string' ? params.message : undefined; + + if (!token || !state) { + return; + } + + switch (state) { + case 'begin': + this.activeTokens.add(token); + this.updateStatus(message ?? vscode.l10n.t('Qihe analysis is running')); + this.startNotification(token, message); + break; + case 'end': + this.activeTokens.delete(token); + this.finishNotification(token); + if (this.activeTokens.size === 0) { + this.updateStatus(message ?? vscode.l10n.t('Qihe analysis finished'), 4000); + } + break; + case 'failed': + this.activeTokens.delete(token); + this.finishNotification(token); + if (this.activeTokens.size === 0) { + const failureMessage = message ?? vscode.l10n.t('Qihe analysis failed'); + this.updateStatus(failureMessage, 6000, showQiheOutputCommand); + void this.showErrorMessage(failureMessage); + } + break; + default: + break; + } + }, + ); + } + + dispose(): void { + if (this.disposed) { + return; + } + + this.disposed = true; + this.clearStatusHideTimer(); + for (const { resolve } of this.progressNotifications.values()) { + resolve(); + } + this.progressNotifications.clear(); + this.activeTokens.clear(); + this.statusBarItem.dispose(); + this.outputChannel.dispose(); + } + + private log(message: string): void { + this.outputChannel.appendLine(message); + } + + private showOutput(): void { + this.outputChannel.show(true); + } + + private async showErrorMessage(message: string): Promise { + const showOutputAction = vscode.l10n.t('Show Qihe Output'); + const selection = await vscode.window.showErrorMessage(message, showOutputAction); + if (selection === showOutputAction) { + this.showOutput(); + } + } + + private updateStatus( + tooltip: string, + hideAfterMs?: number, + command: string | vscode.Command = runQiheAnalysisCommand, + ): void { + this.clearStatusHideTimer(); + this.statusBarItem.text = `${qiheAnalysisIcon} Qihe`; + this.statusBarItem.tooltip = tooltip; + this.statusBarItem.command = command; + this.statusBarItem.show(); + + if (!hideAfterMs) { + return; + } + + this.statusHideTimer = setTimeout(() => { + this.statusBarItem.hide(); + this.statusHideTimer = undefined; + }, hideAfterMs); + } + + private clearStatusHideTimer(): void { + if (!this.statusHideTimer) { + return; + } + + clearTimeout(this.statusHideTimer); + this.statusHideTimer = undefined; + } + + private startNotification(token: string, message?: string): void { + if (this.progressNotifications.has(token)) { + return; + } + + let resolveProgress = () => {}; + const progressPromise = new Promise((resolve) => { + resolveProgress = resolve; + }); + + this.progressNotifications.set(token, { resolve: resolveProgress }); + + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Running Qihe analysis'), + }, + async (progress) => { + if (message) { + progress.report({ message }); + } + await progressPromise; + }, + ); + } + + private finishNotification(token: string): void { + const entry = this.progressNotifications.get(token); + if (!entry) { + return; + } + + this.progressNotifications.delete(token); + entry.resolve(); + } + + private async runAnalysis(resource: unknown): Promise { + const targetUri = qiheAnalysisTargetUri(resource); + if (!targetUri) { + vscode.window.showWarningMessage(vscode.l10n.t('Open a Verilog or SystemVerilog file first.')); + return; + } + + if (!isQiheSourceUri(targetUri)) { + vscode.window.showWarningMessage( + vscode.l10n.t('Qihe analysis is only available for Verilog files.'), + ); + return; + } + + const languageClient = this.options.getClient(); + if (!languageClient) { + await this.options.showLanguageServerErrorMessage( + vscode.l10n.t('Vide language server is not running.'), + ); + return; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(targetUri); + const payload = { + uri: targetUri.toString(), + cwd: workspaceFolder?.uri.fsPath, + }; + + const target = workspaceFolder + ? `workspace ${workspaceFolder.uri.fsPath}` + : `file ${targetUri.fsPath}`; + this.log(`[INFO] Starting Qihe analysis for ${target}`); + + try { + await languageClient.sendRequest('workspace/executeCommand', { + command: runQiheAnalysisRequest, + arguments: [payload], + }); + } catch (error) { + const message = vscode.l10n.t('Failed to run Qihe analysis: {0}', (error as Error).message); + this.options.logLanguageServer(`[ERROR] ${message}`); + await this.options.showLanguageServerErrorMessage(message); + } + } +} + +function createQiheStatusBarItem(): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + item.name = vscode.l10n.t('Qihe Analysis Status'); + item.command = runQiheAnalysisCommand; + item.hide(); + return item; +} + +function qiheAnalysisTargetUri(resource: unknown): vscode.Uri | undefined { + if (resource instanceof vscode.Uri) { + return resource; + } + return vscode.window.activeTextEditor?.document.uri; +} + +function isQiheSourceUri(uri: vscode.Uri): boolean { + if (uri.scheme !== 'file') { + return false; + } + return ['.v', '.vh', '.sv', '.svh', '.svi'].includes(path.extname(uri.fsPath).toLowerCase()); +} From 434b51c453d45751c09fe86c1c3ca3cc3bfa0892 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:10:54 +0800 Subject: [PATCH 07/17] refactor(vscode): isolate project config prompts --- editors/vscode/src/extension.ts | 193 ++---------------- .../vscode/src/node/projectConfigPrompt.ts | 190 +++++++++++++++++ 2 files changed, 206 insertions(+), 177 deletions(-) create mode 100644 editors/vscode/src/node/projectConfigPrompt.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index fe5f967e..362c2ca4 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -1,6 +1,3 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - import * as vscode from 'vscode'; import { LanguageClient, @@ -9,13 +6,6 @@ import { import { registerDiagnosticActions } from './diagnosticActions'; import { profileDiagnosticsCommand } from './profiling'; -import { - DEFAULT_PROJECT_CONFIG_TEXT, - PROJECT_CONFIG_FILE_NAMES, - PROJECT_CONFIG_FILE_NAME, - PROJECT_SOURCE_FILE_GLOB, - getProjectConfigPath, -} from './projectConfig'; import { projectStatusNotification, reloadWorkspaceCommand, @@ -39,6 +29,11 @@ import { import { registerProfilingIntegration } from './node/profilingIntegration'; import { showServerVersion } from './node/serverVersion'; import { QiheController } from './node/qihe'; +import { + createProjectConfigsFromRootUris, + promptForMissingProjectConfigs, + type ProjectConfigPromptActions, +} from './node/projectConfigPrompt'; let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel | undefined; @@ -84,171 +79,6 @@ function registerProjectStatusNotifications(languageClient: LanguageClient): voi }); } -type ProjectConfigTarget = { - folderName: string; - configPath: string; -}; - -async function findMissingProjectConfigTargets(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - const targets: ProjectConfigTarget[] = []; - - for (const folder of workspaceFolders) { - if (folder.uri.scheme !== 'file') { - log( - `[WARN] Skipping project config creation for non-file workspace: ${folder.uri.toString()}`, - ); - continue; - } - - const existingConfigPath = PROJECT_CONFIG_FILE_NAMES - .map((fileName) => getProjectConfigPath(folder.uri.fsPath, fileName)) - .find((configPath) => fs.existsSync(configPath)); - if (existingConfigPath) { - log(`[INFO] Found project config: ${existingConfigPath}`); - continue; - } - - const sourceFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(folder, PROJECT_SOURCE_FILE_GLOB), - undefined, - 1, - ); - if (sourceFiles.length === 0) { - log( - `[INFO] Skipping project config prompt for workspace without Verilog/SystemVerilog files: ${folder.name}`, - ); - continue; - } - - const configPath = getProjectConfigPath(folder.uri.fsPath); - targets.push({ folderName: folder.name, configPath }); - } - - return targets; -} - -function projectConfigTargetsFromRootUris(rootUris: readonly string[]): ProjectConfigTarget[] { - return rootUris.map((rootUri) => { - const uri = vscode.Uri.parse(rootUri); - return { - folderName: path.basename(uri.fsPath), - configPath: getProjectConfigPath(uri.fsPath), - }; - }); -} - -async function promptForMissingProjectConfigs(context: vscode.ExtensionContext): Promise { - const targets = await findMissingProjectConfigTargets(); - - if (targets.length === 0) { - return; - } - - const createConfigAction = - targets.length === 1 - ? vscode.l10n.t('Create Manifest') - : vscode.l10n.t('Create Manifests'); - const restartNotice = vscode.l10n.t( - 'Creating a manifest will restart the Vide language server so the workspace can reload it.', - ); - const promptMessage = - targets.length === 1 - ? vscode.l10n.t( - 'No Vide project manifest was detected in {0}. Project-aware features like semantic diagnostics, navigation, and references may be severely limited. {1}', - targets[0].folderName, - restartNotice, - ) - : vscode.l10n.t( - 'No Vide project manifest was detected in {0} workspace folders. Project-aware features like semantic diagnostics, navigation, and references may be severely limited. {1}', - targets.length, - restartNotice, - ); - - const selection = await vscode.window.showWarningMessage(promptMessage, createConfigAction); - if (selection !== createConfigAction) { - return; - } - - await createProjectConfigs(context, targets); -} - -async function createProjectConfigsFromRootUris( - context: vscode.ExtensionContext, - rootUris: readonly string[], -): Promise { - await createProjectConfigs(context, projectConfigTargetsFromRootUris(rootUris)); -} - -async function createProjectConfigs( - context: vscode.ExtensionContext, - targets: readonly ProjectConfigTarget[], -): Promise { - if (targets.length === 0) { - return; - } - - const createdConfigs: vscode.Uri[] = []; - - for (const { folderName, configPath } of targets) { - try { - await fs.promises.writeFile(configPath, DEFAULT_PROJECT_CONFIG_TEXT, { - encoding: 'utf8', - flag: 'wx', - }); - createdConfigs.push(vscode.Uri.file(configPath)); - log(`[INFO] Created default project config: ${configPath}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - log(`[INFO] Project config already exists: ${configPath}`); - continue; - } - - const errorMessage = vscode.l10n.t( - 'Failed to create {0} in {1}: {2}', - PROJECT_CONFIG_FILE_NAME, - folderName, - (error as Error).message, - ); - log(`[WARN] ${errorMessage}`); - void vscode.window.showWarningMessage(errorMessage); - } - } - - if (createdConfigs.length === 0) { - return; - } - - if (client) { - await restartClient(context); - } - - const createdMessage = - createdConfigs.length === 1 - ? vscode.l10n.t('Created {0}.', PROJECT_CONFIG_FILE_NAME) - : vscode.l10n.t( - 'Created {0} in {1} workspace folders.', - PROJECT_CONFIG_FILE_NAME, - createdConfigs.length, - ); - const openConfigAction = - createdConfigs.length === 1 - ? vscode.l10n.t('Open Manifest') - : vscode.l10n.t('Open First Manifest'); - - void vscode.window.showInformationMessage(createdMessage, openConfigAction).then(async (selection) => { - if (selection !== openConfigAction) { - return; - } - - try { - await vscode.window.showTextDocument(createdConfigs[0]); - } catch (error) { - log(`[WARN] Failed to open ${PROJECT_CONFIG_FILE_NAME}: ${(error as Error).message}`); - } - }); -} - async function createClient(context: vscode.ExtensionContext): Promise { const channel = requireOutputChannel(); log('[INFO] Creating language client...'); @@ -383,8 +213,17 @@ export async function activate(context: vscode.ExtensionContext): Promise }); qiheController.register(context); const profileTraceEnabled = isProfileTraceEnabled(context); + const projectConfigActions: ProjectConfigPromptActions = { + hasClient: () => client !== undefined, + restartClient, + log, + }; videStatusController = new VideStatusController({ - createManifest: (rootUris) => createProjectConfigsFromRootUris(context, rootUris), + createManifest: (rootUris) => createProjectConfigsFromRootUris( + context, + rootUris, + projectConfigActions, + ), profileDiagnostics: profileTraceEnabled ? async () => { await vscode.commands.executeCommand(profileDiagnosticsCommand); @@ -474,7 +313,7 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(configurationRegistration); await startClient(context); - void promptForMissingProjectConfigs(context); + void promptForMissingProjectConfigs(context, projectConfigActions); log('[INFO] Vide extension activated'); } diff --git a/editors/vscode/src/node/projectConfigPrompt.ts b/editors/vscode/src/node/projectConfigPrompt.ts new file mode 100644 index 00000000..d9e9fb88 --- /dev/null +++ b/editors/vscode/src/node/projectConfigPrompt.ts @@ -0,0 +1,190 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +import { + DEFAULT_PROJECT_CONFIG_TEXT, + PROJECT_CONFIG_FILE_NAMES, + PROJECT_CONFIG_FILE_NAME, + PROJECT_SOURCE_FILE_GLOB, + getProjectConfigPath, +} from '../projectConfig'; + +type Logger = (message: string) => void; + +export type ProjectConfigPromptActions = { + hasClient: () => boolean; + restartClient: (context: vscode.ExtensionContext) => Promise; + log: Logger; +}; + +type ProjectConfigTarget = { + folderName: string; + configPath: string; +}; + +export async function promptForMissingProjectConfigs( + context: vscode.ExtensionContext, + actions: ProjectConfigPromptActions, +): Promise { + const targets = await findMissingProjectConfigTargets(actions.log); + + if (targets.length === 0) { + return; + } + + const createConfigAction = + targets.length === 1 + ? vscode.l10n.t('Create Manifest') + : vscode.l10n.t('Create Manifests'); + const restartNotice = vscode.l10n.t( + 'Creating a manifest will restart the Vide language server so the workspace can reload it.', + ); + const promptMessage = + targets.length === 1 + ? vscode.l10n.t( + 'No Vide project manifest was detected in {0}. Project-aware features like semantic diagnostics, navigation, and references may be severely limited. {1}', + targets[0].folderName, + restartNotice, + ) + : vscode.l10n.t( + 'No Vide project manifest was detected in {0} workspace folders. Project-aware features like semantic diagnostics, navigation, and references may be severely limited. {1}', + targets.length, + restartNotice, + ); + + const selection = await vscode.window.showWarningMessage(promptMessage, createConfigAction); + if (selection !== createConfigAction) { + return; + } + + await createProjectConfigs(context, targets, actions); +} + +export async function createProjectConfigsFromRootUris( + context: vscode.ExtensionContext, + rootUris: readonly string[], + actions: ProjectConfigPromptActions, +): Promise { + await createProjectConfigs(context, projectConfigTargetsFromRootUris(rootUris), actions); +} + +async function findMissingProjectConfigTargets(log: Logger): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + const targets: ProjectConfigTarget[] = []; + + for (const folder of workspaceFolders) { + if (folder.uri.scheme !== 'file') { + log( + `[WARN] Skipping project config creation for non-file workspace: ${folder.uri.toString()}`, + ); + continue; + } + + const existingConfigPath = PROJECT_CONFIG_FILE_NAMES + .map((fileName) => getProjectConfigPath(folder.uri.fsPath, fileName)) + .find((configPath) => fs.existsSync(configPath)); + if (existingConfigPath) { + log(`[INFO] Found project config: ${existingConfigPath}`); + continue; + } + + const sourceFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(folder, PROJECT_SOURCE_FILE_GLOB), + undefined, + 1, + ); + if (sourceFiles.length === 0) { + log( + `[INFO] Skipping project config prompt for workspace without Verilog/SystemVerilog files: ${folder.name}`, + ); + continue; + } + + const configPath = getProjectConfigPath(folder.uri.fsPath); + targets.push({ folderName: folder.name, configPath }); + } + + return targets; +} + +function projectConfigTargetsFromRootUris(rootUris: readonly string[]): ProjectConfigTarget[] { + return rootUris.map((rootUri) => { + const uri = vscode.Uri.parse(rootUri); + return { + folderName: path.basename(uri.fsPath), + configPath: getProjectConfigPath(uri.fsPath), + }; + }); +} + +async function createProjectConfigs( + context: vscode.ExtensionContext, + targets: readonly ProjectConfigTarget[], + actions: ProjectConfigPromptActions, +): Promise { + if (targets.length === 0) { + return; + } + + const createdConfigs: vscode.Uri[] = []; + + for (const { folderName, configPath } of targets) { + try { + await fs.promises.writeFile(configPath, DEFAULT_PROJECT_CONFIG_TEXT, { + encoding: 'utf8', + flag: 'wx', + }); + createdConfigs.push(vscode.Uri.file(configPath)); + actions.log(`[INFO] Created default project config: ${configPath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + actions.log(`[INFO] Project config already exists: ${configPath}`); + continue; + } + + const errorMessage = vscode.l10n.t( + 'Failed to create {0} in {1}: {2}', + PROJECT_CONFIG_FILE_NAME, + folderName, + (error as Error).message, + ); + actions.log(`[WARN] ${errorMessage}`); + void vscode.window.showWarningMessage(errorMessage); + } + } + + if (createdConfigs.length === 0) { + return; + } + + if (actions.hasClient()) { + await actions.restartClient(context); + } + + const createdMessage = + createdConfigs.length === 1 + ? vscode.l10n.t('Created {0}.', PROJECT_CONFIG_FILE_NAME) + : vscode.l10n.t( + 'Created {0} in {1} workspace folders.', + PROJECT_CONFIG_FILE_NAME, + createdConfigs.length, + ); + const openConfigAction = + createdConfigs.length === 1 + ? vscode.l10n.t('Open Manifest') + : vscode.l10n.t('Open First Manifest'); + + void vscode.window.showInformationMessage(createdMessage, openConfigAction).then(async (selection) => { + if (selection !== openConfigAction) { + return; + } + + try { + await vscode.window.showTextDocument(createdConfigs[0]); + } catch (error) { + actions.log(`[WARN] Failed to open ${PROJECT_CONFIG_FILE_NAME}: ${(error as Error).message}`); + } + }); +} From 3ad06d7ac0e267322a0d15d83356d49873bf0f7a Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:12:41 +0800 Subject: [PATCH 08/17] refactor(vscode): move node client lifecycle --- editors/vscode/src/extension.ts | 175 ++++-------------- editors/vscode/src/node/clientController.ts | 165 +++++++++++++++++ .../vscode/src/node/projectConfigPrompt.ts | 4 +- 3 files changed, 198 insertions(+), 146 deletions(-) create mode 100644 editors/vscode/src/node/clientController.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 362c2ca4..b8e37e1e 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -1,27 +1,14 @@ import * as vscode from 'vscode'; -import { - LanguageClient, - type ServerOptions, -} from 'vscode-languageclient/node'; import { registerDiagnosticActions } from './diagnosticActions'; import { profileDiagnosticsCommand } from './profiling'; import { - projectStatusNotification, reloadWorkspaceCommand, - reloadWorkspaceRequest, showOutputCommand, showStatusCommand, VideStatusController, } from './videStatus'; import type { ServerStatus } from './status'; -import { createNodeClientOptions } from './common/clientOptions'; -import { createProvideExpandedRenameEdits } from './common/renameMiddleware'; -import { - createServerEnv, - readConfiguration, - resolveServerLaunch, -} from './node/serverLaunch'; import { extensionBuildLabel, isProfileTraceEnabled, @@ -29,16 +16,17 @@ import { import { registerProfilingIntegration } from './node/profilingIntegration'; import { showServerVersion } from './node/serverVersion'; import { QiheController } from './node/qihe'; +import { NodeClientController } from './node/clientController'; import { createProjectConfigsFromRootUris, promptForMissingProjectConfigs, type ProjectConfigPromptActions, } from './node/projectConfigPrompt'; -let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel | undefined; let videStatusController: VideStatusController | undefined; let qiheController: QiheController | undefined; +let clientController: NodeClientController | undefined; const restartServerCommand = 'vide.restartServer'; const showServerVersionCommand = 'vide.showServerVersion'; @@ -73,126 +61,6 @@ function updateServerStatus(status: ServerStatus, detail?: string): void { videStatusController?.updateServerStatus(status, detail); } -function registerProjectStatusNotifications(languageClient: LanguageClient): void { - languageClient.onNotification(projectStatusNotification, (params: unknown) => { - videStatusController?.handleProjectNotification(params); - }); -} - -async function createClient(context: vscode.ExtensionContext): Promise { - const channel = requireOutputChannel(); - log('[INFO] Creating language client...'); - - const config = readConfiguration(); - const launch = resolveServerLaunch(context, config, log); - const serverArgs = [...launch.args, ...launch.additionalArgs]; - - const commonEnv = { - ...createServerEnv(), - }; - - const serverOptions: ServerOptions = { - run: { - command: launch.command, - args: serverArgs, - options: { cwd: launch.cwd, env: commonEnv }, - }, - debug: { - command: launch.command, - args: serverArgs, - options: { - cwd: launch.cwd, - env: createServerEnv('debug', 'full'), - }, - }, - }; - - const clientOptions = createNodeClientOptions({ - outputChannel: channel, - trace: config.trace, - provideRenameEdits: createProvideExpandedRenameEdits( - () => client, - (message) => log(`[WARN] ${message}`), - ), - }); - - log('[INFO] Creating LanguageClient instance...'); - return new LanguageClient( - 'vide', - vscode.l10n.t('Vide Language Server'), - serverOptions, - clientOptions, - ); -} - -async function startClient(context: vscode.ExtensionContext): Promise { - try { - updateServerStatus('starting'); - log('[INFO] Starting language server...'); - client = await createClient(context); - registerProjectStatusNotifications(client); - qiheController?.registerNotifications(client); - await client.start(); - log('[INFO] Language server started successfully'); - updateServerStatus('ready'); - } catch (error) { - const message = (error as Error).message; - client = undefined; - log(`[ERROR] Failed to start language server: ${message}`); - log(`[ERROR] ${(error as Error).stack}`); - updateServerStatus('error', message); - await showLanguageServerErrorMessage( - vscode.l10n.t('Failed to start Vide Language Server: {0}', message), - ); - } -} - -async function stopClient(): Promise { - if (!client) { - updateServerStatus('stopped'); - return; - } - - updateServerStatus('stopping'); - log('[INFO] Stopping language server...'); - try { - await client.stop(); - log('[INFO] Language server stopped'); - } catch (error) { - log(`[ERROR] Error stopping language server: ${(error as Error).message}`); - } finally { - client = undefined; - updateServerStatus('stopped'); - } -} - -async function restartClient(context: vscode.ExtensionContext): Promise { - log('[INFO] Restarting language server...'); - await stopClient(); - await startClient(context); -} - -async function reloadWorkspace(): Promise { - if (!client) { - await showLanguageServerErrorMessage(vscode.l10n.t('Vide language server is not running.')); - return; - } - - try { - await client.sendRequest('workspace/executeCommand', { - command: reloadWorkspaceRequest, - arguments: [], - }); - } catch (error) { - const message = vscode.l10n.t( - 'Failed to reload Vide project configuration: {0}', - (error as Error).message, - ); - log(`[ERROR] ${message}`); - await showLanguageServerErrorMessage(message); - } -} - function affectsServerLaunchConfiguration(event: vscode.ConfigurationChangeEvent): boolean { return ( event.affectsConfiguration('vide.server.command') || @@ -207,15 +75,29 @@ export async function activate(context: vscode.ExtensionContext): Promise outputChannel = vscode.window.createOutputChannel(languageServerOutputChannelName); context.subscriptions.push(outputChannel); qiheController = new QiheController({ - getClient: () => client, + getClient: () => clientController?.getClient(), logLanguageServer: log, showLanguageServerErrorMessage, }); qiheController.register(context); + clientController = new NodeClientController(context, { + outputChannel: requireOutputChannel(), + log, + updateServerStatus, + showLanguageServerErrorMessage, + handleProjectStatusNotification: (params) => { + videStatusController?.handleProjectNotification(params); + }, + registerQiheNotifications: (languageClient) => { + qiheController?.registerNotifications(languageClient); + }, + }); const profileTraceEnabled = isProfileTraceEnabled(context); const projectConfigActions: ProjectConfigPromptActions = { - hasClient: () => client !== undefined, - restartClient, + hasClient: () => clientController?.hasClient() ?? false, + restartClient: async () => { + await clientController?.restart(); + }, log, }; videStatusController = new VideStatusController({ @@ -229,8 +111,12 @@ export async function activate(context: vscode.ExtensionContext): Promise await vscode.commands.executeCommand(profileDiagnosticsCommand); } : undefined, - reloadProject: reloadWorkspace, - restartServer: () => restartClient(context), + reloadProject: async () => { + await clientController?.reloadWorkspace(); + }, + restartServer: async () => { + await clientController?.restart(); + }, showOutput, log, }); @@ -252,7 +138,7 @@ export async function activate(context: vscode.ExtensionContext): Promise restartServerCommand, async () => { log('[INFO] Restart command triggered'); - await restartClient(context); + await clientController?.restart(); }, ); context.subscriptions.push(restartCommandRegistration); @@ -277,7 +163,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const reloadWorkspaceRegistration = vscode.commands.registerCommand( reloadWorkspaceCommand, async () => { - await reloadWorkspace(); + await clientController?.reloadWorkspace(); }, ); context.subscriptions.push(reloadWorkspaceRegistration); @@ -306,13 +192,13 @@ export async function activate(context: vscode.ExtensionContext): Promise restartAction, ); if (selection === restartAction) { - await restartClient(context); + await clientController?.restart(); } }, ); context.subscriptions.push(configurationRegistration); - await startClient(context); + await clientController.start(); void promptForMissingProjectConfigs(context, projectConfigActions); log('[INFO] Vide extension activated'); @@ -325,7 +211,8 @@ export async function deactivate(): Promise { if (outputChannel) { log('[INFO] Vide extension deactivating...'); } - await stopClient(); + await clientController?.stop(); + clientController = undefined; if (outputChannel) { log('[INFO] Vide extension deactivated'); } diff --git a/editors/vscode/src/node/clientController.ts b/editors/vscode/src/node/clientController.ts new file mode 100644 index 00000000..bd8a27e5 --- /dev/null +++ b/editors/vscode/src/node/clientController.ts @@ -0,0 +1,165 @@ +import * as vscode from 'vscode'; +import { + LanguageClient, + type ServerOptions, +} from 'vscode-languageclient/node'; + +import { createNodeClientOptions } from '../common/clientOptions'; +import { createProvideExpandedRenameEdits } from '../common/renameMiddleware'; +import { + projectStatusNotification, + reloadWorkspaceRequest, +} from '../videStatus'; +import type { ServerStatus } from '../status'; +import { + createServerEnv, + readConfiguration, + resolveServerLaunch, +} from './serverLaunch'; + +type Logger = (message: string) => void; + +export class NodeClientController { + private client: LanguageClient | undefined; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly options: { + outputChannel: vscode.OutputChannel; + log: Logger; + updateServerStatus: (status: ServerStatus, detail?: string) => void; + showLanguageServerErrorMessage: (message: string) => Promise; + handleProjectStatusNotification: (params: unknown) => void; + registerQiheNotifications: (client: LanguageClient) => void; + }, + ) {} + + getClient(): LanguageClient | undefined { + return this.client; + } + + hasClient(): boolean { + return this.client !== undefined; + } + + async start(): Promise { + try { + this.options.updateServerStatus('starting'); + this.options.log('[INFO] Starting language server...'); + this.client = await this.createClient(); + this.registerProjectStatusNotifications(this.client); + this.options.registerQiheNotifications(this.client); + await this.client.start(); + this.options.log('[INFO] Language server started successfully'); + this.options.updateServerStatus('ready'); + } catch (error) { + const message = (error as Error).message; + this.client = undefined; + this.options.log(`[ERROR] Failed to start language server: ${message}`); + this.options.log(`[ERROR] ${(error as Error).stack}`); + this.options.updateServerStatus('error', message); + await this.options.showLanguageServerErrorMessage( + vscode.l10n.t('Failed to start Vide Language Server: {0}', message), + ); + } + } + + async stop(): Promise { + if (!this.client) { + this.options.updateServerStatus('stopped'); + return; + } + + this.options.updateServerStatus('stopping'); + this.options.log('[INFO] Stopping language server...'); + try { + await this.client.stop(); + this.options.log('[INFO] Language server stopped'); + } catch (error) { + this.options.log(`[ERROR] Error stopping language server: ${(error as Error).message}`); + } finally { + this.client = undefined; + this.options.updateServerStatus('stopped'); + } + } + + async restart(): Promise { + this.options.log('[INFO] Restarting language server...'); + await this.stop(); + await this.start(); + } + + async reloadWorkspace(): Promise { + if (!this.client) { + await this.options.showLanguageServerErrorMessage( + vscode.l10n.t('Vide language server is not running.'), + ); + return; + } + + try { + await this.client.sendRequest('workspace/executeCommand', { + command: reloadWorkspaceRequest, + arguments: [], + }); + } catch (error) { + const message = vscode.l10n.t( + 'Failed to reload Vide project configuration: {0}', + (error as Error).message, + ); + this.options.log(`[ERROR] ${message}`); + await this.options.showLanguageServerErrorMessage(message); + } + } + + private async createClient(): Promise { + this.options.log('[INFO] Creating language client...'); + + const config = readConfiguration(); + const launch = resolveServerLaunch(this.context, config, this.options.log); + const serverArgs = [...launch.args, ...launch.additionalArgs]; + + const commonEnv = { + ...createServerEnv(), + }; + + const serverOptions: ServerOptions = { + run: { + command: launch.command, + args: serverArgs, + options: { cwd: launch.cwd, env: commonEnv }, + }, + debug: { + command: launch.command, + args: serverArgs, + options: { + cwd: launch.cwd, + env: createServerEnv('debug', 'full'), + }, + }, + }; + + const clientOptions = createNodeClientOptions({ + outputChannel: this.options.outputChannel, + trace: config.trace, + provideRenameEdits: createProvideExpandedRenameEdits( + () => this.client, + (message) => this.options.log(`[WARN] ${message}`), + ), + }); + + this.options.log('[INFO] Creating LanguageClient instance...'); + return new LanguageClient( + 'vide', + vscode.l10n.t('Vide Language Server'), + serverOptions, + clientOptions, + ); + } + + private registerProjectStatusNotifications(languageClient: LanguageClient): void { + languageClient.onNotification(projectStatusNotification, (params: unknown) => { + this.options.handleProjectStatusNotification(params); + }); + } +} diff --git a/editors/vscode/src/node/projectConfigPrompt.ts b/editors/vscode/src/node/projectConfigPrompt.ts index d9e9fb88..d0f5f8ea 100644 --- a/editors/vscode/src/node/projectConfigPrompt.ts +++ b/editors/vscode/src/node/projectConfigPrompt.ts @@ -15,7 +15,7 @@ type Logger = (message: string) => void; export type ProjectConfigPromptActions = { hasClient: () => boolean; - restartClient: (context: vscode.ExtensionContext) => Promise; + restartClient: () => Promise; log: Logger; }; @@ -160,7 +160,7 @@ async function createProjectConfigs( } if (actions.hasClient()) { - await actions.restartClient(context); + await actions.restartClient(); } const createdMessage = From 5578a7ee9a65f1743374f5b73dec4580c7573401 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:13:38 +0800 Subject: [PATCH 09/17] test(vscode): assert package content contracts --- editors/vscode/scripts/package/manifest.ts | 1 + editors/vscode/test/package.contract.test.ts | 131 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 editors/vscode/test/package.contract.test.ts diff --git a/editors/vscode/scripts/package/manifest.ts b/editors/vscode/scripts/package/manifest.ts index 44185f31..87cd5e03 100644 --- a/editors/vscode/scripts/package/manifest.ts +++ b/editors/vscode/scripts/package/manifest.ts @@ -58,6 +58,7 @@ export function stageDistFilesForTarget(context: PackageContext, plan: PackagePl const distDir = path.join(context.vscodeDir, 'dist'); if (plan.targetSpec.kind === 'web') { fs.rmSync(path.join(distDir, 'extension.js'), { force: true }); + fs.rmSync(path.join(distDir, 'node'), { recursive: true, force: true }); return; } diff --git a/editors/vscode/test/package.contract.test.ts b/editors/vscode/test/package.contract.test.ts new file mode 100644 index 00000000..989be915 --- /dev/null +++ b/editors/vscode/test/package.contract.test.ts @@ -0,0 +1,131 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; + +import type { PackageContext } from '../scripts/package/context'; +import { + restorePackageJson, + stageDistFilesForTarget, + stagePackageJsonForTarget, +} from '../scripts/package/manifest'; +import { createPackagePlan } from '../scripts/package/targets'; + +describe('package content contract', () => { + it('stages web packages without desktop entry points or node artifacts', () => { + const context = temporaryPackageContext(); + writePackageJson(context, { + main: './dist/extension.js', + browser: './dist/browser/extension.js', + contributes: { + commands: [ + { command: 'vide.profileDiagnostics' }, + { command: 'vide.showOutput' }, + ], + }, + }); + const nodeBundle = writeFile( + context, + 'dist/extension.js', + 'require("vscode-languageclient/node"); require("./node/serverLaunch");', + ); + const nodeArtifact = writeFile( + context, + 'dist/node/serverLaunch.js', + 'require("node:fs");', + ); + const browserBundle = writeFile( + context, + 'dist/browser/extension.js', + 'require("vscode-languageclient/browser");', + ); + + const plan = createPackagePlan({ + target: 'web', + profile: 'release', + serverMode: 'build', + }); + stageDistFilesForTarget(context, plan); + const originalPackageJson = stagePackageJsonForTarget(context, plan); + const packageJson = readPackageJson(context) as { + main?: unknown; + browser?: unknown; + contributes?: { commands?: Array<{ command?: unknown }> }; + }; + + assert.equal(packageJson.main, undefined); + assert.equal(packageJson.browser, './dist/browser/extension.js'); + assert.equal(fs.existsSync(nodeBundle), false); + assert.equal(fs.existsSync(nodeArtifact), false); + assert.equal(fs.existsSync(browserBundle), true); + assert.doesNotMatch(fs.readFileSync(browserBundle, 'utf8'), /vscode-languageclient\/node/); + assert.doesNotMatch(fs.readFileSync(browserBundle, 'utf8'), /node\/serverLaunch/); + assert.deepEqual(packageJson.contributes?.commands, [{ command: 'vide.showOutput' }]); + + restorePackageJson(context, originalPackageJson); + }); + + it('stages native packages without browser entry points or browser artifacts', () => { + const context = temporaryPackageContext(); + writePackageJson(context, { + main: './dist/extension.js', + browser: './dist/browser/extension.js', + contributes: { + commands: [ + { command: 'vide.profileDiagnostics' }, + { command: 'vide.showOutput' }, + ], + }, + }); + const nodeBundle = writeFile(context, 'dist/extension.js', 'desktop bundle'); + const browserBundle = writeFile(context, 'dist/browser/extension.js', 'browser bundle'); + + const plan = createPackagePlan({ + target: 'linux-x64', + profile: 'release', + serverMode: 'build', + }); + stageDistFilesForTarget(context, plan); + const originalPackageJson = stagePackageJsonForTarget(context, plan); + const packageJson = readPackageJson(context) as { + main?: unknown; + browser?: unknown; + contributes?: { commands?: Array<{ command?: unknown }> }; + }; + + assert.equal(packageJson.main, './dist/extension.js'); + assert.equal(packageJson.browser, undefined); + assert.equal(fs.existsSync(nodeBundle), true); + assert.equal(fs.existsSync(browserBundle), false); + assert.deepEqual(packageJson.contributes?.commands, [{ command: 'vide.showOutput' }]); + + restorePackageJson(context, originalPackageJson); + }); +}); + +function temporaryPackageContext(): PackageContext { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vide-package-contract-')); + return { + vscodeDir: root, + repoRoot: root, + }; +} + +function writePackageJson(context: PackageContext, packageJson: unknown): void { + fs.writeFileSync( + path.join(context.vscodeDir, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n`, + ); +} + +function readPackageJson(context: PackageContext): unknown { + return JSON.parse(fs.readFileSync(path.join(context.vscodeDir, 'package.json'), 'utf8')); +} + +function writeFile(context: PackageContext, relativePath: string, contents: string): string { + const filePath = path.join(context.vscodeDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); + return filePath; +} From 86f650813836880ff4f20cda617396fdb9bb027f Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:14:21 +0800 Subject: [PATCH 10/17] refactor(vscode): normalize node entrypoint --- editors/vscode/src/extension.ts | 220 +-------------------------- editors/vscode/src/node/extension.ts | 219 ++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 219 deletions(-) create mode 100644 editors/vscode/src/node/extension.ts diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index b8e37e1e..3e640248 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -1,219 +1 @@ -import * as vscode from 'vscode'; - -import { registerDiagnosticActions } from './diagnosticActions'; -import { profileDiagnosticsCommand } from './profiling'; -import { - reloadWorkspaceCommand, - showOutputCommand, - showStatusCommand, - VideStatusController, -} from './videStatus'; -import type { ServerStatus } from './status'; -import { - extensionBuildLabel, - isProfileTraceEnabled, -} from './node/buildInfo'; -import { registerProfilingIntegration } from './node/profilingIntegration'; -import { showServerVersion } from './node/serverVersion'; -import { QiheController } from './node/qihe'; -import { NodeClientController } from './node/clientController'; -import { - createProjectConfigsFromRootUris, - promptForMissingProjectConfigs, - type ProjectConfigPromptActions, -} from './node/projectConfigPrompt'; - -let outputChannel: vscode.OutputChannel | undefined; -let videStatusController: VideStatusController | undefined; -let qiheController: QiheController | undefined; -let clientController: NodeClientController | undefined; - -const restartServerCommand = 'vide.restartServer'; -const showServerVersionCommand = 'vide.showServerVersion'; -// Output channel names are stable identifiers in the Output view. -const languageServerOutputChannelName = 'Vide Language Server'; - -function log(message: string): void { - outputChannel?.appendLine(message); -} - -function requireOutputChannel(): vscode.OutputChannel { - if (!outputChannel) { - throw new Error(vscode.l10n.t('Vide output channel has not been initialized.')); - } - - return outputChannel; -} - -function showOutput(): void { - requireOutputChannel().show(true); -} - -async function showLanguageServerErrorMessage(message: string): Promise { - const showOutputAction = vscode.l10n.t('Show Output'); - const selection = await vscode.window.showErrorMessage(message, showOutputAction); - if (selection === showOutputAction) { - showOutput(); - } -} - -function updateServerStatus(status: ServerStatus, detail?: string): void { - videStatusController?.updateServerStatus(status, detail); -} - -function affectsServerLaunchConfiguration(event: vscode.ConfigurationChangeEvent): boolean { - return ( - event.affectsConfiguration('vide.server.command') || - event.affectsConfiguration('vide.server.args') || - event.affectsConfiguration('vide.server.additionalArgs') || - event.affectsConfiguration('vide.server.cwd') || - event.affectsConfiguration('vide.trace.server') - ); -} - -export async function activate(context: vscode.ExtensionContext): Promise { - outputChannel = vscode.window.createOutputChannel(languageServerOutputChannelName); - context.subscriptions.push(outputChannel); - qiheController = new QiheController({ - getClient: () => clientController?.getClient(), - logLanguageServer: log, - showLanguageServerErrorMessage, - }); - qiheController.register(context); - clientController = new NodeClientController(context, { - outputChannel: requireOutputChannel(), - log, - updateServerStatus, - showLanguageServerErrorMessage, - handleProjectStatusNotification: (params) => { - videStatusController?.handleProjectNotification(params); - }, - registerQiheNotifications: (languageClient) => { - qiheController?.registerNotifications(languageClient); - }, - }); - const profileTraceEnabled = isProfileTraceEnabled(context); - const projectConfigActions: ProjectConfigPromptActions = { - hasClient: () => clientController?.hasClient() ?? false, - restartClient: async () => { - await clientController?.restart(); - }, - log, - }; - videStatusController = new VideStatusController({ - createManifest: (rootUris) => createProjectConfigsFromRootUris( - context, - rootUris, - projectConfigActions, - ), - profileDiagnostics: profileTraceEnabled - ? async () => { - await vscode.commands.executeCommand(profileDiagnosticsCommand); - } - : undefined, - reloadProject: async () => { - await clientController?.reloadWorkspace(); - }, - restartServer: async () => { - await clientController?.restart(); - }, - showOutput, - log, - }); - context.subscriptions.push(videStatusController); - updateServerStatus('stopped'); - - log('[INFO] Vide extension activating...'); - log(`[INFO] Extension version: ${extensionBuildLabel(context)}`); - log(`[INFO] Extension path: ${context.extensionPath}`); - log(`[INFO] Platform: ${process.platform}-${process.arch}`); - log(`[INFO] VS Code version: ${vscode.version}`); - - const showOutputRegistration = vscode.commands.registerCommand(showOutputCommand, () => { - showOutput(); - }); - context.subscriptions.push(showOutputRegistration); - - const restartCommandRegistration = vscode.commands.registerCommand( - restartServerCommand, - async () => { - log('[INFO] Restart command triggered'); - await clientController?.restart(); - }, - ); - context.subscriptions.push(restartCommandRegistration); - - const showVersionRegistration = vscode.commands.registerCommand( - showServerVersionCommand, - async () => { - log('[INFO] Server version command triggered'); - await showServerVersion(context, { log, showLanguageServerErrorMessage }); - }, - ); - context.subscriptions.push(showVersionRegistration); - - const profilingRegistration = registerProfilingIntegration(context, { - enabled: profileTraceEnabled, - log, - }); - if (profilingRegistration) { - context.subscriptions.push(profilingRegistration); - } - - const reloadWorkspaceRegistration = vscode.commands.registerCommand( - reloadWorkspaceCommand, - async () => { - await clientController?.reloadWorkspace(); - }, - ); - context.subscriptions.push(reloadWorkspaceRegistration); - - const showStatusRegistration = vscode.commands.registerCommand( - showStatusCommand, - async () => { - await videStatusController?.show(); - }, - ); - context.subscriptions.push(showStatusRegistration); - registerDiagnosticActions(context); - - const configurationRegistration = vscode.workspace.onDidChangeConfiguration( - async (event) => { - if (!affectsServerLaunchConfiguration(event)) { - return; - } - - log('[INFO] Server launch configuration changed'); - const restartAction = vscode.l10n.t('Restart'); - const selection = await vscode.window.showInformationMessage( - vscode.l10n.t( - 'Vide server configuration changed. Restart the language server to apply it.', - ), - restartAction, - ); - if (selection === restartAction) { - await clientController?.restart(); - } - }, - ); - context.subscriptions.push(configurationRegistration); - - await clientController.start(); - void promptForMissingProjectConfigs(context, projectConfigActions); - - log('[INFO] Vide extension activated'); -} - -export async function deactivate(): Promise { - qiheController?.dispose(); - qiheController = undefined; - - if (outputChannel) { - log('[INFO] Vide extension deactivating...'); - } - await clientController?.stop(); - clientController = undefined; - if (outputChannel) { - log('[INFO] Vide extension deactivated'); - } -} +export { activate, deactivate } from './node/extension'; diff --git a/editors/vscode/src/node/extension.ts b/editors/vscode/src/node/extension.ts new file mode 100644 index 00000000..ca4d360e --- /dev/null +++ b/editors/vscode/src/node/extension.ts @@ -0,0 +1,219 @@ +import * as vscode from 'vscode'; + +import { registerDiagnosticActions } from '../diagnosticActions'; +import { profileDiagnosticsCommand } from '../profiling'; +import { + reloadWorkspaceCommand, + showOutputCommand, + showStatusCommand, + VideStatusController, +} from '../videStatus'; +import type { ServerStatus } from '../status'; +import { + extensionBuildLabel, + isProfileTraceEnabled, +} from './buildInfo'; +import { registerProfilingIntegration } from './profilingIntegration'; +import { showServerVersion } from './serverVersion'; +import { QiheController } from './qihe'; +import { NodeClientController } from './clientController'; +import { + createProjectConfigsFromRootUris, + promptForMissingProjectConfigs, + type ProjectConfigPromptActions, +} from './projectConfigPrompt'; + +let outputChannel: vscode.OutputChannel | undefined; +let videStatusController: VideStatusController | undefined; +let qiheController: QiheController | undefined; +let clientController: NodeClientController | undefined; + +const restartServerCommand = 'vide.restartServer'; +const showServerVersionCommand = 'vide.showServerVersion'; +// Output channel names are stable identifiers in the Output view. +const languageServerOutputChannelName = 'Vide Language Server'; + +function log(message: string): void { + outputChannel?.appendLine(message); +} + +function requireOutputChannel(): vscode.OutputChannel { + if (!outputChannel) { + throw new Error(vscode.l10n.t('Vide output channel has not been initialized.')); + } + + return outputChannel; +} + +function showOutput(): void { + requireOutputChannel().show(true); +} + +async function showLanguageServerErrorMessage(message: string): Promise { + const showOutputAction = vscode.l10n.t('Show Output'); + const selection = await vscode.window.showErrorMessage(message, showOutputAction); + if (selection === showOutputAction) { + showOutput(); + } +} + +function updateServerStatus(status: ServerStatus, detail?: string): void { + videStatusController?.updateServerStatus(status, detail); +} + +function affectsServerLaunchConfiguration(event: vscode.ConfigurationChangeEvent): boolean { + return ( + event.affectsConfiguration('vide.server.command') || + event.affectsConfiguration('vide.server.args') || + event.affectsConfiguration('vide.server.additionalArgs') || + event.affectsConfiguration('vide.server.cwd') || + event.affectsConfiguration('vide.trace.server') + ); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + outputChannel = vscode.window.createOutputChannel(languageServerOutputChannelName); + context.subscriptions.push(outputChannel); + qiheController = new QiheController({ + getClient: () => clientController?.getClient(), + logLanguageServer: log, + showLanguageServerErrorMessage, + }); + qiheController.register(context); + clientController = new NodeClientController(context, { + outputChannel: requireOutputChannel(), + log, + updateServerStatus, + showLanguageServerErrorMessage, + handleProjectStatusNotification: (params) => { + videStatusController?.handleProjectNotification(params); + }, + registerQiheNotifications: (languageClient) => { + qiheController?.registerNotifications(languageClient); + }, + }); + const profileTraceEnabled = isProfileTraceEnabled(context); + const projectConfigActions: ProjectConfigPromptActions = { + hasClient: () => clientController?.hasClient() ?? false, + restartClient: async () => { + await clientController?.restart(); + }, + log, + }; + videStatusController = new VideStatusController({ + createManifest: (rootUris) => createProjectConfigsFromRootUris( + context, + rootUris, + projectConfigActions, + ), + profileDiagnostics: profileTraceEnabled + ? async () => { + await vscode.commands.executeCommand(profileDiagnosticsCommand); + } + : undefined, + reloadProject: async () => { + await clientController?.reloadWorkspace(); + }, + restartServer: async () => { + await clientController?.restart(); + }, + showOutput, + log, + }); + context.subscriptions.push(videStatusController); + updateServerStatus('stopped'); + + log('[INFO] Vide extension activating...'); + log(`[INFO] Extension version: ${extensionBuildLabel(context)}`); + log(`[INFO] Extension path: ${context.extensionPath}`); + log(`[INFO] Platform: ${process.platform}-${process.arch}`); + log(`[INFO] VS Code version: ${vscode.version}`); + + const showOutputRegistration = vscode.commands.registerCommand(showOutputCommand, () => { + showOutput(); + }); + context.subscriptions.push(showOutputRegistration); + + const restartCommandRegistration = vscode.commands.registerCommand( + restartServerCommand, + async () => { + log('[INFO] Restart command triggered'); + await clientController?.restart(); + }, + ); + context.subscriptions.push(restartCommandRegistration); + + const showVersionRegistration = vscode.commands.registerCommand( + showServerVersionCommand, + async () => { + log('[INFO] Server version command triggered'); + await showServerVersion(context, { log, showLanguageServerErrorMessage }); + }, + ); + context.subscriptions.push(showVersionRegistration); + + const profilingRegistration = registerProfilingIntegration(context, { + enabled: profileTraceEnabled, + log, + }); + if (profilingRegistration) { + context.subscriptions.push(profilingRegistration); + } + + const reloadWorkspaceRegistration = vscode.commands.registerCommand( + reloadWorkspaceCommand, + async () => { + await clientController?.reloadWorkspace(); + }, + ); + context.subscriptions.push(reloadWorkspaceRegistration); + + const showStatusRegistration = vscode.commands.registerCommand( + showStatusCommand, + async () => { + await videStatusController?.show(); + }, + ); + context.subscriptions.push(showStatusRegistration); + registerDiagnosticActions(context); + + const configurationRegistration = vscode.workspace.onDidChangeConfiguration( + async (event) => { + if (!affectsServerLaunchConfiguration(event)) { + return; + } + + log('[INFO] Server launch configuration changed'); + const restartAction = vscode.l10n.t('Restart'); + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t( + 'Vide server configuration changed. Restart the language server to apply it.', + ), + restartAction, + ); + if (selection === restartAction) { + await clientController?.restart(); + } + }, + ); + context.subscriptions.push(configurationRegistration); + + await clientController.start(); + void promptForMissingProjectConfigs(context, projectConfigActions); + + log('[INFO] Vide extension activated'); +} + +export async function deactivate(): Promise { + qiheController?.dispose(); + qiheController = undefined; + + if (outputChannel) { + log('[INFO] Vide extension deactivating...'); + } + await clientController?.stop(); + clientController = undefined; + if (outputChannel) { + log('[INFO] Vide extension deactivated'); + } +} From 616fbb2421dcfbabd9b58eaa24a3f3b9eeec78f4 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:15:20 +0800 Subject: [PATCH 11/17] chore(vscode): sync runtime l10n bundle --- editors/vscode/l10n/bundle.l10n.zh-cn.json | 1 - 1 file changed, 1 deletion(-) diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index f33d0c94..c57d8510 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -1,6 +1,5 @@ { "Vide output channel has not been initialized.": "Vide 输出通道尚未初始化。", - "Vide Qihe output channel has not been initialized.": "Vide Qihe 输出通道尚未初始化。", "Vide language server is starting.": "Vide 语言服务器正在启动。", "Vide": "Vide", "Vide language server is running.": "Vide 语言服务器正在运行。", From 27b67a95149b3d314bdd9c49f863c5a428b8429c Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:37:53 +0800 Subject: [PATCH 12/17] refactor(vscode): share initialization options --- editors/vscode/src/browser/client.ts | 4 ++-- editors/vscode/src/common/clientOptions.ts | 2 +- editors/vscode/src/{ => common}/initializationOptions.ts | 7 +++++-- editors/vscode/src/profiling.ts | 2 +- editors/vscode/test/initializationOptions.test.ts | 2 +- editors/vscode/tsconfig.browser.json | 2 +- 6 files changed, 11 insertions(+), 8 deletions(-) rename editors/vscode/src/{ => common}/initializationOptions.ts (90%) diff --git a/editors/vscode/src/browser/client.ts b/editors/vscode/src/browser/client.ts index 46175306..9c6e5db3 100644 --- a/editors/vscode/src/browser/client.ts +++ b/editors/vscode/src/browser/client.ts @@ -9,7 +9,6 @@ import { type MessageTransports, } from "vscode-languageclient/browser"; -import { videInitializationOptions } from "../../../../packages/vide-extension-shared/src/config/initialization-options"; import type { LspTraceEntry, WorkerRequest, @@ -20,6 +19,7 @@ import { BROWSER_WORKSPACE_FOLDER_NAME, type BrowserWorkspaceSnapshot, } from "./workspaceSnapshot"; +import { serverInitializationOptions } from "../common/initializationOptions"; import { createProvideExpandedRenameEdits } from "../common/renameMiddleware"; const CLIENT_DISPOSED_MESSAGE = "Vide browser client has been disposed."; @@ -137,7 +137,7 @@ export class VideBrowserClient { name: BROWSER_WORKSPACE_FOLDER_NAME, uri: vscode.Uri.parse(this.snapshot.rootUri), }, - initializationOptions: videInitializationOptions( + initializationOptions: serverInitializationOptions( vscode.workspace.getConfiguration("vide"), ), diagnosticPullOptions: { diff --git a/editors/vscode/src/common/clientOptions.ts b/editors/vscode/src/common/clientOptions.ts index c46f94ad..c83b5212 100644 --- a/editors/vscode/src/common/clientOptions.ts +++ b/editors/vscode/src/common/clientOptions.ts @@ -4,7 +4,7 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient'; -import { serverInitializationOptions } from '../initializationOptions'; +import { serverInitializationOptions } from './initializationOptions'; type ClientMiddleware = NonNullable; diff --git a/editors/vscode/src/initializationOptions.ts b/editors/vscode/src/common/initializationOptions.ts similarity index 90% rename from editors/vscode/src/initializationOptions.ts rename to editors/vscode/src/common/initializationOptions.ts index 422f02fd..96d910ed 100644 --- a/editors/vscode/src/initializationOptions.ts +++ b/editors/vscode/src/common/initializationOptions.ts @@ -1,5 +1,8 @@ -import { USER_CONFIG_SETTINGS } from './generated/configuration'; -import type { ConfigurationReader } from './qiheCommand'; +import { USER_CONFIG_SETTINGS } from '../generated/configuration'; + +export type ConfigurationReader = { + get(section: string): T | undefined; +}; function setting(config: ConfigurationReader, section: string, fallback: T): T { return config.get(section) ?? fallback; diff --git a/editors/vscode/src/profiling.ts b/editors/vscode/src/profiling.ts index edc60612..008bd0cd 100644 --- a/editors/vscode/src/profiling.ts +++ b/editors/vscode/src/profiling.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import { stripProfileArgs } from './profilingArgs'; -import { diagnosticsProfilingInitializationOptions } from './initializationOptions'; +import { diagnosticsProfilingInitializationOptions } from './common/initializationOptions'; import { type DiagnosticProfileRequest, diagnosticsFromProfileResponse, diff --git a/editors/vscode/test/initializationOptions.test.ts b/editors/vscode/test/initializationOptions.test.ts index 16f0c89f..661432a9 100644 --- a/editors/vscode/test/initializationOptions.test.ts +++ b/editors/vscode/test/initializationOptions.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { diagnosticsProfilingInitializationOptions, serverInitializationOptions, -} from '../src/initializationOptions'; +} from '../src/common/initializationOptions'; import { USER_CONFIG_SETTINGS } from '../src/generated/configuration'; import { resolvedQiheCommand } from '../src/qiheCommand'; diff --git a/editors/vscode/tsconfig.browser.json b/editors/vscode/tsconfig.browser.json index 351d3285..7e4bc087 100644 --- a/editors/vscode/tsconfig.browser.json +++ b/editors/vscode/tsconfig.browser.json @@ -7,10 +7,10 @@ "include": [ "../../packages/vide-extension-shared/src/**/*.ts", "src/browser/**/*.ts", + "src/common/**/*.ts", "src/diagnosticActions.ts", "src/diagnosticRules.ts", "src/generated/**/*.ts", - "src/initializationOptions.ts", "src/projectConfigCommon.ts", "src/status.ts", "src/videStatus.ts" From f9497d012614abe20e30d6d3dfd7a5df39f666ba Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:39:52 +0800 Subject: [PATCH 13/17] refactor(vscode): share document selectors --- editors/vscode/src/browser/client.ts | 6 ++-- editors/vscode/src/browser/extension.ts | 3 +- editors/vscode/src/common/clientOptions.ts | 6 +--- editors/vscode/src/common/documentSelector.ts | 28 +++++++++++++++++++ editors/vscode/src/diagnosticActions.ts | 10 +++---- editors/vscode/test/documentSelector.test.ts | 25 +++++++++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 editors/vscode/src/common/documentSelector.ts create mode 100644 editors/vscode/test/documentSelector.test.ts diff --git a/editors/vscode/src/browser/client.ts b/editors/vscode/src/browser/client.ts index 9c6e5db3..b9c1f2cf 100644 --- a/editors/vscode/src/browser/client.ts +++ b/editors/vscode/src/browser/client.ts @@ -19,6 +19,7 @@ import { BROWSER_WORKSPACE_FOLDER_NAME, type BrowserWorkspaceSnapshot, } from "./workspaceSnapshot"; +import { createVideDocumentSelector } from "../common/documentSelector"; import { serverInitializationOptions } from "../common/initializationOptions"; import { createProvideExpandedRenameEdits } from "../common/renameMiddleware"; @@ -128,10 +129,7 @@ export class VideBrowserClient { ); return { - documentSelector: [ - { language: "verilog" }, - { language: "systemverilog" }, - ], + documentSelector: createVideDocumentSelector(), workspaceFolder: { index: 0, name: BROWSER_WORKSPACE_FOLDER_NAME, diff --git a/editors/vscode/src/browser/extension.ts b/editors/vscode/src/browser/extension.ts index f792fa72..c63b8e40 100644 --- a/editors/vscode/src/browser/extension.ts +++ b/editors/vscode/src/browser/extension.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { registerDiagnosticActions } from "../diagnosticActions"; +import { createVideCodeDocumentSelector } from "../common/documentSelector"; import { PROJECT_CONFIG_FILE_NAME, PROJECT_SOURCE_FILE_GLOB, @@ -335,7 +336,7 @@ export async function activate( ); } - registerDiagnosticActions(context); + registerDiagnosticActions(context, createVideCodeDocumentSelector()); registerWorkspaceWatchers(context); context.subscriptions.push( diff --git a/editors/vscode/src/common/clientOptions.ts b/editors/vscode/src/common/clientOptions.ts index c83b5212..8688a3ef 100644 --- a/editors/vscode/src/common/clientOptions.ts +++ b/editors/vscode/src/common/clientOptions.ts @@ -4,6 +4,7 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient'; +import { fileDocumentSelector } from './documentSelector'; import { serverInitializationOptions } from './initializationOptions'; type ClientMiddleware = NonNullable; @@ -14,11 +15,6 @@ export type NodeClientOptionsParams = { provideRenameEdits: NonNullable; }; -export const fileDocumentSelector: LanguageClientOptions['documentSelector'] = [ - { scheme: 'file', language: 'verilog' }, - { scheme: 'file', language: 'systemverilog' }, -]; - export function createNodeClientOptions({ outputChannel, trace, diff --git a/editors/vscode/src/common/documentSelector.ts b/editors/vscode/src/common/documentSelector.ts new file mode 100644 index 00000000..7dc231be --- /dev/null +++ b/editors/vscode/src/common/documentSelector.ts @@ -0,0 +1,28 @@ +import type * as vscode from 'vscode'; +import type { + DocumentSelector as ProtocolDocumentSelector, +} from 'vscode-languageserver-protocol'; + +const videLanguageIds = ['verilog', 'systemverilog'] as const; + +export type VideDocumentSelectorOptions = { + scheme?: string; +}; + +export function createVideDocumentSelector( + options: VideDocumentSelectorOptions = {}, +): ProtocolDocumentSelector { + return videLanguageIds.map((language) => ( + options.scheme ? { scheme: options.scheme, language } : { language } + )); +} + +export const fileDocumentSelector = createVideDocumentSelector({ scheme: 'file' }); + +export function createVideCodeDocumentSelector( + options: VideDocumentSelectorOptions = {}, +): vscode.DocumentSelector { + return createVideDocumentSelector(options) as vscode.DocumentFilter[]; +} + +export const fileCodeDocumentSelector = createVideCodeDocumentSelector({ scheme: 'file' }); diff --git a/editors/vscode/src/diagnosticActions.ts b/editors/vscode/src/diagnosticActions.ts index 5dfc9c71..6e93f17d 100644 --- a/editors/vscode/src/diagnosticActions.ts +++ b/editors/vscode/src/diagnosticActions.ts @@ -7,6 +7,7 @@ import { type DiagnosticRuleTarget, upsertDiagnosticRule, } from './diagnosticRules'; +import { fileCodeDocumentSelector } from './common/documentSelector'; export const configureDiagnosticRuleCommand = 'vide.configureDiagnosticRule'; @@ -17,12 +18,11 @@ interface ConfigureDiagnosticRuleArgs { } const diagnosticRulesSetting = 'diagnostics.slang.rules'; -const documentSelector: vscode.DocumentSelector = [ - { scheme: 'file', language: 'verilog' }, - { scheme: 'file', language: 'systemverilog' }, -]; -export function registerDiagnosticActions(context: vscode.ExtensionContext): void { +export function registerDiagnosticActions( + context: vscode.ExtensionContext, + documentSelector: vscode.DocumentSelector = fileCodeDocumentSelector, +): void { context.subscriptions.push( vscode.commands.registerCommand(configureDiagnosticRuleCommand, configureDiagnosticRule), ); diff --git a/editors/vscode/test/documentSelector.test.ts b/editors/vscode/test/documentSelector.test.ts new file mode 100644 index 00000000..c5dbdc47 --- /dev/null +++ b/editors/vscode/test/documentSelector.test.ts @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createVideCodeDocumentSelector, + createVideDocumentSelector, + fileCodeDocumentSelector, + fileDocumentSelector, +} from '../src/common/documentSelector'; + +test('creates file-scoped selectors for native extension clients', () => { + assert.deepEqual(fileDocumentSelector, [ + { scheme: 'file', language: 'verilog' }, + { scheme: 'file', language: 'systemverilog' }, + ]); + assert.deepEqual(fileCodeDocumentSelector, fileDocumentSelector); +}); + +test('creates scheme-neutral selectors for browser extension clients', () => { + assert.deepEqual(createVideDocumentSelector(), [ + { language: 'verilog' }, + { language: 'systemverilog' }, + ]); + assert.deepEqual(createVideCodeDocumentSelector(), createVideDocumentSelector()); +}); From efbb3aca0fc7099a184b6e372d2e998cef46d75a Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:40:36 +0800 Subject: [PATCH 14/17] refactor(vscode): move node client options --- editors/vscode/src/node/clientController.ts | 2 +- editors/vscode/src/{common => node}/clientOptions.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename editors/vscode/src/{common => node}/clientOptions.ts (90%) diff --git a/editors/vscode/src/node/clientController.ts b/editors/vscode/src/node/clientController.ts index bd8a27e5..981d429d 100644 --- a/editors/vscode/src/node/clientController.ts +++ b/editors/vscode/src/node/clientController.ts @@ -4,7 +4,6 @@ import { type ServerOptions, } from 'vscode-languageclient/node'; -import { createNodeClientOptions } from '../common/clientOptions'; import { createProvideExpandedRenameEdits } from '../common/renameMiddleware'; import { projectStatusNotification, @@ -16,6 +15,7 @@ import { readConfiguration, resolveServerLaunch, } from './serverLaunch'; +import { createNodeClientOptions } from './clientOptions'; type Logger = (message: string) => void; diff --git a/editors/vscode/src/common/clientOptions.ts b/editors/vscode/src/node/clientOptions.ts similarity index 90% rename from editors/vscode/src/common/clientOptions.ts rename to editors/vscode/src/node/clientOptions.ts index 8688a3ef..cf254782 100644 --- a/editors/vscode/src/common/clientOptions.ts +++ b/editors/vscode/src/node/clientOptions.ts @@ -4,8 +4,8 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient'; -import { fileDocumentSelector } from './documentSelector'; -import { serverInitializationOptions } from './initializationOptions'; +import { fileDocumentSelector } from '../common/documentSelector'; +import { serverInitializationOptions } from '../common/initializationOptions'; type ClientMiddleware = NonNullable; From 7d2d0b4c000eeb5b2b07df5ac6f621b1cd749d31 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:41:35 +0800 Subject: [PATCH 15/17] refactor(vscode): share client options core --- editors/vscode/src/browser/client.ts | 14 +++--- .../vscode/src/common/clientOptionsCore.ts | 34 +++++++++++++ editors/vscode/src/node/clientOptions.ts | 13 +++-- editors/vscode/test/clientOptionsCore.test.ts | 48 +++++++++++++++++++ 4 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 editors/vscode/src/common/clientOptionsCore.ts create mode 100644 editors/vscode/test/clientOptionsCore.test.ts diff --git a/editors/vscode/src/browser/client.ts b/editors/vscode/src/browser/client.ts index b9c1f2cf..b099ed92 100644 --- a/editors/vscode/src/browser/client.ts +++ b/editors/vscode/src/browser/client.ts @@ -19,8 +19,8 @@ import { BROWSER_WORKSPACE_FOLDER_NAME, type BrowserWorkspaceSnapshot, } from "./workspaceSnapshot"; +import { createVideClientOptionsCore } from "../common/clientOptionsCore"; import { createVideDocumentSelector } from "../common/documentSelector"; -import { serverInitializationOptions } from "../common/initializationOptions"; import { createProvideExpandedRenameEdits } from "../common/renameMiddleware"; const CLIENT_DISPOSED_MESSAGE = "Vide browser client has been disposed."; @@ -127,17 +127,19 @@ export class VideBrowserClient { () => this.requireLanguageClient(), (message) => this.onLog(message, "warn"), ); + const coreOptions = createVideClientOptionsCore({ + documentSelector: createVideDocumentSelector(), + configuration: vscode.workspace.getConfiguration("vide"), + provideRenameEdits, + }); return { - documentSelector: createVideDocumentSelector(), + ...coreOptions, workspaceFolder: { index: 0, name: BROWSER_WORKSPACE_FOLDER_NAME, uri: vscode.Uri.parse(this.snapshot.rootUri), }, - initializationOptions: serverInitializationOptions( - vscode.workspace.getConfiguration("vide"), - ), diagnosticPullOptions: { onChange: false, onSave: false, @@ -151,10 +153,10 @@ export class VideBrowserClient { closed: () => ({ action: CloseAction.DoNotRestart }), }, middleware: { + ...coreOptions.middleware, handleDiagnostics: (uri, diagnostics, next) => { next(uri, diagnostics); }, - provideRenameEdits, workspace: { configuration: () => [], }, diff --git a/editors/vscode/src/common/clientOptionsCore.ts b/editors/vscode/src/common/clientOptionsCore.ts new file mode 100644 index 00000000..03509306 --- /dev/null +++ b/editors/vscode/src/common/clientOptionsCore.ts @@ -0,0 +1,34 @@ +import type { LanguageClientOptions } from 'vscode-languageclient'; + +import { + serverInitializationOptions, + type ConfigurationReader, +} from './initializationOptions'; + +type ClientMiddleware = NonNullable; + +export type VideClientOptionsCoreParams = { + documentSelector: NonNullable; + configuration: ConfigurationReader; + provideRenameEdits: NonNullable; +}; + +export type VideClientOptionsCore = { + documentSelector: NonNullable; + initializationOptions: Record; + middleware: Pick; +}; + +export function createVideClientOptionsCore({ + documentSelector, + configuration, + provideRenameEdits, +}: VideClientOptionsCoreParams): VideClientOptionsCore { + return { + documentSelector, + initializationOptions: serverInitializationOptions(configuration), + middleware: { + provideRenameEdits, + }, + }; +} diff --git a/editors/vscode/src/node/clientOptions.ts b/editors/vscode/src/node/clientOptions.ts index cf254782..7af70b01 100644 --- a/editors/vscode/src/node/clientOptions.ts +++ b/editors/vscode/src/node/clientOptions.ts @@ -4,8 +4,8 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient'; +import { createVideClientOptionsCore } from '../common/clientOptionsCore'; import { fileDocumentSelector } from '../common/documentSelector'; -import { serverInitializationOptions } from '../common/initializationOptions'; type ClientMiddleware = NonNullable; @@ -20,21 +20,26 @@ export function createNodeClientOptions({ trace, provideRenameEdits, }: NodeClientOptionsParams): LanguageClientOptions { - return { + const coreOptions = createVideClientOptionsCore({ documentSelector: fileDocumentSelector, + configuration: vscode.workspace.getConfiguration('vide'), + provideRenameEdits, + }); + + return { + ...coreOptions, synchronize: { configurationSection: ['vide'], }, outputChannel, traceOutputChannel: outputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, - initializationOptions: serverInitializationOptions(vscode.workspace.getConfiguration('vide')), middleware: { + ...coreOptions.middleware, provideReferences: async (document, position, options, token, next) => { options.includeDeclaration = includeDeclarationInReferences(document); return await next(document, position, options, token); }, - provideRenameEdits, }, ...(trace !== 'off' && { trace }), }; diff --git a/editors/vscode/test/clientOptionsCore.test.ts b/editors/vscode/test/clientOptionsCore.test.ts new file mode 100644 index 00000000..ced448ff --- /dev/null +++ b/editors/vscode/test/clientOptionsCore.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { LanguageClientOptions } from 'vscode-languageclient'; + +import { createVideClientOptionsCore } from '../src/common/clientOptionsCore'; +import { createVideDocumentSelector } from '../src/common/documentSelector'; + +type ClientMiddleware = NonNullable; + +class TestConfiguration { + constructor(private readonly values: Record) {} + + get(section: string): T | undefined { + return this.values[section] as T | undefined; + } +} + +test('creates the runtime-neutral client options core', () => { + const documentSelector = createVideDocumentSelector(); + const provideRenameEdits: NonNullable = + async () => undefined; + + const options = createVideClientOptionsCore({ + documentSelector, + configuration: new TestConfiguration({ + 'files.excludeDirs': ['build'], + 'diagnostics.semantic.enable': false, + }), + provideRenameEdits, + }); + + assert.equal(options.documentSelector, documentSelector); + assert.equal(options.middleware.provideRenameEdits, provideRenameEdits); + assert.deepEqual(options.initializationOptions.files, { + excludeDirs: ['build'], + watcher: 'client', + }); + assert.deepEqual(options.initializationOptions.diagnostics, { + enable: true, + update: 'onSave', + parse: { enable: true }, + semantic: { enable: false }, + slang: { + warnings: ['width-expand', 'width-trunc', 'port-width-expand', 'port-width-trunc'], + rules: [], + }, + }); +}); From f831a17ee43588e4b2e15ace281ce153b3416493 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:43:25 +0800 Subject: [PATCH 16/17] refactor(vscode): add browser client controller --- .../vscode/src/browser/clientController.ts | 141 +++++++++++++++++ editors/vscode/src/browser/extension.ts | 142 ++++-------------- 2 files changed, 168 insertions(+), 115 deletions(-) create mode 100644 editors/vscode/src/browser/clientController.ts diff --git a/editors/vscode/src/browser/clientController.ts b/editors/vscode/src/browser/clientController.ts new file mode 100644 index 00000000..26977bb0 --- /dev/null +++ b/editors/vscode/src/browser/clientController.ts @@ -0,0 +1,141 @@ +import * as vscode from "vscode"; + +import type { ServerStatus } from "../status"; +import { projectStatusNotification } from "../videStatus"; +import { VideBrowserClient } from "./client"; +import { buildBrowserWorkspaceSnapshot } from "./workspaceSnapshot"; + +type Logger = (message: string) => void; + +export class BrowserClientController { + private client: VideBrowserClient | undefined; + private restartChain: Promise = Promise.resolve(); + private workspaceRestartTimer: ReturnType | undefined; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly options: { + log: Logger; + updateServerStatus: (status: ServerStatus, detail?: string) => void; + showLanguageServerErrorMessage: (message: string) => Promise; + handleProjectStatusNotification: (params: unknown) => void; + }, + ) {} + + getClient(): VideBrowserClient | undefined { + return this.client; + } + + hasClient(): boolean { + return this.client !== undefined; + } + + initializeServerInfo(): { name?: string; version?: string } | undefined { + return this.client?.initializeServerInfo(); + } + + queueRestart(reason: string): Promise { + this.restartChain = this.restartChain + .catch(() => undefined) + .then(async () => { + this.options.log(`[INFO] Restarting browser language client: ${reason}`); + await this.stopClient(); + await this.startClient(); + }); + return this.restartChain; + } + + scheduleRestart(reason: string): void { + if (this.workspaceRestartTimer) { + clearTimeout(this.workspaceRestartTimer); + } + this.workspaceRestartTimer = setTimeout(() => { + this.workspaceRestartTimer = undefined; + void this.queueRestart(reason); + }, 250); + } + + async stop(): Promise { + this.clearScheduledRestart(); + await this.stopClient(); + } + + private async startClient(): Promise { + this.options.updateServerStatus("starting"); + this.options.log("[INFO] Building browser workspace snapshot..."); + + let startedClient: VideBrowserClient | undefined; + try { + const snapshot = await buildBrowserWorkspaceSnapshot(this.options.log); + const browserClient = new VideBrowserClient(this.context, snapshot); + startedClient = browserClient; + this.client = browserClient; + + browserClient.onStatus = (status) => { + if (!this.isActiveClient(browserClient)) { + return; + } + this.options.updateServerStatus(status.ready ? "ready" : "error", status.detail); + }; + browserClient.onServerCapabilities = () => undefined; + browserClient.onLog = (message, level) => { + if (!this.isActiveClient(browserClient)) { + return; + } + this.options.log(`[${level.toUpperCase()}] ${message}`); + }; + browserClient.onTrace = (entry) => { + if (!this.isActiveClient(browserClient)) { + return; + } + this.options.log(`[TRACE] ${entry.direction} ${entry.method} ${entry.detail}`); + }; + + browserClient.start(); + browserClient.onNotification(projectStatusNotification, (params) => { + if (!this.isActiveClient(browserClient)) { + return; + } + this.options.handleProjectStatusNotification(params); + }); + this.options.log("[INFO] Browser language client booted."); + } catch (error) { + if (!startedClient || this.isActiveClient(startedClient)) { + this.client = undefined; + } + const message = + error instanceof Error + ? error.message + : "Failed to start the Vide browser extension."; + this.options.log(`[ERROR] ${message}`); + this.options.updateServerStatus("error", message); + await this.options.showLanguageServerErrorMessage( + vscode.l10n.t("Failed to start Vide Language Server: {0}", message), + ); + } + } + + private async stopClient(): Promise { + if (!this.client) { + this.options.updateServerStatus("stopped"); + return; + } + + this.options.updateServerStatus("stopping"); + this.client.dispose(); + this.client = undefined; + this.options.updateServerStatus("stopped"); + } + + private isActiveClient(browserClient: VideBrowserClient): boolean { + return this.client === browserClient; + } + + private clearScheduledRestart(): void { + if (!this.workspaceRestartTimer) { + return; + } + clearTimeout(this.workspaceRestartTimer); + this.workspaceRestartTimer = undefined; + } +} diff --git a/editors/vscode/src/browser/extension.ts b/editors/vscode/src/browser/extension.ts index c63b8e40..59a66fc8 100644 --- a/editors/vscode/src/browser/extension.ts +++ b/editors/vscode/src/browser/extension.ts @@ -9,16 +9,14 @@ import { isProjectSourceFileName, } from "../projectConfigCommon"; import { - projectStatusNotification, reloadWorkspaceCommand, showOutputCommand, showStatusCommand, VideStatusController, } from "../videStatus"; import type { ServerStatus } from "../status"; -import { VideBrowserClient } from "./client"; +import { BrowserClientController } from "./clientController"; import { - buildBrowserWorkspaceSnapshot, createProjectConfigAtRoot, shouldRestartForWatchedUri, } from "./workspaceSnapshot"; @@ -37,11 +35,9 @@ interface ExtensionBuildInfo { profileTrace?: boolean; } -let client: VideBrowserClient | undefined; let outputChannel: vscode.OutputChannel | undefined; let videStatusController: VideStatusController | undefined; -let restartChain: Promise = Promise.resolve(); -let workspaceRestartTimer: ReturnType | undefined; +let clientController: BrowserClientController | undefined; function log(message: string): void { outputChannel?.appendLine(message); @@ -106,98 +102,7 @@ async function extensionBuildLabel( return details.length > 0 ? `${version} (${details.join(", ")})` : version; } -async function startClient(context: vscode.ExtensionContext): Promise { - updateServerStatus("starting"); - log("[INFO] Building browser workspace snapshot..."); - - try { - const snapshot = await buildBrowserWorkspaceSnapshot(log); - const browserClient = new VideBrowserClient(context, snapshot); - client = browserClient; - - browserClient.onStatus = (status) => { - if (client !== browserClient) { - return; - } - updateServerStatus(status.ready ? "ready" : "error", status.detail); - }; - browserClient.onServerCapabilities = () => undefined; - browserClient.onLog = (message, level) => { - if (client !== browserClient) { - return; - } - log(`[${level.toUpperCase()}] ${message}`); - }; - browserClient.onTrace = (entry) => { - if (client !== browserClient) { - return; - } - log(`[TRACE] ${entry.direction} ${entry.method} ${entry.detail}`); - }; - - browserClient.start(); - browserClient.onNotification(projectStatusNotification, (params) => { - if (client !== browserClient) { - return; - } - videStatusController?.handleProjectNotification(params); - }); - log("[INFO] Browser language client booted."); - } catch (error) { - client = undefined; - const message = - error instanceof Error - ? error.message - : "Failed to start the Vide browser extension."; - log(`[ERROR] ${message}`); - updateServerStatus("error", message); - await showLanguageServerErrorMessage( - vscode.l10n.t("Failed to start Vide Language Server: {0}", message), - ); - } -} - -async function stopClient(): Promise { - if (!client) { - updateServerStatus("stopped"); - return; - } - - updateServerStatus("stopping"); - client.dispose(); - client = undefined; - updateServerStatus("stopped"); -} - -function queueRestart( - context: vscode.ExtensionContext, - reason: string, -): Promise { - restartChain = restartChain - .catch(() => undefined) - .then(async () => { - log(`[INFO] Restarting browser language client: ${reason}`); - await stopClient(); - await startClient(context); - }); - return restartChain; -} - -function scheduleWorkspaceRestart( - context: vscode.ExtensionContext, - reason: string, -): void { - if (workspaceRestartTimer) { - clearTimeout(workspaceRestartTimer); - } - workspaceRestartTimer = setTimeout(() => { - workspaceRestartTimer = undefined; - void queueRestart(context, reason); - }, 250); -} - async function createProjectConfigsFromRootUris( - context: vscode.ExtensionContext, rootUris: readonly string[], ): Promise { const created: vscode.Uri[] = []; @@ -205,7 +110,7 @@ async function createProjectConfigsFromRootUris( created.push(await createProjectConfigAtRoot(rootUri)); } - await queueRestart(context, "project manifest created"); + await clientController?.queueRestart("project manifest created"); const action = vscode.l10n.t("Open Manifest"); const selection = await vscode.window.showInformationMessage( @@ -228,7 +133,7 @@ async function showServerVersion( context: vscode.ExtensionContext, ): Promise { const buildLabel = await extensionBuildLabel(context); - const serverInfo = client?.initializeServerInfo(); + const serverInfo = clientController?.initializeServerInfo(); const serverLabel = serverInfo ? `${serverInfo.name ?? "Vide"} ${serverInfo.version ?? ""}`.trim() : "unavailable"; @@ -243,9 +148,7 @@ async function showUnavailableInBrowser(feature: string): Promise { ); } -function registerWorkspaceWatchers( - context: vscode.ExtensionContext, -): void { +function registerWorkspaceWatchers(context: vscode.ExtensionContext): void { const sourceWatcher = vscode.workspace.createFileSystemWatcher( PROJECT_SOURCE_FILE_GLOB, ); @@ -268,7 +171,7 @@ function registerWorkspaceWatchers( return; } log(`[INFO] Workspace ${label}: ${uri.toString()}`); - scheduleWorkspaceRestart(context, `${label}: ${uri.toString()}`); + clientController?.scheduleRestart(`${label}: ${uri.toString()}`); }; sourceWatcher.onDidCreate((uri) => handleSourceEvent(uri, "source created")); @@ -291,16 +194,28 @@ export async function activate( const profileTraceEnabled = buildInfo?.profileTrace === true; videStatusController = new VideStatusController({ - createManifest: (rootUris) => createProjectConfigsFromRootUris(context, rootUris), + createManifest: (rootUris) => createProjectConfigsFromRootUris(rootUris), profileDiagnostics: profileTraceEnabled ? () => showUnavailableInBrowser("Diagnostics profiling") : undefined, - reloadProject: () => queueRestart(context, "reload project"), - restartServer: () => queueRestart(context, "restart command"), + reloadProject: async () => { + await clientController?.queueRestart("reload project"); + }, + restartServer: async () => { + await clientController?.queueRestart("restart command"); + }, showOutput, log, }); context.subscriptions.push(videStatusController); + clientController = new BrowserClientController(context, { + log, + updateServerStatus, + showLanguageServerErrorMessage, + handleProjectStatusNotification: (params) => { + videStatusController?.handleProjectNotification(params); + }, + }); updateServerStatus("stopped"); log("[INFO] Vide browser extension activating..."); @@ -313,10 +228,10 @@ export async function activate( await videStatusController?.show(); }), vscode.commands.registerCommand(restartServerCommand, async () => { - await queueRestart(context, "restart command"); + await clientController?.queueRestart("restart command"); }), vscode.commands.registerCommand(reloadWorkspaceCommand, async () => { - await queueRestart(context, "reload project command"); + await clientController?.queueRestart("reload project command"); }), vscode.commands.registerCommand(showServerVersionCommand, async () => { await showServerVersion(context); @@ -342,19 +257,16 @@ export async function activate( context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("vide")) { - scheduleWorkspaceRestart(context, "Vide configuration changed"); + clientController?.scheduleRestart("Vide configuration changed"); } }), ); - await queueRestart(context, "activation"); + await clientController.queueRestart("activation"); log("[INFO] Vide browser extension activated."); } export async function deactivate(): Promise { - if (workspaceRestartTimer) { - clearTimeout(workspaceRestartTimer); - workspaceRestartTimer = undefined; - } - await stopClient(); + await clientController?.stop(); + clientController = undefined; } From e2d19fcb5be0a5248a967a73d5a8bd9ac3cb5367 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 3 Jul 2026 17:44:28 +0800 Subject: [PATCH 17/17] refactor(vscode): narrow browser config restarts --- editors/vscode/src/browser/configuration.ts | 13 ++++++++ editors/vscode/src/browser/extension.ts | 3 +- .../vscode/test/browserConfiguration.test.ts | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 editors/vscode/src/browser/configuration.ts create mode 100644 editors/vscode/test/browserConfiguration.test.ts diff --git a/editors/vscode/src/browser/configuration.ts b/editors/vscode/src/browser/configuration.ts new file mode 100644 index 00000000..4eeccf9d --- /dev/null +++ b/editors/vscode/src/browser/configuration.ts @@ -0,0 +1,13 @@ +import type * as vscode from "vscode"; + +import { USER_CONFIG_SETTINGS } from "../generated/configuration"; + +const browserRestartConfigurationKeys = USER_CONFIG_SETTINGS.map( + (setting) => setting.vscodeKey, +); + +export function affectsBrowserClientConfiguration( + event: Pick, +): boolean { + return browserRestartConfigurationKeys.some((key) => event.affectsConfiguration(key)); +} diff --git a/editors/vscode/src/browser/extension.ts b/editors/vscode/src/browser/extension.ts index 59a66fc8..fd135341 100644 --- a/editors/vscode/src/browser/extension.ts +++ b/editors/vscode/src/browser/extension.ts @@ -16,6 +16,7 @@ import { } from "../videStatus"; import type { ServerStatus } from "../status"; import { BrowserClientController } from "./clientController"; +import { affectsBrowserClientConfiguration } from "./configuration"; import { createProjectConfigAtRoot, shouldRestartForWatchedUri, @@ -256,7 +257,7 @@ export async function activate( context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("vide")) { + if (affectsBrowserClientConfiguration(event)) { clientController?.scheduleRestart("Vide configuration changed"); } }), diff --git a/editors/vscode/test/browserConfiguration.test.ts b/editors/vscode/test/browserConfiguration.test.ts new file mode 100644 index 00000000..06543016 --- /dev/null +++ b/editors/vscode/test/browserConfiguration.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { USER_CONFIG_SETTINGS } from '../src/generated/configuration'; +import { affectsBrowserClientConfiguration } from '../src/browser/configuration'; + +function changedSection(section: string): { affectsConfiguration(candidate: string): boolean } { + return { + affectsConfiguration: (candidate) => candidate === section, + }; +} + +test('browser restarts for generated user configuration settings', () => { + for (const setting of USER_CONFIG_SETTINGS) { + assert.equal( + affectsBrowserClientConfiguration(changedSection(setting.vscodeKey)), + true, + setting.vscodeKey, + ); + } +}); + +test('browser ignores desktop-only launch configuration settings', () => { + for (const section of [ + 'vide.server.command', + 'vide.server.args', + 'vide.server.additionalArgs', + 'vide.server.cwd', + 'vide.trace.server', + ]) { + assert.equal(affectsBrowserClientConfiguration(changedSection(section)), false, section); + } +});