Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand All @@ -23,6 +23,19 @@ type Client = {
config: Config;
};

type HlsStatusBase<S extends string, F, P> = {
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
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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' &&
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 ?? '<unknown error>');
} 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, {
Expand Down
138 changes: 134 additions & 4 deletions src/statusBar.ts
Original file line number Diff line number Diff line change
@@ -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<string, CradleInfo>();

constructor(readonly version?: string) {
// Set up the status bar item.
this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
Expand All @@ -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 ?? '<unknown>'}\`\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`,
);
}

Expand All @@ -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];
}
}
Loading