From 8096877299de4817aad6f67ec586a9598c615385 Mon Sep 17 00:00:00 2001 From: vidit-od Date: Thu, 7 May 2026 15:35:24 +0530 Subject: [PATCH] Status Bar items update show the status of HLS as (started,loading,error,ready). on error show what error has occured, on ready show cradle information --- src/extension.ts | 68 +++++++++++++++++++---- src/statusBar.ts | 138 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 193 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 1529c27a..42c545bf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,4 @@ -import { commands, env, ExtensionContext, TextDocument, Uri, window, workspace, WorkspaceFolder } from 'vscode'; +import { commands, env, ExtensionContext, TextDocument, ThemeColor, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { ExecutableOptions, LanguageClient, @@ -13,7 +13,7 @@ import { HlsError, MissingToolError, NoMatchingHls } from './errors'; import { findHaskellLanguageServer, HlsExecutable, IEnvVars, fetchConfig } from './hlsBinaries'; import { addPathToProcessPath, comparePVP, callAsync } from './utils'; import { Config, initConfig, initLoggerFromConfig, logConfig } from './config'; -import { HaskellStatusBar } from './statusBar'; +import { CradleInfo, HaskellStatusBar } from './statusBar'; /** * Global information about the running clients. @@ -23,6 +23,19 @@ type Client = { config: Config; }; +type HlsStatusBase = { + status: S + rootDir: string, + file: F + payload: P +} + +type HlsStatusNotification = + | HlsStatusBase<"started", null, null> + | HlsStatusBase<"loading", string, null> + | HlsStatusBase<"error", string, { message: string }> + | HlsStatusBase<"ready", string, { ghcVersion: string; inferred: boolean }> + // The current map of documents & folders to language servers. // It may be null to indicate that we are in the process of launching a server, // in which case don't try to launch another one for that uri @@ -38,9 +51,9 @@ export async function activate(context: ExtensionContext) { // just support // https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#workspace_workspaceFolders // and then we can just launch one server - workspace.onDidOpenTextDocument(async (document: TextDocument) => await activateServer(context, document)); + workspace.onDidOpenTextDocument(async (document: TextDocument) => await activateServer(context, document, statusBar)); for (const document of workspace.textDocuments) { - await activateServer(context, document); + await activateServer(context, document, statusBar); } // Stop the server from any workspace folders that are removed. @@ -86,7 +99,7 @@ export async function activate(context: ExtensionContext) { fetchConfig(); for (const document of workspace.textDocuments) { - await activateServer(context, document); + await activateServer(context, document, statusBar); } }); @@ -125,11 +138,25 @@ export async function activate(context: ExtensionContext) { const openOnHackageDisposable = DocsBrowser.registerDocsOpenOnHackage(); context.subscriptions.push(openOnHackageDisposable); + // Refresh Status bad on file change + context.subscriptions.push( + window.onDidChangeActiveTextEditor((editor) => { + statusBar.refreshForDocument(editor?.document); + }), + ); + context.subscriptions.push( + workspace.onDidCloseTextDocument((document) => { + if (document.uri.scheme === 'file') { + statusBar.clearCradleInfo(document.uri.fsPath); + } + }), + ); + statusBar.refresh(); statusBar.show(); } -async function activateServer(context: ExtensionContext, document: TextDocument) { +async function activateServer(context: ExtensionContext, document: TextDocument, statusBar: HaskellStatusBar) { // We are only interested in Haskell files. if ( (document.languageId !== 'haskell' && @@ -143,10 +170,10 @@ async function activateServer(context: ExtensionContext, document: TextDocument) const uri = document.uri; const folder = workspace.getWorkspaceFolder(uri); - await activateServerForFolder(context, uri, folder); + await activateServerForFolder(context, uri, statusBar, folder); } -async function activateServerForFolder(context: ExtensionContext, uri: Uri, folder?: WorkspaceFolder) { +async function activateServerForFolder(context: ExtensionContext, uri: Uri, statusBar: HaskellStatusBar, folder?: WorkspaceFolder) { const clientsKey = folder ? folder.uri.toString() : uri.toString(); // If the client already has an LSP server for this uri/folder, then don't start a new one. if (clients.has(clientsKey)) { @@ -274,7 +301,30 @@ async function activateServerForFolder(context: ExtensionContext, uri: Uri, fold // Register ClientCapabilities for stuff like window/progress langClient.registerProposedFeatures(); - + langClient.onNotification('ghcide/status', (notification: HlsStatusNotification) => { + const status = notification.status; + if (status === 'started' || status === 'loading') { + statusBar.item.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); + statusBar.item.color = new ThemeColor('statusBarItem.warningForeground'); + statusBar.clearStatusInfo(); + } else if (status === 'error') { + statusBar.item.backgroundColor = new ThemeColor('statusBarItem.errorBackground'); + statusBar.item.color = new ThemeColor('statusBarItem.errorForeground'); + statusBar.setErrorMessage(notification.payload?.message ?? ''); + } else if (status === 'ready') { + statusBar.item.backgroundColor = undefined; + statusBar.item.color = undefined; + statusBar.clearStatusInfo(); + + const cradleInfo: CradleInfo = { + file: notification.file, + rootDir: notification.rootDir, + ghcVersion: notification.payload.ghcVersion, + inferred: notification.payload.inferred ?? false, + }; + statusBar.setCradleInfo(cradleInfo); + } + }); // Finally start the client and add it to the list of clients. logger.info('Starting language server'); clients.set(clientsKey, { diff --git a/src/statusBar.ts b/src/statusBar.ts index e6a9b227..0a29f746 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,8 +1,21 @@ +import * as fs from 'fs'; +import * as path from 'path'; import * as vscode from 'vscode'; import * as constants from './commands/constants'; +export type CradleInfo = { + file: string; + ghcVersion: string; + rootDir: string; + inferred: boolean; +}; + export class HaskellStatusBar { readonly item: vscode.StatusBarItem; + private activeCradleInfo?: CradleInfo; + private activeErrorMessage?: string; + private readonly cradleInfoByFile = new Map(); + constructor(readonly version?: string) { // Set up the status bar item. this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); @@ -15,12 +28,31 @@ export class HaskellStatusBar { this.item.command = constants.OpenLogsCommandName; this.item.tooltip = new vscode.MarkdownString('', true); this.item.tooltip.isTrusted = true; + if (this.activeErrorMessage) { + this.item.tooltip.appendText(this.activeErrorMessage); + return; + } + + if (this.activeCradleInfo) { + const fileLink = this.renderFileLink(this.activeCradleInfo.file, this.activeCradleInfo.rootDir); + const dependenciesSection = this.renderDependenciesSection(); + + this.item.tooltip.appendMarkdown( + `**Cradle Info**\n\n` + + `Root: \`${this.activeCradleInfo.rootDir}\`\n\n` + + `File: ${fileLink}\n\n` + + `GHC: \`${this.activeCradleInfo.ghcVersion ?? ''}\`\n\n` + + `Inferred: \`${this.activeCradleInfo.inferred}\`\n\n` + + dependenciesSection + + `---\n\n`, + ); + } this.item.tooltip.appendMarkdown( `[Extension Info](command:${constants.ShowExtensionVersions} "Show Extension Version"): Version ${version}\n\n` + - `---\n\n` + - `[$(terminal) Open Logs](command:${constants.OpenLogsCommandName} "Open the logs of the Server and Extension")\n\n` + - `[$(debug-restart) Restart Server](command:${constants.RestartServerCommandName} "Restart Haskell Language Server")\n\n` + - `[$(refresh) Restart Extension](command:${constants.RestartExtensionCommandName} "Restart vscode-haskell Extension")\n\n`, + `---\n\n` + + `[$(terminal) Open Logs](command:${constants.OpenLogsCommandName} "Open the logs of the Server and Extension")\n\n` + + `[$(debug-restart) Restart Server](command:${constants.RestartServerCommandName} "Restart Haskell Language Server")\n\n` + + `[$(refresh) Restart Extension](command:${constants.RestartExtensionCommandName} "Restart vscode-haskell Extension")\n\n`, ); } @@ -32,7 +64,105 @@ export class HaskellStatusBar { this.item.hide(); } + setCradleInfo(cradleInfo: CradleInfo): void { + const normalized = vscode.Uri.file(cradleInfo.file).fsPath; + this.activeErrorMessage = undefined; + this.cradleInfoByFile.set(normalized, cradleInfo); + this.refreshForDocument(vscode.window.activeTextEditor?.document); + } + + setErrorMessage(message: string): void { + this.activeCradleInfo = undefined; + this.activeErrorMessage = message; + this.cradleInfoByFile.clear(); + this.refresh(); + } + + clearStatusInfo(): void { + this.activeCradleInfo = undefined; + this.activeErrorMessage = undefined; + this.cradleInfoByFile.clear(); + this.refresh(); + } + + clearCradleInfo(file: string): void { + const normalized = vscode.Uri.file(file).fsPath; + this.cradleInfoByFile.delete(normalized); + if (this.activeCradleInfo?.file === normalized) { + this.activeCradleInfo = undefined; + } + this.refresh(); + } + + refreshForDocument(document?: vscode.TextDocument): void { + if (document?.uri.scheme !== 'file') return; + + const key = document.uri.fsPath; + const found = this.cradleInfoByFile.get(key); + + this.activeCradleInfo = found; + this.refresh(); + } + dispose() { this.item.dispose(); } + + // Make a clickable list of dependency files + private renderDependenciesSection(): string { + if (!this.activeCradleInfo) return ''; + + const { activeCradleInfo } = this; + const dependencies = this.getDependencyFiles(activeCradleInfo); + if (dependencies.length === 0) return 'Dependencies: `none`\n\n'; + + const links = dependencies.map((dependency) => this.renderFileLink(dependency, activeCradleInfo.rootDir)).join(', '); + return `Dependencies: ${links}\n\n`; + } + + // Uses Absolute path to make clickable links in statusbar items + private renderFileLink(filePath: string, rootDir: string): string { + const resolvedPath = this.resolvePath(filePath, rootDir); + const uri = vscode.Uri.file(resolvedPath); + const args = encodeURIComponent(JSON.stringify([uri])); + const label = this.toRelativePath(resolvedPath, rootDir); + + return `[${label}](command:vscode.open?${args} "${resolvedPath}")`; + } + + // Given a file path and root directoty, returns file location relative to root + private toRelativePath(filePath: string, rootDir: string): string { + const relativePath = path.relative(rootDir, filePath); + return relativePath || path.basename(filePath); + } + + // Returns absolute file path inCase Relative path is provided + private resolvePath(filePath: string, rootDir: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath); + } + + // Find list of Dependency files from root based on Cradle inferred value + // if inferred is false, search for hie.yaml + // if inferred is true, search for cabal , stack files + private getDependencyFiles(cradleInfo: CradleInfo): string[] { + if (!cradleInfo.inferred) { + const hieYamlPath = path.join(cradleInfo.rootDir, 'hie.yaml'); + return fs.existsSync(hieYamlPath) ? [hieYamlPath] : []; + } + + if (!fs.existsSync(cradleInfo.rootDir)) { + return []; + } + + const entries = fs.readdirSync(cradleInfo.rootDir, { withFileTypes: true }); + const cabalFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.cabal')) + .map((entry) => path.join(cradleInfo.rootDir, entry.name)) + .sort((left, right) => left.localeCompare(right)); + const otherConfigFiles = ['stack.yaml', 'cabal.project'] + .map((fileName) => path.join(cradleInfo.rootDir, fileName)) + .filter((filePath) => fs.existsSync(filePath)); + + return [...cabalFiles, ...otherConfigFiles]; + } }