diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcee9bd..e91b002 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,6 +99,10 @@ scenario instead, against `samples/workspace` or the directory `ELIDE_WORKSPACE_ ELIDE_TEST_SCENARIO=workspace bazel run //packages/vscode:integration_test ``` +Set `ELIDE_TEST_WORKSPACE_NAVIGATION_ONLY=1` with the `workspace` scenario to stop after sync and +manifest member-navigation checks (links, definitions, synced metadata hover, unsaved edits and +unloaded members), without waiting for Kotlin source analysis. + `ELIDE_TEST_SCENARIO=build-extension` opens `samples/build-extension` with third-party extensions disabled. It exercises the built-in TypeScript service before sync (inferred and configured TS projects, JSDoc JS, task/tool completion, invalid-option diagnostics, snippets and non-Elide diff --git a/packages/core/src/buildExtensions.ts b/packages/core/src/buildExtensions.ts index 195a510..9ad0135 100644 --- a/packages/core/src/buildExtensions.ts +++ b/packages/core/src/buildExtensions.ts @@ -7,6 +7,31 @@ export interface ManifestBuildExtension { end: number; } +/** A literal directory in `workspace.members`; offsets exclude its quotes. */ +export interface ManifestWorkspaceMember { + path: string; + start: number; + end: number; +} + +/** Literal workspace member directories, with source offsets excluding their quotes. */ +export function parseManifestWorkspaceMembers(text: string): ManifestWorkspaceMember[] { + if (/#"|"""/.test(text)) return []; + const workspace = pklBlock(text, "workspace"); + if (!workspace) return []; + const members = pklBlock(text, "members", workspace.start, workspace.end); + if (!members) return []; + const tokens = tokenize(text, members.start, members.end); + if (!tokens) return []; + const found: ManifestWorkspaceMember[] = []; + for (const entry of statements(tokens)) { + if (entry.every((token) => token.text === "string")) { + for (const token of entry) addLiteral(token, found); + } + } + return found; +} + interface Token { text: string; start: number; diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index ae6c2da..39eb13b 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -9,6 +9,7 @@ import { ManifestParseError } from "./errors.js"; */ export interface Manifest { name?: string; + description?: string; version?: string; entrypoint: string[]; scripts: Record; @@ -411,6 +412,7 @@ export function decodeManifest(text: string): Manifest { return { name: typeof raw.name === "string" ? raw.name : undefined, + description: typeof raw.description === "string" ? raw.description : undefined, version: typeof raw.version === "string" ? raw.version : undefined, entrypoint: strings(raw.entrypoint), scripts: stringMap(raw.scripts), diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index ca00106..90e7759 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -80,6 +80,7 @@ export type Entrypoint = export interface ProjectModel { root: string; name: string; + description?: string; elideVersion: string; jdk?: Jdk; kotlin: { languageVersion?: string; apiVersion?: string; freeCompilerArgs: string[]; jvmTarget?: string; compose?: boolean }; @@ -719,6 +720,7 @@ function assembleProject(project: ProjectSpec, answers: ProjectAnswers, ctx: Bui model: { root, name, + ...(manifest.description !== undefined ? { description: manifest.description } : {}), elideVersion: ctx.elideVersion, jdk, kotlin, diff --git a/packages/core/test/buildExtensions.test.ts b/packages/core/test/buildExtensions.test.ts index 35ea7ac..54e5c61 100644 --- a/packages/core/test/buildExtensions.test.ts +++ b/packages/core/test/buildExtensions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseManifestBuildExtensions } from "../src/index.js"; +import { parseManifestBuildExtensions, parseManifestWorkspaceMembers } from "../src/index.js"; function paths(text: string): string[] { return parseManifestBuildExtensions(text).map((entry) => entry.path); @@ -98,3 +98,31 @@ other { "outside.ts" }`; expect(paths('extensions { "ok.ts" /* unfinished }')).toEqual([]); }); }); + +describe("parseManifestWorkspaceMembers", () => { + test("finds only literal entries in the root workspace members, retaining editor ranges", () => { + const text = String.raw`other { workspace { members { "nested" } } } +workspace { + // members { "comment" } + members = new Listing { + "model"; + /* "ignored" */ "sub/\u{63}li" + "prefix/" + "computed" + read("argument") + new { name = "object" } + "interpolated/\(value)" + } +} +extensions { "tools.ts" }`; + const entries = parseManifestWorkspaceMembers(text); + expect(entries.map((entry) => entry.path)).toEqual(["model", "sub/cli"]); + expect(entries.map((entry) => text.slice(entry.start, entry.end))).toEqual(["model", String.raw`sub/\u{63}li`]); + expect(parseManifestWorkspaceMembers('workspace { members { "model" "cli" } }').map((entry) => entry.path)).toEqual(["model", "cli"]); + }); + + test("declines incomplete and unsupported string syntax", () => { + expect(parseManifestWorkspaceMembers('workspace { members { "unfinished } }')).toEqual([]); + expect(parseManifestWorkspaceMembers('workspace { members { #"raw"# } }')).toEqual([]); + expect(parseManifestWorkspaceMembers('workspace { members { """multiline""" } }')).toEqual([]); + }); +}); diff --git a/packages/vscode/BUILD.bazel b/packages/vscode/BUILD.bazel index 59bff8a..04322af 100644 --- a/packages/vscode/BUILD.bazel +++ b/packages/vscode/BUILD.bazel @@ -66,6 +66,7 @@ ts_project( "src/targetShortcuts.ts", "src/tasks.ts", "src/testing.ts", + "src/workspaceMembers.ts", "src/workspacePaths.ts", ], declaration = False, diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index eb58c60..5d7025e 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the Elide extension are documented here. The format follo [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [semantic versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Workspace member strings in `elide.pkl` link to the member's manifest and support Go to Definition. + Hover shows the project's name and description when the member is already loaded and synced. + Navigation follows unsaved edits without triggering project loading or synchronization. + ## [0.8.0](https://github.com/elide-dev/vscode/compare/v0.7.0...v0.8.0) (2026-11-03) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index f1f9779..08464e3 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -26,6 +26,7 @@ import { registerAndroidReadiness } from "./androidReadiness.js"; import { registerAndroidWireless } from "./androidWireless.js"; import { ElideCodeLensProvider } from "./codelens.js"; import { registerBuildExtensionLinks } from "./buildExtensions.js"; +import { registerWorkspaceMemberNavigation } from "./workspaceMembers.js"; import { ComposeAgentPreviews } from "./composeAgent.js"; import { registerComposePreviewMcp, type ElideComposePreviewMcpApi } from "./composeAgentMcp.js"; import { registerComposePreview, type ElideComposePreviewApi } from "./composePreview.js"; @@ -87,6 +88,7 @@ export async function activate(context: vscode.ExtensionContext): Promise syncCommand(workspace, target)), vscode.commands.registerCommand("elide.installKomodo", () => installKomodoCommand(workspace, ui)), diff --git a/packages/vscode/src/workspaceMembers.ts b/packages/vscode/src/workspaceMembers.ts new file mode 100644 index 0000000..347b4fe --- /dev/null +++ b/packages/vscode/src/workspaceMembers.ts @@ -0,0 +1,75 @@ +import { realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { MANIFEST_NAME, isPathUnder, normalizePath, parseManifestWorkspaceMembers } from "@elide/ide-core"; +import * as vscode from "vscode"; +import type { ElideWorkspace } from "./projects.js"; + +/** Navigation follows the buffer; descriptive metadata comes only from an already synced member. */ +export function registerWorkspaceMemberNavigation(workspace: ElideWorkspace): vscode.Disposable { + const selector = { scheme: "file", pattern: `**/${MANIFEST_NAME}` }; + + function declarations(document: vscode.TextDocument) { + if (!vscode.workspace.isTrusted || document.uri.scheme !== "file" || path.basename(document.uri.fsPath) !== MANIFEST_NAME) return []; + const root = path.dirname(document.uri.fsPath); + if (!workspace.locate(document.uri) || !workspace.projectAt(root)) return []; + return parseManifestWorkspaceMembers(document.getText()).map((entry) => ({ + root: path.resolve(root, entry.path), + path: entry.path, + range: new vscode.Range(document.positionAt(entry.start), document.positionAt(entry.end)), + })); + } + + async function target(document: vscode.TextDocument, root: string): Promise { + const uri = vscode.Uri.file(path.join(root, MANIFEST_NAME)); + if (!workspace.locate(uri)) return undefined; + try { + const tree = normalizePath(await realpath(path.dirname(document.uri.fsPath))); + if (!isPathUnder(normalizePath(await realpath(uri.fsPath)), tree) || !(await stat(uri.fsPath)).isFile()) return undefined; + return uri; + } catch { + return undefined; + } + } + + return vscode.Disposable.from( + vscode.languages.registerDocumentLinkProvider(selector, { + async provideDocumentLinks(document, token) { + const version = document.version; + const links: vscode.DocumentLink[] = []; + for (const entry of declarations(document)) { + const uri = await target(document, entry.root); + if (token.isCancellationRequested || document.version !== version || !vscode.workspace.isTrusted) return []; + if (!uri) continue; + const link = new vscode.DocumentLink(entry.range, uri); + link.tooltip = `Open workspace member ${entry.path}`; + links.push(link); + } + return links; + }, + }), + vscode.languages.registerDefinitionProvider(selector, { + async provideDefinition(document, position, token) { + const version = document.version; + const entry = declarations(document).find((entry) => entry.range.contains(position)); + if (!entry) return undefined; + const uri = await target(document, entry.root); + if (!uri || token.isCancellationRequested || document.version !== version || !vscode.workspace.isTrusted) return undefined; + return [{ originSelectionRange: entry.range, targetUri: uri, targetRange: new vscode.Range(0, 0, 0, 0) }]; + }, + }), + vscode.languages.registerHoverProvider(selector, { + provideHover(document, position, token) { + if (token.isCancellationRequested) return undefined; + const entry = declarations(document).find((entry) => entry.range.contains(position)); + if (!entry) return undefined; + const owner = path.dirname(document.uri.fsPath); + const member = workspace.projectAt(entry.root); + if (!member?.model || member.workspaceRoot !== owner) return undefined; + const content = new vscode.MarkdownString(); + content.appendText(member.model.name); + if (member.model.description) content.appendMarkdown("\n\n").appendText(member.model.description); + return new vscode.Hover(content, entry.range); + }, + }), + ); +} diff --git a/packages/vscode/test/integration/workspace.ts b/packages/vscode/test/integration/workspace.ts index 4e4e987..fcd73d1 100644 --- a/packages/vscode/test/integration/workspace.ts +++ b/packages/vscode/test/integration/workspace.ts @@ -61,6 +61,46 @@ export async function runWorkspaceScenario(): Promise { ); log("sync ok"); + // Manifest providers navigate the editor buffer, but describe only members in the synced model. + const manifestUri = vscode.Uri.file(path.join(sample, "elide.pkl")); + const manifestDocument = await vscode.workspace.openTextDocument(manifestUri); + await vscode.window.showTextDocument(manifestDocument); + const modelOffset = manifestDocument.getText().indexOf('"model"') + 1; + assert.ok(modelOffset > 0); + const modelPosition = manifestDocument.positionAt(modelOffset); + const modelUri = vscode.Uri.file(path.join(member("model"), "elide.pkl")).toString(); + const links = await vscode.commands.executeCommand("vscode.executeLinkProvider", manifestUri); + assert.ok(links?.some((link) => link.target?.toString() === modelUri && link.range.contains(modelPosition)), "member links open their manifests"); + const definitions = await vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>("vscode.executeDefinitionProvider", manifestUri, modelPosition); + assert.ok(definitions?.some((definition) => ("targetUri" in definition ? definition.targetUri : definition.uri).toString() === modelUri), "Go to Definition opens the member manifest"); + const memberHover = async () => { + const hovers = await vscode.commands.executeCommand("vscode.executeHoverProvider", manifestUri, modelPosition); + return (hovers?.flatMap((hover) => hover.contents.map((content) => typeof content === "string" ? content : "value" in content ? content.value : "")).join("\n") ?? "").replaceAll(" ", " "); + }; + const description = readFileSync(path.join(member("model"), "elide.pkl"), "utf8").match(/^description = "(.*)"$/m)![1]!; + const hover = await waitFor("synced workspace member hover", async () => { + const value = await memberHover(); + return value.includes(description) ? value : undefined; + }, 60_000, 500); + assert.match(hover, /model/); + const originalManifest = manifestDocument.getText(); + const edit = new vscode.WorkspaceEdit(); + edit.replace(manifestUri, new vscode.Range(manifestDocument.positionAt(modelOffset), manifestDocument.positionAt(modelOffset + "model".length)), "./parser"); + assert.ok(await vscode.workspace.applyEdit(edit)); + const editedLinks = await vscode.commands.executeCommand("vscode.executeLinkProvider", manifestUri); + assert.ok(editedLinks?.some((link) => link.target?.toString() === vscode.Uri.file(path.join(member("parser"), "elide.pkl")).toString() && link.range.contains(modelPosition)), "unsaved member edits change navigation"); + assert.ok(!editedLinks?.some((link) => link.target?.toString() === modelUri), "the old synced declaration does not supply a stale link"); + const unloadedEdit = new vscode.WorkspaceEdit(); + unloadedEdit.replace(manifestUri, new vscode.Range(manifestDocument.positionAt(modelOffset), manifestDocument.positionAt(modelOffset + "./parser".length)), "not-loaded"); + assert.ok(await vscode.workspace.applyEdit(unloadedEdit)); + assert.equal(await memberHover(), "", "unloaded members have no project metadata hover"); + const missingLinks = await vscode.commands.executeCommand("vscode.executeLinkProvider", manifestUri); + assert.ok(!missingLinks?.some((link) => link.range.contains(modelPosition)), "missing member manifests have no navigation target"); + await vscode.commands.executeCommand("workbench.action.files.revert"); + assert.equal(manifestDocument.getText(), originalManifest); + log("workspace member links, definitions, synced hover and unsaved navigation ok"); + if (process.env.ELIDE_TEST_WORKSPACE_NAVIGATION_ONLY === "1") return; + // 2. The Kotlin LSP resolves sibling code from `cli` through the module dependencies, without anything built: // `parser` directly, and `model` (the type `parseFile` returns) through it. const commandKt = vscode.Uri.file(path.join(member("cli"), "src", "main", "kotlin", "logstat", "cli", "Command.kt"));