diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..bbbe3cd --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,104 @@ +# Publishing to the Visual Studio Marketplace + +Everything in this repository that can be automated is in place: the manifest carries the metadata the Marketplace +requires, `bun run --filter elide package` produces a clean `.vsix`, and `.github/workflows/release.yml` publishes on a +`vX.Y.Z` tag as soon as a `VSCE_PAT` secret exists. What is left are the steps that need a human with an account — +none of them can be done from this repository. + +## What the repo already provides + +| Requirement | Where | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `publisher`, `name`, `version`, `engines.vscode` | `packages/vscode/package.json` (`elide-dev.elide`, `0.1.0`, `^1.105.0`) | +| Icon (256×256 PNG, brand mark) | `packages/vscode/icon.png`, rasterized from Elide's official `elide-square-gradient` artwork; 128×128 is the Marketplace minimum, 256×256 is what it recommends for retina listings | +| Gallery banner | `galleryBanner` (`#0F0F0F`, dark) | +| Marketplace README, changelog, license | `packages/vscode/{README.md,CHANGELOG.md,LICENSE}`, all shipped in the `.vsix` | +| Repository / issues / homepage links | `repository`, `bugs`, `homepage` in the manifest | +| Trust and remote declarations | `capabilities.untrustedWorkspaces` (unsupported), `capabilities.virtualWorkspaces` (unsupported), `extensionKind: ["workspace"]` | +| Reproducible package build | `vscode:prepublish` builds `@elide/ide-core` and the bundle, so `vsce package` works from a clean checkout | +| Automated publication | `Release` workflow: tag check → unit tests → `tools/deploy.sh` → `vsce publish --packagePath` (skipped without `VSCE_PAT`) → GitHub release | + +The `.vsix` contents were verified locally: `extension/{package.json,README.md,CHANGELOG.md,LICENSE.txt,icon.png,dist/extension.js}`, 40 KB total, no source or test files. + +## Remaining manual steps + +### 1. Azure DevOps organization and publisher + +1. Sign in to with the Microsoft/Entra account that should own the extension (a shared Elide + account, not a personal one — the publisher cannot be transferred without support intervention). +2. Create (or reuse) an Azure DevOps organization for Elide. +3. Create the publisher at . The **publisher ID must be + exactly `elide-dev`** — that is what `packages/vscode/package.json` and `tools/deploy.sh` assume. If `elide-dev` is + already taken, the manifest `publisher`, the extension ID used by `plugins.elide.dev` (`elide-dev.elide`), and the + README install links all have to change together. +4. Fill in the publisher display name, logo, and links on the management page. This data is publisher-level and cannot + be set from the manifest. + +### 2. Personal access token + +1. In Azure DevOps: **User settings ▸ Personal access tokens ▸ New Token**. +2. Organization: **All accessible organizations** (required; a single-org token fails with a 401 at publish time). +3. Scopes: **Custom defined ▸ Marketplace ▸ Manage**. Nothing else. +4. Expiration: maximum is 1 year — put a calendar reminder on the rotation date, or the release workflow starts + skipping the publish step silently (it only emits a notice when the secret is missing; an expired token fails loudly + instead). +5. Store it as the repository secret **`VSCE_PAT`** (Settings ▸ Secrets and variables ▸ Actions). No other change is + needed: the workflow step activates on its own. + +### 3. Verify the publisher (optional, recommended) + +Domain verification adds the blue check next to the publisher name. It requires adding a TXT record to a domain you +control (`elide.dev`) from the publisher management page. Not required to publish. + +### 4. First publication + +The very first upload of an extension ID is the risky one — the Marketplace validates the manifest, icon, and README +before the extension appears, and rejected uploads can leave the ID in a half-created state. Two options: + +- **Manual first release (recommended).** From a clean checkout: + ```sh + bun install + bun run --filter elide package # → packages/vscode/elide-0.1.0.vsix + cd packages/vscode + bunx @vscode/vsce login elide-dev # paste the PAT + bunx @vscode/vsce publish --no-dependencies --packagePath elide-0.1.0.vsix + ``` + Then tag `v0.1.0` so the plugins.elide.dev upload and GitHub release happen through the workflow (the workflow's + Marketplace step will re-publish the same version and fail if `VSCE_PAT` is already set — publish manually *or* by + tag for the first release, not both). +- **Tag-driven.** Set `VSCE_PAT` first, then push `v0.1.0` and watch the `Release` run. + +Marketplace validation runs asynchronously after upload; the listing goes live within a few minutes, and a validation +failure arrives by email to the publisher account. + +### 5. Post-publication follow-ups + +- Add the Marketplace badges to the READMEs once the listing exists, e.g. + `https://img.shields.io/visual-studio-marketplace/v/elide-dev.elide` (version) and `.../i/elide-dev.elide` + (installs). `img.shields.io` is on vsce's trusted-badge list, so these are safe in the packaged README. +- Replace the "Until the extension is on the Marketplace…" section of the root `README.md` with the Marketplace + install instructions (`code --install-extension elide-dev.elide`), keeping the `plugins.elide.dev` link as the + fallback for air-gapped installs. +- Drop `"preview": true` from `packages/vscode/package.json` when the extension leaves 0.x, so the "Preview" badge + disappears from the listing. +- Consider publishing to **Open VSX** () as well, for VSCodium, Cursor, Windsurf, and Gitpod + users: create an Eclipse Foundation account, sign the publisher agreement, mint an access token, and add an + `npx ovsx publish` step next to the Marketplace step. This needs its own account and agreement, so it is a separate + manual decision. +- Enable **private vulnerability reporting** in the repository settings (Settings ▸ Code security) so the + `security/advisories/new` link in `SECURITY.md` resolves; it 404s for reporters until that switch is on. +- Confirm the org identifiers these files assert, none of which can be checked from this repository: + - `engineering@elide.dev` is the fallback contact in `SECURITY.md` and the enforcement contact in + `CODE_OF_CONDUCT.md`. It is the address on Elide's release GPG key, so it exists — but confirm it routes to + people who should receive vulnerability and conduct reports, or swap in a dedicated mailbox. + - `.github/CODEOWNERS` lists `@sgammon @darvld`, mirroring the main Elide repository's ownership. Switch to a + GitHub team if one is created; CODEOWNERS silently fails for handles without write access to the repo. + +## Things that intentionally were not changed + +- `packages/vscode/package.json` keeps `"private": true`. `vsce` ignores the field (it is checked by npm, not by the + Marketplace); it prevents an accidental `npm publish` of the extension package. +- The extension is not marked as a pre-release (`--pre-release`); `preview: true` is metadata only and does not affect + the release channel. +- No platform-specific (`--target`) packages: the extension is pure JavaScript and depends on a locally installed + Elide CLI. diff --git a/README.md b/README.md index 39ae134..6b59470 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,10 @@ Opening a folder that contains `elide.pkl` runs a **sync**: `intellij.jdkForSymbolResolution` is written to **user** settings. A running Kotlin LSP is then asked to reload. `workspace.json` is a generated artifact — add it to `.gitignore`. Every `elide.pkl` under a workspace folder (outside -`.dev/` and `node_modules/`) becomes a set of modules in that folder's single `workspace.json`. +`.dev/` and `node_modules/`) becomes a set of modules in that folder's single `workspace.json`. A manifest nested +inside another project's directory — a vendored checkout, a sample, a fixture — is a separate build the enclosing +project does not invoke: only the outermost manifest of each tree is imported, and changes to the nested ones do not +trigger a sync. ### Mixed-editor checkouts (`.idea`, Gradle, Maven) diff --git a/bun.lock b/bun.lock index 84ae7a9..49fcb69 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "version": "0.1.0", "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^22.15.0", + "@types/node": "^26.5.0", "typescript": "^5.9.0", }, }, @@ -21,7 +21,7 @@ "@elide/ide-core": "workspace:*", }, "devDependencies": { - "@types/node": "^22.15.0", + "@types/node": "^26.5.0", "@types/vscode": "^1.105.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.6.0", @@ -161,7 +161,7 @@ "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], - "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -663,7 +663,7 @@ "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -707,6 +707,8 @@ "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "bun-types/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -745,6 +747,8 @@ "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/packages/core/package.json b/packages/core/package.json index a13c285..c680c6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^22.15.0", + "@types/node": "^26.5.0", "typescript": "^5.9.0" } } diff --git a/packages/core/src/elide.ts b/packages/core/src/elide.ts index d0fc496..0040647 100644 --- a/packages/core/src/elide.ts +++ b/packages/core/src/elide.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { ElideCommandFailedError, ElideNotFoundError, InvalidElideHomeError, ManifestParseError } from "./errors.js"; import { decodeManifest, type Manifest } from "./manifest.js"; +import { isPathUnder, normalizePath } from "./sourceRoots.js"; export interface ElideDistribution { /** Distribution root, e.g. `~/.local/share/elide`. */ @@ -292,6 +293,34 @@ export function isLockfileName(fileName: string): boolean { return fileName.startsWith(LOCKFILE_PREFIX) && fileName.endsWith(LOCKFILE_EXTENSION); } +/** Whether `p` is a strict descendant of directory `dir`; both are resolved and compared as normalized paths. */ +export function isNestedUnder(p: string, dir: string): boolean { + const child = normalizePath(path.resolve(p)); + const parent = normalizePath(path.resolve(dir)); + return child !== parent && isPathUnder(child, parent); +} + +/** + * Keep only the outermost manifest of each directory tree. + * + * Elide has no subproject concept: a manifest inside another project's directory (a vendored checkout, a sample, a + * test fixture) is a separate build the enclosing project never invokes. Importing both resolves the inner sources + * twice and produces overlapping content roots in the generated workspace model, so the nested ones are dropped. + * Input order is preserved. + */ +export function outermostManifests(manifestPaths: Iterable): string[] { + const all = [...manifestPaths].map((manifest) => ({ manifest, root: path.dirname(path.resolve(manifest)) })); + const roots: string[] = []; + const kept = new Set(); + // Shallower roots first: an ancestor is always shorter than its descendants, so it is decided before them. + for (const entry of [...all].sort((a, b) => a.root.length - b.root.length)) { + if (roots.some((r) => r === entry.root || isNestedUnder(entry.root, r))) continue; + roots.push(entry.root); + kept.add(entry.manifest); + } + return all.map((e) => e.manifest).filter((m) => kept.has(m)); +} + /** * Whether installed dependencies can be trusted without `elide install`: `.dev/dependencies` exists and the * newest `.dev/elide.lock*.bin` is at least as recent as the manifest. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a46c1c4..2cc208b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,9 @@ export { distributionAt, isLockfileCurrent, isLockfileName, + isNestedUnder, killProcessTree, + outermostManifests, parseClasspath, resolveElideDistribution, type ClasspathUsage, diff --git a/packages/core/test/elide.test.ts b/packages/core/test/elide.test.ts index 92bd7fd..f6e3d24 100644 --- a/packages/core/test/elide.test.ts +++ b/packages/core/test/elide.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { ElideNotFoundError, InvalidElideHomeError, parseClasspath, resolveElideDistribution } from "../src/index.js"; +import { ElideNotFoundError, InvalidElideHomeError, outermostManifests, parseClasspath, resolveElideDistribution } from "../src/index.js"; describe("resolveElideDistribution", () => { const files = new Set(["/custom/elide/bin/elide", "/home/u/.local/share/elide/bin/elide", "/usr/local/bin/elide"]); @@ -27,3 +27,33 @@ describe("parseClasspath", () => { expect(parseClasspath("", "/proj", ":")).toEqual([]); }); }); + +describe("outermostManifests", () => { + test("drops manifests nested inside another project, keeping discovery order", () => { + expect( + outermostManifests([ + "/proj/tools/elide.pkl", + "/proj/elide.pkl", + "/proj/samples/ktjvm/elide.pkl", + "/proj/third_party/vendored/deep/elide.pkl", + ]), + ).toEqual(["/proj/elide.pkl"]); + }); + + test("keeps siblings and prefix-sharing roots", () => { + expect(outermostManifests(["/w/app/elide.pkl", "/w/app-tests/elide.pkl", "/w/lib/elide.pkl"])).toEqual([ + "/w/app/elide.pkl", + "/w/app-tests/elide.pkl", + "/w/lib/elide.pkl", + ]); + }); + + test("an outer manifest discovered last still shadows the inner ones", () => { + expect(outermostManifests(["/w/a/b/elide.pkl", "/w/a/elide.pkl"])).toEqual(["/w/a/elide.pkl"]); + }); + + test("relative and non-normalized paths resolve before comparison", () => { + const cwd = process.cwd(); + expect(outermostManifests(["elide.pkl", `${cwd}/sub/./elide.pkl`])).toEqual(["elide.pkl"]); + }); +}); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index d5f23aa..44b9dac 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to the Elide extension are documented here. The format follo ## [Unreleased] +### Changed + +- Manifests nested inside another Elide project (vendored checkouts, samples, fixtures) are no longer imported as + projects: only the outermost `elide.pkl` of each directory tree is synced, and edits to nested manifests no longer + mark the folder out of date. Deleting an enclosing manifest promotes the manifests it was shadowing. + ## [0.1.0] - 2026-09-06 First release. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 08a12b3..e0dd346 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -11,7 +11,8 @@ Elide's build, run, test, and debug commands are available from the editor. ## Features - **Project sync.** Opening a folder with `elide.pkl` resolves the manifest, installs dependencies when the lockfile is - stale, and reads the compile classpath of every source set. + stale, and reads the compile classpath of every source set. Manifests nested inside another project (vendored + checkouts, samples) are separate builds and are not imported. - **Kotlin/Java code intelligence.** The project model is written as a `workspace.json` the Kotlin LSP imports — modules per source set, libraries with attached sources and javadoc, the selected JDK, and the manifest's Kotlin compiler options. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 17ecdaa..656f4ab 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -226,7 +226,7 @@ "@elide/ide-core": "workspace:*" }, "devDependencies": { - "@types/node": "^22.15.0", + "@types/node": "^26.5.0", "@types/vscode": "^1.105.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.6.0", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 15fc35d..32d1d16 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -51,9 +51,15 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork ); }; - const onStale = (uri: vscode.Uri, what: string) => { - const folder = workspace.locate(uri)?.folder; - if (!folder || workspace.projectsIn(folder).length === 0 || workspace.isSelfInflicted(folder)) return; + /** + * React to a change under a project root. `rootOf` maps the changed file to the directory whose manifest owns it; + * a path that is not a tracked project root belongs to a nested (ignored) manifest and is not a reason to resync. + */ + const onStale = (uri: vscode.Uri, what: string, rootOf: (fsPath: string) => string) => { + const located = workspace.locate(uri); + if (!located) return; + const folder = located.folder; + if (!workspace.projectAt(rootOf(located.fsPath)) || workspace.isSelfInflicted(folder)) return; workspace.markStale(folder); ui.log(`${what} changed: ${uri.fsPath}`); const policy = readConfig(folder).onManifestChange; @@ -62,7 +68,7 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork }; const manifests = vscode.workspace.createFileSystemWatcher(`**/${MANIFEST_NAME}`); - manifests.onDidChange((uri) => onStale(uri, MANIFEST_NAME)); + manifests.onDidChange((uri) => onStale(uri, MANIFEST_NAME, path.dirname)); manifests.onDidCreate((uri) => { const project = workspace.addProject(uri); if (!project) return; @@ -70,17 +76,20 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork ui.setStatus("stale"); schedule(project.folder, "project-added"); }); - manifests.onDidDelete((uri) => { + manifests.onDidDelete(async (uri) => { const project = workspace.removeProject(uri); if (!project) return; ui.log(`project removed: ${project.root}`); + // Manifests that were nested inside the deleted project become projects of their own. + await workspace.discover(project.folder); if (workspace.projectsIn(project.folder).length > 0) void workspace.syncFolder(project.folder, "project-removed"); else ui.setStatus("none"); }); const lockfiles = vscode.workspace.createFileSystemWatcher("**/.dev/elide.lock*"); const onLock = (uri: vscode.Uri) => { - if (isLockfileName(path.basename(uri.fsPath))) onStale(uri, "lockfile"); + // `/.dev/elide.lock*.bin`: two levels up from the lockfile. + if (isLockfileName(path.basename(uri.fsPath))) onStale(uri, "lockfile", (p) => path.dirname(path.dirname(p))); }; lockfiles.onDidChange(onLock); lockfiles.onDidCreate(onLock); diff --git a/packages/vscode/src/projects.ts b/packages/vscode/src/projects.ts index 3e1856f..70be3a7 100644 --- a/packages/vscode/src/projects.ts +++ b/packages/vscode/src/projects.ts @@ -9,6 +9,8 @@ import { ManifestParseError, WORKSPACE_JSON, buildProjectModel, + isNestedUnder, + outermostManifests, resolveElideDistribution, writeKotlinLspWorkspace, type ElideDistribution, @@ -78,14 +80,33 @@ export class ElideWorkspace implements vscode.Disposable { return [...(this.folders.get(folder.uri.toString())?.projects.values() ?? [])]; } + /** The tracked project rooted exactly at `root`; `undefined` for a directory whose manifest is nested and ignored. */ + projectAt(root: string): ElideProject | undefined { + const abs = path.resolve(root); + for (const state of this.folders.values()) { + const project = state.projects.get(abs); + if (project) return project; + } + return undefined; + } + + /** + * Find the projects of `folder`: every `elide.pkl` outside `.dev/` and `node_modules/`, minus the manifests nested + * inside another project's directory, which are separate builds this one does not include. + */ async discover(folder: vscode.WorkspaceFolder): Promise { const found = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, `**/${MANIFEST_NAME}`), DISCOVERY_EXCLUDE); + const manifests = outermostManifests(found.map((uri) => uri.fsPath)); const state = this.folderState(folder); const seen = new Set(); - for (const uri of found) { - const root = path.dirname(uri.fsPath); + for (const manifestPath of manifests) { + const root = path.dirname(manifestPath); seen.add(root); - if (!state.projects.has(root)) state.projects.set(root, { root, manifestPath: uri.fsPath, folder }); + if (!state.projects.has(root)) state.projects.set(root, { root, manifestPath, folder }); + } + if (manifests.length < found.length) { + const kept = new Set(manifests); + for (const uri of found) if (!kept.has(uri.fsPath)) this.ui.log(`ignoring nested manifest: ${uri.fsPath}`); } for (const root of [...state.projects.keys()]) if (!seen.has(root)) state.projects.delete(root); this.changed.fire(); @@ -111,17 +132,30 @@ export class ElideWorkspace implements vscode.Disposable { return undefined; } + /** + * Track a manifest a watcher just reported. Returns `undefined` when the manifest sits inside an existing project + * and is therefore not imported; a manifest that encloses tracked projects takes their trees over. + */ addProject(manifestUri: vscode.Uri): ElideProject | undefined { const located = this.locate(manifestUri); if (!located) return undefined; const state = this.folderState(located.folder); const root = path.dirname(located.fsPath); - let project = state.projects.get(root); - if (!project) { - project = { root, manifestPath: located.fsPath, folder: located.folder }; - state.projects.set(root, project); - this.changed.fire(); + const existing = state.projects.get(root); + if (existing) return existing; + const outer = [...state.projects.keys()].find((other) => isNestedUnder(root, other)); + if (outer) { + this.ui.log(`ignoring nested manifest: ${located.fsPath} (inside ${outer})`); + return undefined; + } + for (const inner of [...state.projects.keys()]) { + if (!isNestedUnder(inner, root)) continue; + state.projects.delete(inner); + this.ui.log(`project dropped: ${inner} is now nested inside ${root}`); } + const project: ElideProject = { root, manifestPath: located.fsPath, folder: located.folder }; + state.projects.set(root, project); + this.changed.fire(); return project; } diff --git a/packages/vscode/test/integration/runner.ts b/packages/vscode/test/integration/runner.ts index 63a9010..7cf7b28 100644 --- a/packages/vscode/test/integration/runner.ts +++ b/packages/vscode/test/integration/runner.ts @@ -44,6 +44,27 @@ writeFileSync( const sample = mkdtempSync(path.join(realpathSync(tmpdir()), "elide-ktjvm-")); cpSync(path.join(repoRoot, "samples", "ktjvm"), sample, { recursive: true, filter: (src) => !src.includes(`${path.sep}.dev`) && !src.endsWith("workspace.json") }); +// A vendored checkout with its own manifest, nested inside the project: a separate build the sample never invokes, +// so it must not appear in workspace.json (the module assertion in index.ts covers it). +mkdirSync(path.join(sample, "vendored", "src", "main", "nested"), { recursive: true }); +writeFileSync(path.join(sample, "vendored", "src", "main", "nested", "Nested.kt"), "package nested\n\nfun vendored() = 1\n"); +writeFileSync( + path.join(sample, "vendored", "elide.pkl"), + `amends "elide:project.pkl" +import "elide:Sources.pkl" as Sources + +name = "vendored-sample" + +sources { + ["main"] = new Sources.SourceSetSpec { + paths { + "src/main/**/*.kt" + } + } +} +`, +); + // A checked-in `.idea` (team members on IntelliJ) must not divert the Kotlin LSP away from workspace.json: the // server's auto-detection picks JPS whenever `.idea/modules.xml` exists, so the extension has to pin the importer. mkdirSync(path.join(sample, ".idea", "modules"), { recursive: true });