diff --git a/README.md b/README.md index 0f18b8a..7fe0a57 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Startup project and solution configuration in the status bar, with build, debug, hot reload that actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush). ``` -$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) $(flame) +$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) $(flame) $(database) 4/4 ``` - The **project item** shows which `.csproj` is the startup project, and picks another one @@ -13,6 +13,8 @@ $(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) $(flame) `Test | x64`, `Release | Any CPU` — exactly as `MyGame.sln` lists them. - The **debug button** builds with that configuration and launches the startup project. - The **flame** starts it under `dotnet watch` with hot reload instead. +- The **database counter** says how many projects of the solution DotRush's language server has actually loaded. +- The **database counter** says how many projects of the solution DotRush's language server has actually loaded. ## Why, when DotRush already has a status bar item @@ -141,6 +143,26 @@ hot reload under those asks whether to switch to Debug first. before the watcher starts, so the Editor's post-build step finds the Builder even on a clean checkout; the watcher's own build is then incremental. +## Language server status + +DotRush shows a spinner while it loads the workspace and nothing afterwards, so when Find +References comes back empty there is no way to tell "no references" from "not indexed". +DotRush does raise one `projectLoaded` event per project through its exports; the +`$(database) 3/4` item counts those against the solution in the status bar and turns +warning-coloured in two cases: + +- the **startup project was never loaded**, so IntelliSense and references miss it; +- projects were loaded from **outside the selected solution** (`+1`), which is what a + `dotrush.roslyn.projectOrSolutionFiles` pointing at another checkout looks like. + +The tooltip lists loaded and missing projects. Clicking offers *Reload Workspace* (which +also resets the count), DotRush's solution picker, and its output channel. The +`dotnetSolution.languageServerStatus` command returns the same data for other +extensions; *colored-references* uses it to say *why* a search found nothing. + +The count only includes events raised while this extension was listening, so a window +where DotRush finished before this extension activated shows 0 until a reload. + ## Usage | Action | How | diff --git a/package.json b/package.json index 4520053..9b6e5fc 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,22 @@ "title": "Hot Reload: Actions", "category": ".NET Solution" }, + { + "command": "dotnetSolution.languageServerReload", + "title": "Reload Language Server Workspace (DotRush)", + "category": ".NET Solution", + "icon": "$(refresh)" + }, + { + "command": "dotnetSolution.languageServerMenu", + "title": "Language Server Status", + "category": ".NET Solution" + }, + { + "command": "dotnetSolution.languageServerStatus", + "title": "Language Server Status (as data)", + "category": ".NET Solution" + }, { "command": "dotnetSolution.generateLaunchConfig", "title": "Create launch.json and tasks.json entries", @@ -190,6 +206,10 @@ { "command": "dotnetSolution.hotReload.menu", "when": "false" + }, + { + "command": "dotnetSolution.languageServerStatus", + "when": "false" } ], "debug/toolBar": [ diff --git a/src/dotrush.ts b/src/dotrush.ts new file mode 100644 index 0000000..dff8809 --- /dev/null +++ b/src/dotrush.ts @@ -0,0 +1,153 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { SolutionModel } from './model'; + +/** What DotRush sends per project in its `dotrush/projectLoaded` notification. */ +interface LoadedProject { + name: string; + path: string; + frameworks?: string[]; + isTestProject?: boolean; +} + +interface DotRushExports { + onProjectLoaded?: { add(callback: (project: LoadedProject) => void): void }; +} + +/** Answer of the `dotnetSolution.languageServerStatus` command, for other extensions. */ +export interface LanguageServerStatus { + /** Projects of the active solution the language server reported loaded. */ + loaded: string[]; + /** Projects of the active solution it has not reported. */ + missing: string[]; + /** Loaded projects that are not in the active solution at all. */ + foreign: string[]; + /** True when DotRush is not installed, so nothing can be known. */ + unavailable: boolean; +} + +function samePath(a: string, b: string): boolean { + return path.normalize(a).toLowerCase() === path.normalize(b).toLowerCase(); +} + +/** + * A status bar item saying how much of the solution DotRush's language server has + * actually loaded. + * + * DotRush shows a spinner while loading and nothing afterwards, so when Find References + * comes back empty there is no way to tell "no references" from "not indexed". It does + * raise one `projectLoaded` event per project through its exports; this collects them + * and compares against the solution in the status bar. Two mismatches get the warning + * colour: a startup project that was never loaded, and projects loaded from *outside* + * the selected solution — which is what a `dotrush.roslyn.projectOrSolutionFiles` + * pointing at another checkout looks like. + */ +export class LanguageServerIndicator implements vscode.Disposable { + private readonly item: vscode.StatusBarItem; + private readonly loaded = new Map(); + private available = false; + private readonly subscriptions: vscode.Disposable[] = []; + + constructor(private readonly model: SolutionModel, private readonly log: vscode.OutputChannel) { + this.item = vscode.window.createStatusBarItem('dotnetSolution.languageServer', vscode.StatusBarAlignment.Left, 100.2); + this.item.name = '.NET Solution: Language Server'; + this.item.command = 'dotnetSolution.languageServerMenu'; + this.subscriptions.push(this.item, model.onDidChange(() => this.render())); + + const dotrush = vscode.extensions.getExtension('nromanov.dotrush'); + if (dotrush) { + this.available = true; + void dotrush.activate().then(exports => { + exports?.onProjectLoaded?.add(project => { + if (!project?.path) { + return; + } + this.loaded.set(path.normalize(project.path).toLowerCase(), project); + this.log.appendLine(`DotRush loaded ${project.name} (${project.path})`); + this.render(); + }); + }, error => this.log.appendLine(`DotRush did not activate: ${error}`)); + } + this.render(); + } + + status(): LanguageServerStatus { + const solution = this.model.activeSolution; + const projects = solution?.projects ?? []; + const loaded: string[] = []; + const missing: string[] = []; + for (const project of projects) { + (this.loaded.has(path.normalize(project.fsPath).toLowerCase()) ? loaded : missing).push(project.name); + } + const foreign = [...this.loaded.values()] + .filter(entry => !projects.some(project => samePath(project.fsPath, entry.path))) + .map(entry => entry.name); + return { loaded, missing, foreign, unavailable: !this.available }; + } + + /** Forgets what was loaded and asks DotRush to load again, so the count restarts from zero. */ + async reload(): Promise { + this.loaded.clear(); + this.render(); + await vscode.commands.executeCommand('dotrush.reloadWorkspace'); + } + + render(): void { + if (!this.available || !this.model.activeSolution) { + this.item.hide(); + return; + } + const { loaded, missing, foreign } = this.status(); + const total = loaded.length + missing.length; + const startup = this.model.startupProject?.info.name; + const startupMissing = startup !== undefined && missing.includes(startup); + const warn = startupMissing || foreign.length > 0; + + const icon = total > 0 && missing.length === 0 ? 'database' : loaded.length === 0 ? 'loading~spin' : 'database'; + this.item.text = `$(${icon}) ${loaded.length}/${total}${foreign.length ? ` +${foreign.length}` : ''}`; + this.item.backgroundColor = warn ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined; + + const lines = [`**Language server (DotRush):** ${loaded.length} of ${total} projects of \`${this.model.activeSolution.name}\` loaded`]; + if (loaded.length) { + lines.push('', 'Loaded: ' + loaded.map(name => `\`${name}\``).join(', ')); + } + if (missing.length) { + lines.push('', 'Not loaded: ' + missing.map(name => `\`${name}\``).join(', ')); + } + if (startupMissing) { + lines.push('', `$(warning) The startup project \`${startup}\` is not loaded — Find References and IntelliSense will miss it.`); + } + if (foreign.length) { + lines.push('', `$(warning) Loaded from outside this solution: ${foreign.map(name => `\`${name}\``).join(', ')}. ` + + 'Check `dotrush.roslyn.projectOrSolutionFiles` — it may point at another checkout.'); + } + if (loaded.length === 0 && missing.length > 0) { + lines.push('', 'Still loading, or the events fired before this extension was listening. Click → *Reload Workspace* to resync.'); + } + lines.push('', 'Click for reload and solution picking.'); + const tooltip = new vscode.MarkdownString(lines.join(' \n')); + tooltip.supportThemeIcons = true; + this.item.tooltip = tooltip; + this.item.show(); + } + + async menu(): Promise { + type Item = vscode.QuickPickItem & { run: () => unknown }; + const { missing, foreign } = this.status(); + const items: Item[] = [ + { label: '$(refresh) Reload Workspace', description: 'DotRush: Reload Workspace, counting from zero', run: () => this.reload() }, + { label: '$(file-submodule) Pick solution for DotRush', description: 'dotrush.roslyn.projectOrSolutionFiles', run: () => vscode.commands.executeCommand('dotrush.pickProjectOrSolutionFiles') }, + { label: '$(output) Show DotRush output', run: () => vscode.commands.executeCommand('workbench.action.output.show.extension-output-nromanov.dotrush-#1-DotRush').then(undefined, () => vscode.commands.executeCommand('workbench.action.output.toggleOutput')) }, + ]; + const picked = await vscode.window.showQuickPick(items, { + title: 'Language server' + (missing.length ? ` — ${missing.length} not loaded` : '') + (foreign.length ? ` — ${foreign.length} foreign` : ''), + }); + await picked?.run(); + } + + dispose(): void { + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index ee7dca1..a16ad9b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,11 +7,13 @@ import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, task import { launchOptionsFor } from './launch'; import { StatusBar } from './status'; import { HotReloadController } from './hotreload/controller'; +import { LanguageServerIndicator } from './dotrush'; let model: SolutionModel; let status: StatusBar; let log: vscode.OutputChannel; let hotReload: HotReloadController; +let languageServer: LanguageServerIndicator; function settings() { return vscode.workspace.getConfiguration('dotnetSolution'); @@ -306,13 +308,14 @@ export function activate(context: vscode.ExtensionContext): void { model = new SolutionModel(context.workspaceState, log); status = new StatusBar(model); hotReload = new HotReloadController(model, log, () => build('build')); + languageServer = new LanguageServerIndicator(model, log); const active = () => model.active; const command = (id: string, handler: (...args: unknown[]) => unknown) => vscode.commands.registerCommand(id, handler); context.subscriptions.push( - log, model, status, hotReload, + log, model, status, hotReload, languageServer, vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)), vscode.debug.registerDebugConfigurationProvider( @@ -340,6 +343,9 @@ export function activate(context: vscode.ExtensionContext): void { command('dotnetSolution.hotReload.detach', () => hotReload.detach()), command('dotnetSolution.hotReload.showTerminal', () => hotReload.showTerminal()), command('dotnetSolution.hotReload.menu', () => hotReload.menu()), + command('dotnetSolution.languageServerMenu', () => languageServer.menu()), + command('dotnetSolution.languageServerReload', () => languageServer.reload()), + command('dotnetSolution.languageServerStatus', () => languageServer.status()), command('dotnetSolution.reload', () => model.reload()), command('dotnetSolution.showOutput', () => log.show()), command('dotnetSolution.setStartupProject', async (resource?: unknown) => {