From a62ec9f2f7a296ee4faf9ee73ed1b21ab389909b Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 10:38:28 +0100 Subject: [PATCH] docs: publish reviewed workspace Open proposal and test evidence --- README.md | 2 +- .../workspace-open/NIMBALYST-LICENSE.txt | 21 + docs/upstream/workspace-open/README.md | 53 ++ .../workspace-open/final-sequential-gate.log | 91 +++ .../workspace-open/focused-open.config.mts | 23 + docs/upstream/workspace-open/manifest.json | 39 ++ docs/upstream/workspace-open/source.json | 55 ++ .../upstream/workspace-open/verification.json | 83 +++ .../workspace-open/workspace-open.patch | 525 ++++++++++++++++++ 9 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 docs/upstream/workspace-open/NIMBALYST-LICENSE.txt create mode 100644 docs/upstream/workspace-open/README.md create mode 100644 docs/upstream/workspace-open/final-sequential-gate.log create mode 100644 docs/upstream/workspace-open/focused-open.config.mts create mode 100644 docs/upstream/workspace-open/manifest.json create mode 100644 docs/upstream/workspace-open/source.json create mode 100644 docs/upstream/workspace-open/verification.json create mode 100644 docs/upstream/workspace-open/workspace-open.patch diff --git a/README.md b/README.md index 46c720e..7dc8e03 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The asset importer checks SHA-256 `daa104aecdca43df5ca6dcf36b420351be09784a8436a The visual dependency builder verifies all released files before bundling local Shoelace modules and embedding the exact local font/icon bytes. Editor, graph Worker and Rust/WASM source stay unchanged; no CDN is used by the frame. The resulting source-component manifest is not a released native-package manifest. -The read-only component workflow runs these checks on Linux, macOS and Windows and retains actual logs and saved outputs. It builds and retains a development candidate package without publishing a release. Every supported native app must install the eventual package and produce saved outputs for all 41 acceptance requirements before a release. The native Open action also depends on a [supported upstream capability](https://github.com/nimbalyst/nimbalyst/issues/1764), absent from released SDK 0.7.0. Track the remaining work in [NIM-10](https://github.com/terraphim/terraphim-editor/issues/6). +The read-only component workflow runs these checks on Linux, macOS and Windows and retains actual logs and saved outputs. It builds and retains a development candidate package without publishing a release. Every supported native app must install the eventual package and produce saved outputs for all 41 acceptance requirements before a release. The native Open action also depends on a [supported upstream capability](https://github.com/nimbalyst/nimbalyst/issues/1764), absent from the inspected released app 0.81.7 and SDK 0.7.1. The adapter still pins SDK 0.7.0. A [reviewed source proposal and actual upstream test results](docs/upstream/workspace-open/README.md) are available for review; they are not a shipped host capability. Track the remaining work in [NIM-10](https://github.com/terraphim/terraphim-editor/issues/6). ## Source and dependencies diff --git a/docs/upstream/workspace-open/NIMBALYST-LICENSE.txt b/docs/upstream/workspace-open/NIMBALYST-LICENSE.txt new file mode 100644 index 0000000..a861e41 --- /dev/null +++ b/docs/upstream/workspace-open/NIMBALYST-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Nimbalyst Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/upstream/workspace-open/README.md b/docs/upstream/workspace-open/README.md new file mode 100644 index 0000000..b10fdfd --- /dev/null +++ b/docs/upstream/workspace-open/README.md @@ -0,0 +1,53 @@ +# Proposed workspace Open for custom editors + +This is a review packet for [Nimbalyst issue 1764](https://github.com/nimbalyst/nimbalyst/issues/1764). It contains an agent-prepared nine-file source proposal and actual test results. It is published in Terraphim's repository so maintainers and a human contributor can inspect the concrete change before an upstream contribution. + +**It is not accepted upstream or shipped in Nimbalyst.** At verification time on 17 October 2026, the inspected app 0.81.7 and SDK 0.7.1 did not expose this capability. Terraphim's adapter still pins SDK 0.7.0, and its first native release remains held for the required qualification. + +## Behavior proposed + +An optional `EditorHost.openWorkspaceFile()` asks the existing desktop host to choose an existing workspace text file and add or activate its tab. It returns `opened`, `cancelled` or `refused`; `opened` confirms tab admission, not a disk save or completed editor initialization. Existing tabs and dirty buffers are retained. Already-open files activate without reload. + +The existing sender/workspace and symlink boundaries remain authoritative. The proposal refuses unsupported binary content, directories, absent files, oversized files and an originating tab/workspace that changed during admission. Embedded, collaborative and older hosts leave the method absent. It adds no extension permission to call private Electron IPC. + +## Source and reproducible checks + +- [Exact patch](workspace-open.patch), SHA-256 `09c211cbd4e7ce08a327c541b4fb18c9a5ab3ed4105618e96ae12d3d3b0b03cc`. +- [Upstream base and nine resulting file hashes](source.json): main `b5d994092c27ebe1f6a402c61351081b0dd52c56`. The patch also applies to inspected v0.81.7 source `4fc0ce0f4d41239b323cf6b478153f445a912d0e`. +- [Actual verification receipt](verification.json) and [final sequential gate output](final-sequential-gate.log). Only the disposable absolute checkout path is normalized to `$CHECKOUT`; both raw and public log hashes are recorded. +- [Upstream MIT notice](NIMBALYST-LICENSE.txt) accompanies the proposal, which includes upstream context. + +In a disposable upstream checkout at that base, use its pinned package manager and Node 24, apply the patch, install the unchanged frozen lockfile, and build the documented workspace dependencies. Runtime and memory-engine output must exist before the whole-workspace gate: + +```sh +git checkout b5d994092c27ebe1f6a402c61351081b0dd52c56 +git apply /path/to/workspace-open.patch +pnpm install --frozen-lockfile +pnpm run build:tracker-packages +pnpm --filter @nimbalyst/extension-sdk run build +pnpm --filter @nimbalyst/runtime run build +pnpm --dir packages/extensions/nimbalyst-memory/engine run build +pnpm typecheck && pnpm test:prepush +``` + +The tested runtime was Node 24.22.0 and pnpm 12.9.1. Upstream's `pnpm install` prepare hook ran `build:tracker-packages`; that explicit command is shown above so preparation is visible. The SDK build also ran before the focused tests. Build scripts regenerate declaration byproducts; keep those separate from this exact nine-file contribution. Tests need loopback access for real local HTTP servers. + +To repeat the separate fifteen-test filesystem/tab-store check without upstream's shared test setup or module substitutes, copy the included, unchanged [focused configuration](focused-open.config.mts) into the disposable checkout and run it there: + +```sh +mkdir -p .artifacts +cp /path/to/workspace-open/focused-open.config.mts .artifacts/focused-open.config.mts +pnpm exec vitest --run --config .artifacts/focused-open.config.mts +``` + +That configuration aliases actual upstream source modules, uses real filesystem/Tab-store behavior, and has no setup files or IPC/module mocks. It is a validation harness accompanying this packet, not part of the nine-file upstream implementation patch. + +The final official sequential gate exited zero: **37 workspace typechecks passed; 17,806 tests passed, zero failed, 30 skipped across 2,096 files**. Fifteen focused real filesystem/tab-store tests, SDK build, Electron types and four changed-module lint checks also passed. Two existing TabEditor lint-rule configuration errors and upstream runtime/JSDOM diagnostics are retained; no zero-warning or clean whole-repository lint result is claimed. + +The initial unprepared/sandboxed full run failed; after official builds and loopback access, the exact ten previously failing files passed 135 tests. That history is retained in the receipt rather than presenting its failure as an earlier success. The later final passing whole gate is a distinct run, with all nine reviewed patch-file hashes unchanged afterward. + +## Contribution and release gates + +This code was prepared with an agent. No upstream commit, contributor identity or personal `Signed-off-by` certificate is supplied. Nimbalyst's current contribution policy requires truthful authorship, a real-name DCO, normal review and green checks. A contributor must personally decide whether they can certify the DCO and how to represent authorship accurately; this packet does not impersonate that decision. + +Actual native chooser/cancellation/refusal/dirty-tab verification remains outstanding. Maintainer agreement, normal upstream contribution and a released host/SDK with the supported API must precede Terraphim's three-platform native acceptance. All 41 editor requirements on actual macOS, Windows and Linux apps, reviewed admission, released-package installation and marketplace admission remain separate gates. The adapter's closed release registry is unchanged. diff --git a/docs/upstream/workspace-open/final-sequential-gate.log b/docs/upstream/workspace-open/final-sequential-gate.log new file mode 100644 index 0000000..6b599fb --- /dev/null +++ b/docs/upstream/workspace-open/final-sequential-gate.log @@ -0,0 +1,91 @@ +$ node scripts/run-workspace-script.mjs typecheck +Running "typecheck" in 37 workspaces, 8 at a time +ok packages/tracker-engine +ok packages/tracker-core +ok packages/tracker-schema +ok packages/node +ok packages/collab-protocol +ok packages/collab-adapters +ok packages/markdown-ydoc +ok packages/cli +ok packages/local-wiki +ok packages/extensions/animation +ok packages/extension-sdk +ok packages/extensions/browser +ok packages/extensions/calc-sheets +ok packages/extensions/crew +ok packages/extensions/datamodellm +ok packages/extensions/csv-spreadsheet +ok packages/extensions/extension-dev-kit +ok packages/collab-client +ok packages/extensions/excalidraw +ok packages/extensions/github-issues-importer +ok packages/extensions/git +ok packages/extensions/knowledge +ok packages/extensions/math +ok packages/extensions/media-viewer +ok packages/extensions/pdf-viewer +ok packages/extensions/playwright +ok packages/extensions/nimbalyst-memory +ok packages/extensions/rtl-support +ok packages/extensions/nimbalyst-memory/engine +ok packages/opencode-plugin +ok packages/cloudflare-sandbox +ok packages/extensions/project-graph +ok packages/runtime +ok packages/collab-bundle +ok packages/web-editors +ok packages/wiki-web +ok packages/electron + +"typecheck" passed in 37 workspaces (136.2s) +$ vitest --run --reporter=dot --reporter=./scripts/vitest-run-log-reporter.mjs --silent=passed-only + + RUN v4.1.11 $CHECKOUT + +·····································································································································································································································································································································································································Error: Not implemented: HTMLCanvasElement.prototype.getContext (without installing the canvas npm package) + at module.exports ($CHECKOUT/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17) + at HTMLCanvasElementImpl.getContext ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLCanvasElement-impl.js:42:5) + at HTMLCanvasElement.getContext ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/generated/HTMLCanvasElement.js:131:58) + at measureText ($CHECKOUT/packages/runtime/src/ui/quadrant/QuadrantChart.tsx:41:97) + at wrapCaption ($CHECKOUT/packages/runtime/src/ui/quadrant/quadrantLayout.ts:56:7) + at layoutQuadrant ($CHECKOUT/packages/runtime/src/ui/quadrant/quadrantLayout.ts:83:34) + at $CHECKOUT/packages/runtime/src/ui/quadrant/QuadrantChart.tsx:74:11 + at mountMemo ($CHECKOUT/node_modules/react-dom/cjs/react-dom-client.development.js:8777:23) + at Object.useMemo ($CHECKOUT/node_modules/react-dom/cjs/react-dom-client.development.js:26216:18) + at process.env.NODE_ENV.exports.useMemo ($CHECKOUT/node_modules/react/cjs/react.development.js:1251:34) undefined +········································································································································································································································································································································································································································································································································································································································································································································································································---··········································································································································································································································································Error: Not implemented: HTMLCanvasElement.prototype.getContext (without installing the canvas npm package) + at module.exports ($CHECKOUT/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17) + at HTMLCanvasElementImpl.getContext ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLCanvasElement-impl.js:42:5) + at HTMLCanvasElement.getContext ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/generated/HTMLCanvasElement.js:131:58) + at file://$CHECKOUT/node_modules/vega-scenegraph/build/vega-scenegraph.js:1396:50 + at processTicksAndRejections (node:internal/process/task_queues:104:5) undefined +··························································································································································································································-·························································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································-·······································································································································································Error: Not implemented: navigation (except hash changes) + at module.exports ($CHECKOUT/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17) + at navigateFetch ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:77:3) + at exports.navigate ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:55:3) + at Timeout._onTimeout ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLHyperlinkElementUtils-impl.js:80:7) + at listOnTimeout (node:internal/timers:685:17) + at processTimers (node:internal/timers:618:7) undefined +·Error: Not implemented: navigation (except hash changes) + at module.exports ($CHECKOUT/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17) + at navigateFetch ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:77:3) + at exports.navigate ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:55:3) + at Timeout._onTimeout ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLHyperlinkElementUtils-impl.js:80:7) + at listOnTimeout (node:internal/timers:685:17) + at processTimers (node:internal/timers:618:7) undefined +Error: Not implemented: navigation (except hash changes) + at module.exports ($CHECKOUT/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17) + at navigateFetch ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:77:3) + at exports.navigate ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/window/navigation.js:55:3) + at Timeout._onTimeout ($CHECKOUT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLHyperlinkElementUtils-impl.js:80:7) + at listOnTimeout (node:internal/timers:685:17) + at processTimers (node:internal/timers:618:7) undefined +······················································································································································································································································································································································-··-································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································-----··························································································································································································································································································································································································································································································································································································-····················································································································································································································································································································································································································································································································································································································································Preparing worktree (new branch 'task') +···································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································--············································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································································---······---------·--- + + Test Files 2089 passed | 7 skipped (2096) + Tests 17806 passed | 30 skipped (17836) + Start at 10:23:16 + Duration 499.27s (transform 247.14s, setup 222.69s, import 2169.19s, tests 1497.25s, environment 408.20s) + diff --git a/docs/upstream/workspace-open/focused-open.config.mts b/docs/upstream/workspace-open/focused-open.config.mts new file mode 100644 index 0000000..dbab5ba --- /dev/null +++ b/docs/upstream/workspace-open/focused-open.config.mts @@ -0,0 +1,23 @@ +// Reconstructed validation harness; not part of the preserved implementation. +// All aliases select actual upstream modules. No setup files, IPC/module mocks, +// or substitutes for the filesystem reader or Jotai/TabsProvider are loaded. +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; +const root = path.resolve(import.meta.dirname, '..'); +export default defineConfig({ + root, + resolve: { alias: [ + { find: /^@nimbalyst\/runtime\/(.+)$/, replacement: path.join(root, 'packages/runtime/src') + '/$1' }, + { find: /^@nimbalyst\/extension-sdk$/, replacement: path.join(root, 'packages/extension-sdk/src/index.ts') }, + { find: /^@nimbalyst\/collab-protocol$/, replacement: path.join(root, 'packages/collab-protocol/src/index.ts') }, + ] }, + test: { + environment: 'jsdom', + setupFiles: [], + include: ['packages/electron/src/renderer/services/__tests__/WorkspaceFileOpen.test.tsx'], + testTimeout: 10000, + hookTimeout: 10000, + reporters: ['default', 'json'], + outputFile: { json: path.join(root, '.artifacts/focused-open-results.json') }, + }, +}); diff --git a/docs/upstream/workspace-open/manifest.json b/docs/upstream/workspace-open/manifest.json new file mode 100644 index 0000000..c07916f --- /dev/null +++ b/docs/upstream/workspace-open/manifest.json @@ -0,0 +1,39 @@ +{ + "files": [ + { + "path": "NIMBALYST-LICENSE.txt", + "bytes": 1076, + "sha256": "999b2e2de90cea0f48c54acfce3a1875cf05c814092008cb5bb0cf06a4ab7201" + }, + { + "path": "README.md", + "bytes": 5560, + "sha256": "0155b5a397e39742f0c5506e0daa6cb162b08b61d3bdd8ac191a30e8ae2981ff" + }, + { + "path": "final-sequential-gate.log", + "bytes": 40816, + "sha256": "058ba3bcc462f932e8dd52905fa6e43ab91fbea1a53e3b9ae590a42a47ad0946" + }, + { + "path": "focused-open.config.mts", + "bytes": 1101, + "sha256": "61dc66a24c9d4e5aca899fc965a71fc77b44eeffacb75eae70266769a611c7c4" + }, + { + "path": "source.json", + "bytes": 2144, + "sha256": "4dc9a0285ff2b1565358c44af40ac072d5031588ec43183ae90a8d226b978b20" + }, + { + "path": "verification.json", + "bytes": 2650, + "sha256": "b81004e2a486ce1becd2ba717541606186dd7ff0e117dba54475e792b4e2b35a" + }, + { + "path": "workspace-open.patch", + "bytes": 25864, + "sha256": "09c211cbd4e7ce08a327c541b4fb18c9a5ab3ed4105618e96ae12d3d3b0b03cc" + } + ] +} diff --git a/docs/upstream/workspace-open/source.json b/docs/upstream/workspace-open/source.json new file mode 100644 index 0000000..96678c4 --- /dev/null +++ b/docs/upstream/workspace-open/source.json @@ -0,0 +1,55 @@ +{ + "source": "b5d994092c27ebe1f6a402c61351081b0dd52c56", + "releasedTag": "v0.81.7", + "releasedTagCommit": "4fc0ce0f4d41239b323cf6b478153f445a912d0e", + "originalPatchAppliedWithoutModification": true, + "files": [ + { + "path": "docs/EXTENSION_ARCHITECTURE.md", + "bytes": 55330, + "sha256": "39621589eac0cd152ad328c9c05f70da244d1e52f9779ec1c2e4af57d44803f9" + }, + { + "path": "docs/FEATURE_INVENTORY.md", + "bytes": 74785, + "sha256": "8fef9794febbbd9349520dd9c9418893b9010a5ffecb99c1c6ffdc299ebc42a7" + }, + { + "path": "packages/collab-bundle/types/internal/extension-sdk/src/types/editor.d.ts", + "bytes": 39452, + "sha256": "37f5fb3dceaf47dbaaccbd9ee2e7413e780b5a4d64660349f31445b08667254a" + }, + { + "path": "packages/electron/src/renderer/components/TabContent/TabContent.tsx", + "bytes": 36197, + "sha256": "fa7fbcb7ae11acd883483a35cfe86ef34cafdced0e133e7a256761781464c41b" + }, + { + "path": "packages/electron/src/renderer/components/TabEditor/TabEditor.tsx", + "bytes": 170494, + "sha256": "7855843cc5206c32ade12813bb8697094bc73e1e7ed10cbfb1c6e72bc5ad2dd2" + }, + { + "path": "packages/electron/src/renderer/components/TabEditor/createEditorHost.ts", + "bytes": 9808, + "sha256": "c1d4ce9b10991b41939e6c2491b39d8b2f162792522fa0fef7616a3f297cfcc1" + }, + { + "path": "packages/electron/src/renderer/services/WorkspaceFileOpen.ts", + "bytes": 3695, + "sha256": "056fa1f00f5d10e075c5cb879f85f58135081b3e963a78b828b0a4d697edd127" + }, + { + "path": "packages/electron/src/renderer/services/__tests__/WorkspaceFileOpen.test.tsx", + "bytes": 9987, + "sha256": "0d1650248b4101dba586c48f238b441f9ba11c5a7a64a6b1ab10544c2715acac" + }, + { + "path": "packages/extension-sdk/src/types/editor.ts", + "bytes": 39845, + "sha256": "46036169be82779d4759bb3b4b69f24a50c8d7649ffa37261031cd01ffd6bd5d" + } + ], + "patchSha256": "09c211cbd4e7ce08a327c541b4fb18c9a5ab3ed4105618e96ae12d3d3b0b03cc", + "authorship": "Agent-prepared proposal for human review; no upstream commit or DCO sign-off supplied." +} diff --git a/docs/upstream/workspace-open/verification.json b/docs/upstream/workspace-open/verification.json new file mode 100644 index 0000000..2b36bf8 --- /dev/null +++ b/docs/upstream/workspace-open/verification.json @@ -0,0 +1,83 @@ +{ + "verifiedOn": "2026-10-17", + "upstreamCommit": "b5d994092c27ebe1f6a402c61351081b0dd52c56", + "patchSha256": "09c211cbd4e7ce08a327c541b4fb18c9a5ab3ed4105618e96ae12d3d3b0b03cc", + "reviewedFilesUnchangedAfterFinalGate": true, + "focusedRealFilesystemTabStoreTests": 15, + "sdkBuild": "PASS", + "electronTypecheck": "PASS", + "changedModuleLint": "PASS for four modules; two baseline TabEditor missing-rule errors retained", + "finalSequentialGate": { + "command": "pnpm typecheck && pnpm test:prepush", + "exit": 0, + "loopbackPermitted": true, + "typecheck": { + "passedWorkspaces": 37, + "failedWorkspaces": 0, + "elapsedSeconds": 136.2 + }, + "prepush": { + "runId": "289e932a-99e7-4bd0-8533-90d4b29e0075", + "startedAt": "2026-10-17T09:23:16.848Z", + "finishedAt": "2026-10-17T09:31:36.191Z", + "counts": { + "passed": 17806, + "failed": 0, + "skipped": 30 + }, + "files": 2096, + "elapsedSeconds": 499.343 + }, + "warningsRetained": true, + "stdout": "final-sequential-gate.log" + }, + "initialFullGate": { + "exit": 1, + "counts": { + "passed": 17756, + "failed": 50, + "skipped": 30 + }, + "files": 2096, + "elapsedSeconds": 736.873, + "errors": 23, + "state": "FAIL_RETAINED" + }, + "boundedRecovery": { + "exit": 0, + "files": 10, + "passed": 135, + "failed": 0, + "skipped": 0, + "sandbox": "loopback permitted by escalated execution", + "sourceEdits": false, + "sameFailingFilesAsFullRun": true + }, + "runtimeBuild": "PASS with preserved upstream warnings", + "nativeChooser": "NOT VERIFIED", + "upstreamAccepted": "NO", + "releasedHostSDKCapability": "NOT AVAILABLE in inspected app0.81.7/SDK0.7.1", + "terraphimThreeHostQualification": "NOT COMPLETE", + "signedOffBy": "NOT SUPPLIED", + "stdoutProvenance": { + "rawSha256": "d1303426b877d5fec130202e90518903cecc41969c9256d2b7ba99e010a60e19", + "publicSha256": "058ba3bcc462f932e8dd52905fa6e43ab91fbea1a53e3b9ae590a42a47ad0946", + "normalization": "Only disposable absolute checkout path replaced by $CHECKOUT; raw log remains in local execution evidence." + }, + "cleanBasePatchApplication": { + "status": "PASS", + "files": 9, + "resultHashesMatchReviewedSource": true, + "newFiles": 2 + }, + "testedEnvironment": { + "node": "24.22.0", + "pnpm": "12.9.1" + }, + "prerequisites": [ + "pnpm install --frozen-lockfile (prepare hook runs build:tracker-packages)", + "pnpm --filter @nimbalyst/extension-sdk run build", + "pnpm --filter @nimbalyst/runtime run build", + "pnpm --dir packages/extensions/nimbalyst-memory/engine run build" + ] +} diff --git a/docs/upstream/workspace-open/workspace-open.patch b/docs/upstream/workspace-open/workspace-open.patch new file mode 100644 index 0000000..fc02948 --- /dev/null +++ b/docs/upstream/workspace-open/workspace-open.patch @@ -0,0 +1,525 @@ +diff --git a/docs/EXTENSION_ARCHITECTURE.md b/docs/EXTENSION_ARCHITECTURE.md +index 293b9a228..16ce5ca79 100644 +--- a/docs/EXTENSION_ARCHITECTURE.md ++++ b/docs/EXTENSION_ARCHITECTURE.md +@@ -40,6 +40,24 @@ interface EditorHost { + + This contract ensures that extensions integrate seamlessly with tabs, dirty indicators, file watching, and AI edit streaming regardless of the underlying editor technology. + ++### Native workspace Open ++ ++Desktop workspace tab hosts may expose `host.openWorkspaceFile()`. Check that ++method exists before offering the action: embedded, collaborative, virtual and ++older hosts do not provide it. It shows the existing native chooser and returns ++`{status: 'opened', filePath}`, `{status: 'cancelled'}`, or ++`{status: 'refused', reason}`. Open adds or activates a separate tab and retains ++all existing tab contents, including dirty buffers. Selecting an already-open ++file activates that tab without reloading it from disk. ++ ++This capability accepts existing workspace text files within the project reader's ++4 MiB limit. The main process applies its existing sender-workspace and symlink ++containment checks; known binary formats, missing files and directories are ++refused. The originating tab must still be active in the same workspace after ++the chooser and file read. `opened` confirms tab state, not a disk save or the ++completion of the destination editor's initialization. Extensions must not call ++Electron IPC or browser file-import APIs to replace this capability. ++ + ### Reporting the current selection to the chat + + An editor can tell the AI chat what the user currently has selected. The chat renders each selection as a removable chip above the input, and includes the non-dismissed items' descriptions in the next prompt. +diff --git a/docs/FEATURE_INVENTORY.md b/docs/FEATURE_INVENTORY.md +index 328c9c22f..e2e4c595c 100644 +--- a/docs/FEATURE_INVENTORY.md ++++ b/docs/FEATURE_INVENTORY.md +@@ -413,6 +413,7 @@ Companion app; pairs with a desktop over encrypted sync. Voice mode is not inclu + + - Manifest-based extension registration + - Custom editor contribution ++- Optional custom-editor workspace Open: native chooser adds or activates a workspace text-file tab while retaining existing dirty buffers; desktop workspace hosts only, with project filesystem containment and size limits. + - AI tool (MCP) contribution + - Document header contribution + - File icon contribution +diff --git a/packages/collab-bundle/types/internal/extension-sdk/src/types/editor.d.ts b/packages/collab-bundle/types/internal/extension-sdk/src/types/editor.d.ts +index cd35d311d..9a408f949 100644 +--- a/packages/collab-bundle/types/internal/extension-sdk/src/types/editor.d.ts ++++ b/packages/collab-bundle/types/internal/extension-sdk/src/types/editor.d.ts +@@ -200,6 +200,16 @@ export interface RevisionSnapshotAdapter { + */ + readonly previewKind?: 'text' | 'metadata-only'; + } ++/** Outcome of the host's native workspace file chooser. */ ++export type EditorWorkspaceFileOpenResult = { ++ status: 'opened'; ++ filePath: string; ++} | { ++ status: 'cancelled'; ++} | { ++ status: 'refused'; ++ reason: string; ++}; + /** + * A thing an editor might want to do that not every host can provide. + * +@@ -566,6 +576,15 @@ export interface EditorHost { + * boundary; custom editors must not navigate the renderer directly. + */ + openExternal?(url: string): Promise; ++ /** ++ * Show the native chooser and add/activate an existing workspace text file in ++ * another tab. Retains existing tabs and their unsaved buffers; cancelling ++ * changes nothing. The host refuses files outside its authorized workspace, ++ * known binary formats and files exceeding the project reader's 4 MiB limit. ++ * `opened` confirms a tab, not a completed disk save or editor initialization. ++ * Optional: absent in embedded, virtual, collaborative and older hosts. ++ */ ++ openWorkspaceFile?(): Promise; + /** + * A URL the renderer can load this document from directly, for editors whose + * element wants to fetch the bytes itself rather than be handed them. +diff --git a/packages/electron/src/renderer/components/TabContent/TabContent.tsx b/packages/electron/src/renderer/components/TabContent/TabContent.tsx +index fdc6732d3..08f5615e7 100644 +--- a/packages/electron/src/renderer/components/TabContent/TabContent.tsx ++++ b/packages/electron/src/renderer/components/TabContent/TabContent.tsx +@@ -44,6 +44,7 @@ import { getPersistedCollabDocMetadata } from '../../utils/collabOpenDocsPersist + import { store, editorDirtyAtom, editorHasUnacceptedChangesAtom, makeEditorKey } from '@nimbalyst/runtime/store'; + import { clearMockupAnnotationsForFile, getMockupFilePath } from '../UnifiedAI/MockupAnnotationIndicator'; + import { resolveDesktopCollabScope } from '../../store/atoms/collabDocuments'; ++import { createWorkspaceFileOpener } from '../../services/WorkspaceFileOpen'; + + interface TabContentProps { + textReplacements?: TextReplacement[]; +@@ -547,6 +548,18 @@ const TabContentComponent: React.FC = ({ + return; + } + ++ const ownerWorkspace = propsRef.current.workspaceId; ++ const openWorkspaceFile = ownerWorkspace && !isNonFilesystemTab(tab.filePath) ++ ? createWorkspaceFileOpener(ownerWorkspace, { ++ isCurrent: () => propsRef.current.workspaceId === ownerWorkspace && ++ tabInstancesRef.current.get(tab.id)?.root === root && ++ tabsActions.getSnapshot().activeTabId === tab.id && ++ tabsActions.getTabState(tab.id)?.filePath === tab.filePath, ++ readFiles: paths => window.electronAPI.invoke('project-fs:read', paths), ++ tabs: tabsActions, ++ }) ++ : undefined; ++ + root.render( + + = ({ + onRenameDocument={propsRef.current.onRenameDocument} + onSwitchToAgentMode={propsRef.current.onSwitchToAgentMode} + onOpenSessionInChat={propsRef.current.onOpenSessionInChat} ++ onOpenWorkspaceFile={openWorkspaceFile} + workspaceId={propsRef.current.workspaceId} + // A file tab in the Wiki is a Local wiki page, never under review. + acceptAgentEdits={propsRef.current.trackerPageHeader} +diff --git a/packages/electron/src/renderer/components/TabEditor/TabEditor.tsx b/packages/electron/src/renderer/components/TabEditor/TabEditor.tsx +index 1ea3337d2..9ea4a3251 100644 +--- a/packages/electron/src/renderer/components/TabEditor/TabEditor.tsx ++++ b/packages/electron/src/renderer/components/TabEditor/TabEditor.tsx +@@ -151,6 +151,7 @@ interface TabEditorProps { + onRenameDocument?: () => void; + onSwitchToAgentMode?: (planDocumentPath?: string, sessionId?: string) => void; + onOpenSessionInChat?: (sessionId: string) => void; ++ onOpenWorkspaceFile?: EditorHost['openWorkspaceFile']; + + // Document metadata + workspaceId?: string; +@@ -175,6 +176,7 @@ export const TabEditor: React.FC = ({ + onRenameDocument, + onSwitchToAgentMode, + onOpenSessionInChat, ++ onOpenWorkspaceFile, + workspaceId, + acceptAgentEdits = false, + }) => { +@@ -2912,6 +2914,7 @@ export const TabEditor: React.FC = ({ + }, + + ...(workspaceId && !filePath.startsWith('virtual://') ? { ++ openWorkspaceFile: onOpenWorkspaceFile, + fs: createProjectFileSystemHost({ + onAfterWrite: refreshCurrentFileAfterProjectWrite, + }) satisfies EditorHostFileSystem, +@@ -3150,7 +3153,7 @@ export const TabEditor: React.FC = ({ + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps +- }, [filePath, fileName, workspaceId, extensionStorage, customEditorSupportsDiffMode]); // Recreate when file, workspace, storage, or diff support changes (theme accessed via themeRef) ++ }, [filePath, fileName, workspaceId, extensionStorage, customEditorSupportsDiffMode, onOpenWorkspaceFile]); // Recreate when file, workspace, storage, or diff support changes (theme accessed via themeRef) + + // Clean up editor context when tab unmounts + useEffect(() => { +diff --git a/packages/electron/src/renderer/components/TabEditor/createEditorHost.ts b/packages/electron/src/renderer/components/TabEditor/createEditorHost.ts +index c8b72b0a7..3dda6caf4 100644 +--- a/packages/electron/src/renderer/components/TabEditor/createEditorHost.ts ++++ b/packages/electron/src/renderer/components/TabEditor/createEditorHost.ts +@@ -78,6 +78,9 @@ export interface EditorHostOptions { + /** Open an already host-reviewed URL outside the renderer. */ + openExternal?: (url: string) => Promise; + ++ /** Native chooser that retains the owning tab and its unsaved buffer. */ ++ openWorkspaceFile?: EditorHost['openWorkspaceFile']; ++ + /** Optional: Subscribe to diff requests */ + subscribeToDiffRequests?: ( + callback: (config: DiffConfig) => void +@@ -200,6 +203,7 @@ export function createEditorHost(options: EditorHostOptions): EditorHost { + }, + + fs: options.fs, ++ openWorkspaceFile: options.openWorkspaceFile, + + openExternal: options.openExternal + ? async (url: string): Promise => { +diff --git a/packages/electron/src/renderer/services/WorkspaceFileOpen.ts b/packages/electron/src/renderer/services/WorkspaceFileOpen.ts +new file mode 100644 +index 000000000..2e63f70b6 +--- /dev/null ++++ b/packages/electron/src/renderer/services/WorkspaceFileOpen.ts +@@ -0,0 +1,83 @@ ++/** Native workspace Open for custom editor hosts; extensions never invoke IPC. */ ++import type { EditorWorkspaceFileOpenResult, ProjectFileSnapshot } from '@nimbalyst/extension-sdk'; ++import type { useTabsActions } from '../contexts/TabsContext'; ++import { isBinaryFile } from '../utils/fileTypeDetector'; ++ ++type WorkspaceOpenTabs = Pick, 'addTab' | 'getSnapshot'>; ++ ++export interface WorkspaceFileOpenContext { ++ /** Must become false when the owning tab, editor instance or workspace changes. */ ++ isCurrent(): boolean; ++ /** Main resolves the sender's workspace and validates containment; never a renderer path check. */ ++ readFiles(paths: string[]): Promise; ++ tabs: WorkspaceOpenTabs; ++} ++ ++interface NativeFileSelection { ++ canceled: boolean; ++ filePaths: string[]; ++} ++ ++const unavailable = (): EditorWorkspaceFileOpenResult => ({ ++ status: 'refused', reason: 'The workspace editor is no longer available.', ++}); ++ ++/** ++ * Complete a native selection using the existing authorized project reader and ++ * tab store. An opened result means the tab was actually added/activated, not ++ * merely that an IPC notification was sent. Existing tab content is retained. ++ */ ++export async function openSelectedWorkspaceFile( ++ selection: NativeFileSelection, ++ context: WorkspaceFileOpenContext, ++): Promise { ++ if (!context.isCurrent()) return unavailable(); ++ if (selection?.canceled === true) return { status: 'cancelled' }; ++ if (selection?.canceled !== false || !Array.isArray(selection.filePaths) || ++ selection.filePaths.length !== 1 || typeof selection.filePaths[0] !== 'string' || ++ !selection.filePaths[0] || isBinaryFile(selection.filePaths[0])) { ++ return { status: 'refused', reason: 'Select one supported workspace text file.' }; ++ } ++ try { ++ const snapshots = await context.readFiles(selection.filePaths); ++ if (!context.isCurrent()) return unavailable(); ++ const snapshot = snapshots[0]; ++ if (snapshots.length !== 1 || !snapshot?.exists || typeof snapshot.content !== 'string' || ++ snapshot.content.includes('\0')) { ++ return { status: 'refused', reason: 'The selected file is unavailable or is not a text file.' }; ++ } ++ // addTab activates an existing tab without replacing its content/dirty state. ++ const tabId = context.tabs.addTab(snapshot.path, snapshot.content); ++ const state = context.tabs.getSnapshot(); ++ if (!tabId || state.activeTabId !== tabId || state.tabs.get(tabId)?.filePath !== snapshot.path) { ++ return { status: 'refused', reason: 'The selected file could not be opened in a tab.' }; ++ } ++ return { status: 'opened', filePath: snapshot.path }; ++ } catch { ++ return { status: 'refused', reason: 'The selected file could not be read inside this workspace.' }; ++ } ++} ++ ++/** Bind the existing native dialog to a single live editor instance. */ ++export function createWorkspaceFileOpener( ++ workspacePath: string, ++ context: WorkspaceFileOpenContext, ++): () => Promise { ++ let pending = false; ++ return async () => { ++ if (!context.isCurrent() || !window.electronAPI?.openFileDialog) return unavailable(); ++ if (pending) return { status: 'refused', reason: 'A file chooser is already open for this editor.' }; ++ pending = true; ++ try { ++ const selection = await window.electronAPI.openFileDialog({ ++ title: 'Open workspace file', ++ defaultPath: workspacePath, ++ }); ++ return await openSelectedWorkspaceFile(selection, context); ++ } catch { ++ return { status: 'refused', reason: 'The native file chooser could not be opened.' }; ++ } finally { ++ pending = false; ++ } ++ }; ++} +diff --git a/packages/electron/src/renderer/services/__tests__/WorkspaceFileOpen.test.tsx b/packages/electron/src/renderer/services/__tests__/WorkspaceFileOpen.test.tsx +new file mode 100644 +index 000000000..957bed47b +--- /dev/null ++++ b/packages/electron/src/renderer/services/__tests__/WorkspaceFileOpen.test.tsx +@@ -0,0 +1,220 @@ ++// @vitest-environment jsdom ++import { mkdtemp, realpath, writeFile, readFile, symlink, mkdir, rm, appendFile } from 'node:fs/promises'; ++import { tmpdir } from 'node:os'; ++import path from 'node:path'; ++import React from 'react'; ++import { act, cleanup, renderHook } from '@testing-library/react'; ++import { afterEach, beforeEach, describe, expect, it } from 'vitest'; ++import { TabsProvider, useTabsActions } from '../../contexts/TabsContext'; ++import { ProjectFileService } from '../../../main/services/ProjectFileService'; ++import { createWorkspaceFileOpener, openSelectedWorkspaceFile, type WorkspaceFileOpenContext } from '../WorkspaceFileOpen'; ++ ++// Exercise the real tab store and filesystem reader. The native chooser's ++// returned selection is input, not evidence that an OS dialog was displayed. ++// No Electron/IPC or module mocks are installed by this focused test. ++let root: string; ++let outside: string; ++let source: string; ++let target: string; ++let files: ProjectFileService; ++ ++function wrapper({ children }: { children: React.ReactNode }) { ++ return {children}; ++} ++ ++beforeEach(async () => { ++ delete (window as unknown as { electronAPI?: unknown }).electronAPI; ++ root = await realpath(await mkdtemp(path.join(tmpdir(), 'workspace-open-'))); ++ outside = await realpath(await mkdtemp(path.join(tmpdir(), 'workspace-outside-'))); ++ source = path.join(root, 'draft.terraphim.md'); ++ target = path.join(root, 'decision.terraphim.md'); ++ await writeFile(source, 'saved original'); ++ await writeFile(target, 'Risk is recorded here.'); ++ files = new ProjectFileService({ ++ async createSnapshot(filePath, content, type, description, metadata) { ++ await appendFile(path.join(root, 'history.jsonl'), JSON.stringify({ filePath, content, type, description, metadata }) + '\n'); ++ }, ++ }, () => false); ++}); ++ ++afterEach(async () => { ++ cleanup(); ++ await Promise.all([rm(root, { recursive: true, force: true }), rm(outside, { recursive: true, force: true })]); ++}); ++ ++function origin() { ++ const { result } = renderHook(() => useTabsActions(), { wrapper }); ++ let sourceId!: string; ++ act(() => { ++ sourceId = result.current.addTab(source, 'unsaved original')!; ++ result.current.updateTab(sourceId, { isDirty: true }); ++ }); ++ const context: WorkspaceFileOpenContext = { ++ isCurrent: () => result.current.getSnapshot().activeTabId === sourceId && ++ result.current.getTabState(sourceId)?.filePath === source, ++ readFiles: paths => files.read(root, paths), ++ tabs: result.current, ++ }; ++ return { context, tabs: result.current, sourceId }; ++} ++ ++async function choose(context: WorkspaceFileOpenContext, filePaths: string[], canceled = false) { ++ let outcome; ++ await act(async () => { outcome = await openSelectedWorkspaceFile({ canceled, filePaths }, context); }); ++ return outcome; ++} ++ ++describe('workspace Open selection', () => { ++ it('adds a real saved workspace file while retaining the dirty originating tab', async () => { ++ const { context, tabs, sourceId } = origin(); ++ expect(await choose(context, [target])).toEqual({ status: 'opened', filePath: target }); ++ const state = tabs.getSnapshot(); ++ expect(state.tabs.size).toBe(2); ++ expect(tabs.getTabState(sourceId)).toMatchObject({ content: 'unsaved original', isDirty: true }); ++ expect(state.tabs.get(state.activeTabId!)?.content).toBe(await readFile(target, 'utf8')); ++ expect(await readFile(source, 'utf8')).toBe('saved original'); ++ }); ++ ++ it('activates an existing dirty target without replacing its unsaved buffer', async () => { ++ const { context, tabs, sourceId } = origin(); ++ let targetId!: string; ++ act(() => { ++ targetId = tabs.addTab(target, 'unsaved target')!; ++ tabs.updateTab(targetId, { isDirty: true }); ++ tabs.switchTab(sourceId); ++ }); ++ expect(await choose(context, [target])).toEqual({ status: 'opened', filePath: target }); ++ expect(tabs.getSnapshot().activeTabId).toBe(targetId); ++ expect(tabs.getTabState(targetId)).toMatchObject({ content: 'unsaved target', isDirty: true }); ++ expect(tabs.getSnapshot().tabs.size).toBe(2); ++ }); ++ ++ it('cancels without changing tabs, dirty contents or disk', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ expect(await choose(context, [], true)).toEqual({ status: 'cancelled' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ expect(await readFile(source, 'utf8')).toBe('saved original'); ++ }); ++ ++ it('refuses unsupported binary selections and malformed multi-file selections', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ const binary = path.join(root, 'archive.zip'); ++ await writeFile(binary, Buffer.from([0, 1, 2])); ++ for (const selected of [[binary], [], [source, target]]) { ++ expect(await choose(context, selected)).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ } ++ }); ++ ++ it('refuses outside-workspace paths and actual symlink escapes through the existing reader', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ const external = path.join(outside, 'external.md'); ++ const alias = path.join(root, 'alias.md'); ++ await writeFile(external, 'outside'); ++ await symlink(external, alias); ++ for (const selected of [external, alias, path.relative(root, external)]) { ++ expect(await choose(context, [selected])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ } ++ }); ++ ++ it('refuses a missing file and a directory without reporting opened', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ const directory = path.join(root, 'folder.md'); ++ await mkdir(directory); ++ for (const selected of [path.join(root, 'missing.md'), directory]) { ++ expect(await choose(context, [selected])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ } ++ }); ++ ++ it('refuses a closed origin and does not resurrect it', async () => { ++ const { context, tabs, sourceId } = origin(); ++ act(() => tabs.removeTab(sourceId)); ++ expect(await choose(context, [target])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot().tabs.size).toBe(0); ++ }); ++ ++ it('rechecks origin lifetime after the actual asynchronous filesystem read', async () => { ++ const { context, tabs, sourceId } = origin(); ++ let outcome; ++ await act(async () => { ++ const pending = openSelectedWorkspaceFile({ canceled: false, filePaths: [target] }, context); ++ tabs.removeTab(sourceId); ++ outcome = await pending; ++ }); ++ expect(outcome).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot().tabs.size).toBe(0); ++ }); ++ ++ it('refuses the native wrapper when its host does not provide a chooser', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ expect(await createWorkspaceFileOpener(root, context)()).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ }); ++ ++ it('refuses a hidden origin without switching away from the current tab', async () => { ++ const { context, tabs } = origin(); ++ act(() => tabs.addTab(target, 'current target')); ++ const before = tabs.getSnapshot(); ++ expect(await choose(context, [source])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ }); ++ ++ it('refuses NUL-containing data with a text suffix instead of opening a binary buffer', async () => { ++ const { context, tabs } = origin(); ++ await writeFile(target, Buffer.from([82, 0, 105, 115, 107])); ++ const before = tabs.getSnapshot(); ++ expect(await choose(context, [target])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ }); ++ ++ it('refuses a workspace lifetime change while the filesystem read is pending', async () => { ++ const { context, tabs } = origin(); ++ const before = tabs.getSnapshot(); ++ let currentWorkspace = root; ++ const originStillOpen = context.isCurrent; ++ context.isCurrent = () => currentWorkspace === root && originStillOpen(); ++ let outcome; ++ await act(async () => { ++ const pending = openSelectedWorkspaceFile({ canceled: false, filePaths: [target] }, context); ++ currentWorkspace = outside; ++ outcome = await pending; ++ }); ++ expect(outcome).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot()).toBe(before); ++ }); ++ ++ it('refuses an oversized actual file through the existing project read limit', async () => { ++ const { context, tabs } = origin(); ++ await writeFile(target, 'x'.repeat(4 * 1024 * 1024 + 1)); ++ expect(await choose(context, [target])).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot().tabs.size).toBe(1); ++ }); ++ ++ it('retains the same owner across same-path dirty tab updates', async () => { ++ const { context, tabs, sourceId } = origin(); ++ act(() => tabs.updateTab(sourceId, { content: 'new unsaved text', isDirty: true })); ++ expect(await choose(context, [target])).toEqual({ status: 'opened', filePath: target }); ++ expect(tabs.getTabState(sourceId)).toMatchObject({ content: 'new unsaved text', isDirty: true }); ++ expect(await readFile(source, 'utf8')).toBe('saved original'); ++ }); ++ ++ it('invalidates the old owner when its real tab is renamed during a pending read', async () => { ++ const { context, tabs, sourceId } = origin(); ++ let outcome; ++ await act(async () => { ++ const pending = openSelectedWorkspaceFile({ canceled: false, filePaths: [target] }, context); ++ tabs.updateTab(sourceId, { filePath: path.join(root, 'renamed.terraphim.md') }); ++ outcome = await pending; ++ }); ++ expect(outcome).toMatchObject({ status: 'refused' }); ++ expect(tabs.getSnapshot().tabs.size).toBe(1); ++ expect(tabs.getTabState(sourceId)).toMatchObject({ content: 'unsaved original', isDirty: true }); ++ }); ++}); +diff --git a/packages/extension-sdk/src/types/editor.ts b/packages/extension-sdk/src/types/editor.ts +index 372373dda..227dca43c 100644 +--- a/packages/extension-sdk/src/types/editor.ts ++++ b/packages/extension-sdk/src/types/editor.ts +@@ -215,6 +215,12 @@ export interface RevisionSnapshotAdapter { + readonly previewKind?: 'text' | 'metadata-only'; + } + ++/** Outcome of the host's native workspace file chooser. */ ++export type EditorWorkspaceFileOpenResult = ++ | { status: 'opened'; filePath: string } ++ | { status: 'cancelled' } ++ | { status: 'refused'; reason: string }; ++ + // ============================================================================ + // Host capability negotiation + // ============================================================================ +@@ -652,6 +658,16 @@ export interface EditorHost { + */ + openExternal?(url: string): Promise; + ++ /** ++ * Show the native chooser and add/activate an existing workspace text file in ++ * another tab. Retains existing tabs and their unsaved buffers; cancelling ++ * changes nothing. The host refuses files outside its authorized workspace, ++ * known binary formats and files exceeding the project reader's 4 MiB limit. ++ * `opened` confirms a tab, not a completed disk save or editor initialization. ++ * Optional: absent in embedded, virtual, collaborative and older hosts. ++ */ ++ openWorkspaceFile?(): Promise; ++ + /** + * A URL the renderer can load this document from directly, for editors whose + * element wants to fetch the bytes itself rather than be handed them.