diff --git a/.eslintrc.js b/.eslintrc.js index 2dffd1f23..cbd5bb85f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,10 @@ module.exports = { { ignoreRestArgs: true }, ], "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"], + "@typescript-eslint/no-unused-vars": [ + "error", + { varsIgnorePattern: "_" }, + ], "@typescript-eslint/no-non-null-assertion": "error", "guard-for-in": "error", "no-var": "error", diff --git a/package.json b/package.json index 5f6e40c09..a2f72563d 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ { "id": "java", "extensions": [ - ".cfr" + ".cfr", + ".class" ] }, { @@ -407,6 +408,11 @@ "category": "Metals", "title": "Run doctor" }, + { + "command": "metals.show-libraries-folder", + "category": "Metals", + "title": "Show libraries folder in file explorer" + }, { "command": "metals.show-tasty", "category": "Metals", @@ -561,33 +567,37 @@ } ], "commandPalette": [ + { + "command": "metals.show-libraries-folder", + "when": "metals:enabled" + }, { "command": "metals.show-tasty", - "when": "metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.tasty || metals:enabled && resourceExtname==.tasty-decoded" + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceExtname==.tasty-decoded" }, { "command": "metals.show-cfr", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class || metals:enabled && resourceExtname==.cfr" + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceExtname==.cfr" }, { "command": "metals.show-javap", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class || metals:enabled && resourceExtname==.javap" + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resourceExtname==.javap" }, { "command": "metals.show-javap-verbose", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class || metals:enabled && resourceExtname==.javap-verbose" + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resourceExtname==.javap-verbose" }, { "command": "metals.show-semanticdb-compact", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb || metals:enabled && resourceExtname==.semanticdb-compact" + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb-compact" }, { "command": "metals.show-semanticdb-detailed", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb || metals:enabled && resourceExtname==.semanticdb-detailed" + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb-detailed" }, { "command": "metals.show-semanticdb-proto", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb || metals:enabled && resourceExtname==.semanticdb-proto" + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb-proto" }, { "command": "metals.reveal-active-file", @@ -647,11 +657,11 @@ }, { "command": "metals.new-scala-file", - "when": "metals:enabled" + "when": "metals:enabled && resourceScheme != metalsfs" }, { "command": "metals.new-java-file", - "when": "metals:enabled" + "when": "metals:enabled && resourceScheme != metalsfs" }, { "command": "metals.new-scala-project", @@ -685,12 +695,12 @@ "explorer/context": [ { "command": "metals.new-scala-file", - "when": "metals:enabled", + "when": "metals:enabled && resourceScheme != metalsfs", "group": "navigation@1" }, { "command": "metals.new-java-file", - "when": "metals:enabled", + "when": "metals:enabled && resourceScheme != metalsfs", "group": "navigation@2" }, { @@ -702,37 +712,37 @@ "metals.analyze": [ { "command": "metals.show-tasty", - "when": "metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.tasty || metals:enabled && resourceExtname==.tasty-decoded", + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceExtname==.tasty || metals:enabled && resourceExtname==.tasty-decoded", "group": "metals-1@1" }, { "command": "metals.show-cfr", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class", + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceScheme != metalsfs && resourceExtname==.class", "group": "metals-2@1" }, { "command": "metals.show-javap", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class", + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceExtname==.class", "group": "metals-3@1" }, { "command": "metals.show-javap-verbose", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.class", + "when": "metals:enabled && resourceScheme != metalsfs && resourceExtname==.java || metals:enabled && resourceScheme != metalsfs && resourceExtname==.scala || metals:enabled && resourceExtname==.class", "group": "metals-3@2" }, { "command": "metals.show-semanticdb-compact", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb", + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resource ~= ///.metals//readonly/// && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb", "group": "metals-4@1" }, { "command": "metals.show-semanticdb-detailed", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb", + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resource ~= ///.metals//readonly/// && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb", "group": "metals-4@2" }, { "command": "metals.show-semanticdb-proto", - "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceExtname==.semanticdb", + "when": "metals:enabled && resourceExtname==.java || metals:enabled && resourceExtname==.scala || metals:enabled && resourceScheme == metalsfs && resourceExtname==.class || metals:enabled && resource ~= ///.metals//readonly/// && resourceExtname==.class || metals:enabled && resourceExtname==.semanticdb", "group": "metals-4@3" } ], diff --git a/src/extension.ts b/src/extension.ts index 7c7a8ccce..9b34a0cd5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { ProviderResult, Hover, TextDocument, + FileSystemProvider, } from "vscode"; import { LanguageClient, @@ -78,7 +79,11 @@ import { startFindInFilesProvider, } from "./findInFiles"; import * as ext from "./hoverExtension"; -import { decodeAndShowFile, MetalsFileProvider } from "./metalsContentProvider"; +import { + decodeAndShowFile, + DecodeExtension, + MetalsFileProvider, +} from "./metalsContentProvider"; import { getJavaHomeFromConfig, getTextDocumentPositionParams, @@ -90,6 +95,7 @@ import * as workbenchCommands from "./workbenchCommands"; import { getServerVersion } from "./getServerVersion"; import { getCoursierMirrorPath } from "./mirrors"; import { DoctorProvider } from "./doctor"; +import MetalsFileSystemProvider from "./metalsFileSystemProvider"; const outputChannel = window.createOutputChannel("Metals"); const openSettingsAction = "Open settings"; @@ -98,6 +104,8 @@ const downloadJava = "Download Java"; const installJava11Action = "Install Java (JDK 11)"; const installJava17Action = "Install Java (JDK 17)"; +const librariesURI = Uri.parse("metalsfs:/metalsLibraries"); + let treeViews: MetalsTreeViews | undefined; let currentClient: LanguageClient | undefined; @@ -364,8 +372,8 @@ function launchMetals( documentSelector: [ { scheme: "file", language: "scala" }, { scheme: "file", language: "java" }, - { scheme: "jar", language: "scala" }, - { scheme: "jar", language: "java" }, + { scheme: "metalsfs", language: "scala" }, + { scheme: "metalsfs", language: "java" }, ], synchronize: { configurationSection: "metals", @@ -440,6 +448,18 @@ function launchMetals( ); } + function registerFileSystemProvider( + scheme: string, + provider: FileSystemProvider + ) { + context.subscriptions.push( + workspace.registerFileSystemProvider(scheme, provider, { + isCaseSensitive: true, + isReadonly: true, + }) + ); + } + function registerTextDocumentContentProvider( scheme: string, provider: TextDocumentContentProvider @@ -450,52 +470,26 @@ function launchMetals( } const metalsFileProvider = new MetalsFileProvider(client); - registerTextDocumentContentProvider("metalsDecode", metalsFileProvider); - registerTextDocumentContentProvider("jar", metalsFileProvider); - - registerCommand("metals.show-cfr", async (uri: Uri) => { - await decodeAndShowFile(client, metalsFileProvider, uri, "cfr"); - }); - - registerCommand("metals.show-javap-verbose", async (uri: Uri) => { - await decodeAndShowFile(client, metalsFileProvider, uri, "javap-verbose"); - }); - - registerCommand("metals.show-javap", async (uri: Uri) => { - await decodeAndShowFile(client, metalsFileProvider, uri, "javap"); - }); - - registerCommand("metals.show-semanticdb-compact", async (uri: Uri) => { - await decodeAndShowFile( - client, - metalsFileProvider, - uri, - "semanticdb-compact" - ); - }); - - registerCommand("metals.show-semanticdb-detailed", async (uri: Uri) => { - await decodeAndShowFile( - client, - metalsFileProvider, - uri, - "semanticdb-detailed" - ); - }); - registerCommand("metals.show-semanticdb-proto", async (uri: Uri) => { - await decodeAndShowFile( - client, - metalsFileProvider, - uri, - "semanticdb-proto" - ); - }); + registerCommand("metals.show-libraries-folder", async () => + addLibrariesFolder() + ); - registerCommand("metals.show-tasty", async (uri: Uri) => { - await decodeAndShowFile(client, metalsFileProvider, uri, "tasty-decoded"); - }); + const decodeCommands: [string, DecodeExtension][] = [ + ["cfr", "cfr"], + ["javap-verbose", "javap-verbose"], + ["javap", "javap"], + ["semanticdb-compact", "semanticdb-compact"], + ["semanticdb-detailed", "semanticdb-detailed"], + ["semanticdb-proto", "semanticdb-proto"], + ["tasty", "tasty-decoded"], + ]; + decodeCommands.forEach((command) => + registerCommand(`metals.show-${command[0]}`, async (uri: Uri) => { + await decodeAndShowFile(client, metalsFileProvider, uri, command[1]); + }) + ); registerCommand( "metals.restartServer", @@ -636,7 +630,7 @@ function launchMetals( codeLensRefresher ); languages.registerCodeLensProvider( - { scheme: "jar", language: "scala" }, + { scheme: "metalsfs", language: "scala" }, codeLensRefresher ); @@ -703,6 +697,19 @@ function launchMetals( case ClientCommands.ReloadDoctor: doctorProvider.reloadOrRefreshDoctor(params); break; + case "metals-library-filesystem-ready": { + const metalsFileSystemProvider = new MetalsFileSystemProvider( + client, + librariesURI + ); + registerFileSystemProvider( + librariesURI.scheme, + metalsFileSystemProvider + ); + + metalsFileSystemProvider.reinitialiseURI(librariesURI); + break; + } case ClientCommands.FocusDiagnostics: commands.executeCommand(ClientCommands.FocusDiagnostics); break; @@ -862,7 +869,7 @@ function launchMetals( registerCommand("metals.reveal-active-file", () => { if (treeViews) { const editor = window.visibleTextEditors.find((e) => - isSupportedLanguage(e.document.languageId) + isSupportedDocument(e.document) ); if (editor) { const params = getTextDocumentPositionParams(editor); @@ -939,7 +946,6 @@ function launchMetals( client, findInFilesProvider, findInFilesView, - metalsFileProvider, outputChannel ) ); @@ -985,7 +991,7 @@ function launchMetals( ); window.onDidChangeActiveTextEditor((editor) => { - if (editor && isSupportedLanguage(editor.document.languageId)) { + if (editor && isSupportedDocument(editor.document)) { client.sendNotification( MetalsDidFocus.type, editor.document.uri.toString() @@ -1241,14 +1247,52 @@ function detectLaunchConfigurationChanges() { ); } -function isSupportedLanguage(languageId: TextDocument["languageId"]): boolean { - switch (languageId) { - case "scala": - case "sc": - case "java": - return true; - default: - return false; +function isSupportedDocument(textDocument: TextDocument): boolean { + return ( + ["metalsfs", "file"].includes(textDocument.uri.scheme) && + ["scala", "sc", "java"].includes(textDocument.languageId) + ); +} + +function addLibrariesFolder() { + const libraryFolderName = "Metals - Libraries"; + + // filesystem can be persistent across VSCode sessions so may already exist + const newLibraryFolder = { + uri: librariesURI, + name: libraryFolderName, + }; + const folderByUri = workspace.getWorkspaceFolder(librariesURI); + if (folderByUri && folderByUri.name != libraryFolderName) { + // wrong name on libraries folder + workspace.updateWorkspaceFolders(folderByUri.index, 1, newLibraryFolder); + } else { + const folderByName = workspace.workspaceFolders?.find( + (folder) => folder.name == libraryFolderName + ); + if (folderByName && folderByName.uri.toString != librariesURI.toString) { + if (folderByUri) { + // too many libraries folders + workspace.updateWorkspaceFolders(folderByName.index, 1); + } else { + // wrong root on libraries folder + workspace.updateWorkspaceFolders( + folderByName.index, + 1, + newLibraryFolder + ); + } + } else if (!folderByUri) { + // missing libraries folder + const workspaceCount = workspace.workspaceFolders?.length; + if (workspaceCount) { + workspace.updateWorkspaceFolders( + workspaceCount, + null, + newLibraryFolder + ); + } + } } } diff --git a/src/findInFiles.ts b/src/findInFiles.ts index fb4492a76..d2d726044 100644 --- a/src/findInFiles.ts +++ b/src/findInFiles.ts @@ -17,7 +17,6 @@ import { workspace, } from "vscode"; import { LanguageClient, Location } from "vscode-languageclient/node"; -import { MetalsFileProvider } from "./metalsContentProvider"; class TopLevel { constructor( @@ -101,7 +100,6 @@ export async function executeFindInFiles( client: LanguageClient, provider: FindInFilesProvider, view: TreeView, - metalsFileProvider: MetalsFileProvider, outputChannel: OutputChannel ): Promise { try { @@ -143,7 +141,7 @@ export async function executeFindInFiles( ); commands.executeCommand("setContext", "metals.showFindInFiles", true); - const newTopLevel = await toTopLevel(locations, metalsFileProvider); + const newTopLevel = await toTopLevel(locations); provider.update(newTopLevel); @@ -159,10 +157,7 @@ export async function executeFindInFiles( } } -async function toTopLevel( - locations: Location[], - metalsFileProvider: MetalsFileProvider -): Promise { +async function toTopLevel(locations: Location[]): Promise { const locationsByFile = new Map(); for (const loc of locations) { @@ -174,12 +169,8 @@ async function toTopLevel( Array.from(locationsByFile, async ([filePath, locations]) => { const uri: Uri = Uri.parse(filePath); const getFileContent = async () => { - if (uri.scheme == "jar") { - return await metalsFileProvider.provideTextDocumentContent(uri); - } else { - const readData = await workspace.fs.readFile(uri); - return Buffer.from(readData).toString("utf8"); - } + const readData = await workspace.fs.readFile(uri); + return Buffer.from(readData).toString("utf8"); }; const fileContent = await getFileContent(); if (fileContent) { diff --git a/src/metalsContentProvider.ts b/src/metalsContentProvider.ts index 76c9c9e5e..b78d68ff1 100644 --- a/src/metalsContentProvider.ts +++ b/src/metalsContentProvider.ts @@ -45,7 +45,7 @@ export class MetalsFileProvider implements TextDocumentContentProvider { } } -type DecodeExtension = +export type DecodeExtension = | "cfr" | "javap" | "javap-verbose" diff --git a/src/metalsFileSystemProvider.ts b/src/metalsFileSystemProvider.ts new file mode 100644 index 000000000..ac390cca8 --- /dev/null +++ b/src/metalsFileSystemProvider.ts @@ -0,0 +1,204 @@ +import { ServerCommands } from "metals-languageclient"; +import { + EventEmitter, + //ProviderResult, + FileSystemProvider, + Uri, + Disposable, + Event, + FileChangeEvent, + FileStat, + FileType, + FileSystemError, + FileChangeType, +} from "vscode"; +import { ExecuteCommandRequest } from "vscode-languageclient"; +import { LanguageClient } from "vscode-languageclient/node"; + +export interface FSReadDirectoryResponse { + name: string; + isFile: boolean; +} + +export interface FSReadDirectoriesResponse { + name: string; + directories?: FSReadDirectoryResponse[]; + error?: string; +} + +export interface FSReadFileResponse { + name: string; + value?: string; + error?: string; +} + +export interface FSStatResponse { + name: string; + isFile?: boolean; + error?: string; +} + +export class GenericFileStat implements FileStat { + type: FileType; + ctime: number; + mtime: number; + size: number; + + constructor(isFile: boolean) { + this.type = isFile ? FileType.File : FileType.Directory; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + } +} + +export default class MetalsFileSystemProvider implements FileSystemProvider { + readonly client: LanguageClient; + readonly exclusions: string[]; + + constructor(client: LanguageClient, rootUri: Uri) { + // automatically reject files/directories that vscode checks for but will never appear on metalfs + this.exclusions = [".vscode", ".git", ".devcontainer"].map( + (path) => `${rootUri.path}/${path}` + ); + this.client = client; + } + + private checkUri(uri: Uri) { + if ( + this.exclusions.findIndex((exclusion) => + uri.path.startsWith(exclusion) + ) >= 0 + ) { + throw FileSystemError.FileNotFound(uri); + } + } + + stat(uri: Uri): FileStat | Thenable { + this.checkUri(uri); + + return this.client + .sendRequest(ExecuteCommandRequest.type, { + command: ServerCommands.FileSystemStat, + arguments: [uri.toString(true)], + }) + .then((result) => { + const { isFile, error, name } = result as FSStatResponse; + if (isFile !== undefined) { + return new GenericFileStat(isFile); + } else if (error !== undefined) { + throw FileSystemError.FileNotFound(error); + } else { + throw FileSystemError.FileNotFound(name); + } + }); + } + + readDirectory( + uri: Uri + ): [string, FileType][] | Thenable<[string, FileType][]> { + this.checkUri(uri); + + return this.client + .sendRequest(ExecuteCommandRequest.type, { + command: ServerCommands.FileSystemReadDirectory, + arguments: [uri.toString(true)], + }) + .then((result) => { + const { directories, error, name } = + result as FSReadDirectoriesResponse; + if (directories) { + return directories.map(this.toReadDirResult); + } else if (error !== undefined) { + throw FileSystemError.FileNotFound(error); + } else { + throw FileSystemError.FileNotFound(name); + } + }); + } + + readFile(uri: Uri): Uint8Array | Thenable { + this.checkUri(uri); + + return this.client + .sendRequest(ExecuteCommandRequest.type, { + command: ServerCommands.FileSystemReadFile, + arguments: [uri.toString(true)], + }) + .then((result) => { + const { value, error, name } = result as FSReadFileResponse; + if (value) { + return Buffer.from(value); + } else if (error !== undefined) { + throw FileSystemError.FileNotFound(error); + } else { + throw FileSystemError.FileNotFound(name); + } + }); + } + + reinitialiseURI(uri: Uri) { + this._fireSoon({ type: FileChangeType.Changed, uri: uri }); + } + + private toReadDirResult( + response: FSReadDirectoryResponse + ): [string, FileType] { + return [ + response.name, + response.isFile ? FileType.File : FileType.Directory, + ]; + } + + private _emitter = new EventEmitter(); + private _bufferedEvents: FileChangeEvent[] = []; + private _fireSoonHandle?: NodeJS.Timer; + + readonly onDidChangeFile: Event = this._emitter.event; + + private _fireSoon(...events: FileChangeEvent[]): void { + this._bufferedEvents.push(...events); + + if (this._fireSoonHandle) { + clearTimeout(this._fireSoonHandle); + } + + this._fireSoonHandle = setTimeout(() => { + this._emitter.fire(this._bufferedEvents); + this._bufferedEvents.length = 0; + }, 5); + } + + watch( + _uri: Uri, + _options: { recursive: boolean; excludes: string[] } + ): Disposable { + return new Disposable(() => { + // do nothing + }); + } + + createDirectory(uri: Uri): void | Thenable { + throw new Error(`createDirectory ${uri} not implemented.`); + } + + delete(uri: Uri, options: { recursive: boolean }): void | Thenable { + throw new Error(`delete ${uri} ${options} not implemented.`); + } + + rename( + oldUri: Uri, + newUri: Uri, + options: { overwrite: boolean } + ): void | Thenable { + throw new Error(`rename ${oldUri} ${newUri} ${options} not implemented.`); + } + + writeFile( + uri: Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean } + ): void | Thenable { + throw new Error(`writeFile ${uri} ${content} ${options} not implemented.`); + } +}