Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/buildExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ManifestParseError } from "./errors.js";
*/
export interface Manifest {
name?: string;
description?: string;
version?: string;
entrypoint: string[];
scripts: Record<string, string>;
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion packages/core/test/buildExtensions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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([]);
});
});
1 change: 1 addition & 0 deletions packages/vscode/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ ts_project(
"src/targetShortcuts.ts",
"src/tasks.ts",
"src/testing.ts",
"src/workspaceMembers.ts",
"src/workspacePaths.ts",
],
declaration = False,
Expand Down
8 changes: 8 additions & 0 deletions packages/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 2 additions & 0 deletions packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +88,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<ElideE
context.subscriptions.push(
codeLenses,
registerBuildExtensionLinks(workspace),
registerWorkspaceMemberNavigation(workspace),
vscode.languages.registerCodeLensProvider(ElideCodeLensProvider.selector, codeLenses),
vscode.commands.registerCommand("elide.sync", (target: unknown) => syncCommand(workspace, target)),
vscode.commands.registerCommand("elide.installKomodo", () => installKomodoCommand(workspace, ui)),
Expand Down
75 changes: 75 additions & 0 deletions packages/vscode/src/workspaceMembers.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.Uri | undefined> {
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);
},
}),
);
}
40 changes: 40 additions & 0 deletions packages/vscode/test/integration/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ export async function runWorkspaceScenario(): Promise<void> {
);
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.DocumentLink[]>("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.Hover[]>("vscode.executeHoverProvider", manifestUri, modelPosition);
return (hovers?.flatMap((hover) => hover.contents.map((content) => typeof content === "string" ? content : "value" in content ? content.value : "")).join("\n") ?? "").replaceAll("&nbsp;", " ");
};
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.DocumentLink[]>("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.DocumentLink[]>("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"));
Expand Down
Loading