From 5164f7467a276eec8a68a71221b2cf8e5530a116 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 01:18:55 +0000 Subject: [PATCH 1/2] Build the UI shell: slot registry, layout persistence, OpenTUI/React components Implements Task 1.14 (Req 6.1-6.5, 7.3; design.md sections 8, 9, 16): - Add react/@opentui/core/@opentui/react to packages/core only, with jsx: react-jsx / jsxImportSource: @opentui/react on the root tsconfig so both bun test and a whole-repo tsc --noEmit stay clean. - ui/slotRegistry.ts: an ordered per-slot view registry backing tecode.ui.registerView, with last-wins duplicate handling, lazy entries seeded from manifest-declared pendingViews, a never-throwing activateExtension hook for on-demand activation, the activityBar.item <-> sidebar.view pairing helper, and the statusBar.item side/priority sorted-enumeration helper. - ui/layoutState.ts: debounced, serialized persistence of sidebar/panel visibility+size and the active view to state.json, with injectable fs/timer seams, non-blocking load, and parse-failure-keeps-last-good semantics; host/paths.ts grows getUserLayoutStatePath(). - ui/theme.tsx, ui/focus.tsx, ui/components.tsx: ThemeProvider/useTheme over the existing base palette, ContextFocusTracker/useFocusTracking bridging OpenTUI's focus events into the context-key store, and minimal List/Tree/Input/Tabs plus a RegisteredView bridge from @tecode/api's React-free ComponentType to real React elements. - ui/shell.tsx: Shell/ActivityBar/Sidebar/EditorArea/Panel/StatusBar, wired to the slot registry and layout state, with workbench.view. commands kept in sync with known sidebar pairs. - api/create.ts now wires tecode.ui.registerView/useTheme/List/Tree/ Input/Tabs to the real slot registry and components instead of the Task 1.13 stub; api/stubs.ts drops the now-superseded UI stub. Headless UI tests use @opentui/core's real testing renderer (createTestRenderer, wrapped by @opentui/react's testRender) for actual cell-grid snapshots via captureCharFrame(), not a fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- bun.lock | 220 +++++++++++ packages/core/package.json | 8 +- packages/core/src/api/create.ts | 42 ++- packages/core/src/api/index.ts | 3 - packages/core/src/api/stubs.test.ts | 33 +- packages/core/src/api/stubs.ts | 58 +-- packages/core/src/host/index.ts | 1 + packages/core/src/host/paths.test.ts | 5 + packages/core/src/host/paths.ts | 8 + packages/core/src/index.ts | 53 ++- packages/core/src/ui/components.test.tsx | 91 +++++ packages/core/src/ui/components.tsx | 279 ++++++++++++++ packages/core/src/ui/focus.test.tsx | 81 ++++ packages/core/src/ui/focus.tsx | 95 +++++ packages/core/src/ui/index.ts | 71 +++- packages/core/src/ui/layoutState.test.ts | 279 ++++++++++++++ packages/core/src/ui/layoutState.ts | 316 ++++++++++++++++ packages/core/src/ui/shell.test.tsx | 309 +++++++++++++++ packages/core/src/ui/shell.tsx | 435 ++++++++++++++++++++++ packages/core/src/ui/slotRegistry.test.ts | 309 +++++++++++++++ packages/core/src/ui/slotRegistry.ts | 425 +++++++++++++++++++++ packages/core/src/ui/theme.tsx | 82 ++++ tsconfig.json | 2 + 23 files changed, 3106 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/ui/components.test.tsx create mode 100644 packages/core/src/ui/components.tsx create mode 100644 packages/core/src/ui/focus.test.tsx create mode 100644 packages/core/src/ui/focus.tsx create mode 100644 packages/core/src/ui/layoutState.test.ts create mode 100644 packages/core/src/ui/layoutState.ts create mode 100644 packages/core/src/ui/shell.test.tsx create mode 100644 packages/core/src/ui/shell.tsx create mode 100644 packages/core/src/ui/slotRegistry.test.ts create mode 100644 packages/core/src/ui/slotRegistry.ts create mode 100644 packages/core/src/ui/theme.tsx diff --git a/bun.lock b/bun.lock index 3a88ef3..087d4ac 100644 --- a/bun.lock +++ b/bun.lock @@ -38,11 +38,19 @@ "name": "@tecode/core", "version": "0.1.0", "dependencies": { + "@opentui/core": "^0.1.30", + "@opentui/react": "^0.1.30", "@tecode/api": "workspace:*", + "react": "^19.0.0", + }, + "devDependencies": { + "@types/react": "^19.0.0", }, }, }, "packages": { + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -71,6 +79,78 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@opentui/core": ["@opentui/core@0.1.107", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.107", "@opentui/core-darwin-x64": "0.1.107", "@opentui/core-linux-arm64": "0.1.107", "@opentui/core-linux-x64": "0.1.107", "@opentui/core-win32-arm64": "0.1.107", "@opentui/core-win32-x64": "0.1.107", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-gadu9EtNR+sOGyHN0buZryllavkWHRkCcX4yW/1ldp/l7HGS52hvkjYmo+74cuzUcfds/5Rbw2cgiy0Z7RxXmQ=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.107", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yqt2/9Ntw0IdtPA/qmHvXCE16y4Jq5/btCmuzN9/opzqZ5rYGYYVtiBii3LezGcTZYuJQZthjvh8MLPXXwA2EQ=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.107", "", { "os": "darwin", "cpu": "x64" }, "sha512-p6yeHsIWRLy/J30nZTyUuwgFYEpk8NS0H0Cmh9P8a1+eHA406MMMP4FAC0YpqlF4SHb7R7LNkUSsfCx9yMtS8w=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.107", "", { "os": "linux", "cpu": "arm64" }, "sha512-w6MpRTd06KUH4KdgH4x7rVB2I67KE62w3W3jQVBDEMeJejdJVOSwwUdgaTY9ffoHglcZc3WA2PFH1PCpgzna4A=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.107", "", { "os": "linux", "cpu": "x64" }, "sha512-oxKbIpWZRgY+8KQZ9dXq8lzDEhMVpBMCiZGDiHtK8/DP1MvK5kFE/vtwgUK9YkmT4OSgZsFeojjvyePXV+PcfQ=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.107", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7hbLgoTkb5eAsP5GJdTRyDl48WI/hMEtj+BGlIITzSaOBSN7ZPCeblcfUz+uXrdF6g3dF1a9uyEQSJlzeGaKA=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.107", "", { "os": "win32", "cpu": "x64" }, "sha512-e/uFLPyKK/hFDvDZtTxp6L3Zx0FWuZv5Gf2qIKf/7FAAadD0hala+K41OJAmYWxu1X3cT5XozKCT8gN/S1N08A=="], + + "@opentui/react": ["@opentui/react@0.1.107", "", { "dependencies": { "@opentui/core": "0.1.107", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-BiREndm6Cro9jZvBOJeKGBJwk9KLo9t0UQSUGXxmUiqVKsjItbvawDX3POhxEfjvjKkmBRQ9AQ9wsMiIQYwmhw=="], + "@tecode/api": ["@tecode/api@workspace:packages/api"], "@tecode/builtin": ["@tecode/builtin@workspace:packages/builtin"], @@ -79,6 +159,8 @@ "@tecode/core": ["@tecode/core@workspace:packages/core"], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -87,6 +169,8 @@ "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.67.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/type-utils": "8.67.0", "@typescript-eslint/utils": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.67.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w=="], @@ -107,22 +191,50 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], + "@webgpu/types": ["@webgpu/types@0.1.72", "", {}, "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -135,10 +247,16 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], @@ -157,6 +275,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -167,20 +291,30 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -191,6 +325,10 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -207,44 +345,104 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], + + "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -257,12 +455,28 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], @@ -271,6 +485,12 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/packages/core/package.json b/packages/core/package.json index 9d42058..7c4106a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,12 @@ "main": "src/index.ts", "types": "src/index.ts", "dependencies": { - "@tecode/api": "workspace:*" + "@tecode/api": "workspace:*", + "@opentui/core": "^0.1.30", + "@opentui/react": "^0.1.30", + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0" } } diff --git a/packages/core/src/api/create.ts b/packages/core/src/api/create.ts index 7275dea..e635e12 100644 --- a/packages/core/src/api/create.ts +++ b/packages/core/src/api/create.ts @@ -44,11 +44,12 @@ import type { DocumentManager } from "../buffer/documentManager"; import type { ConfigService } from "../config/service"; import type { ContextService } from "../keymap/context"; import type { StatusSink } from "../host/errors"; +import { Input, List, Tabs, Tree } from "../ui/components"; +import { createSlotRegistry, type SlotRegistry } from "../ui/slotRegistry"; import { createEditorStub, createLanguagesStub, createThemesStub, - createUiStub, createWindowStub, } from "./stubs"; @@ -77,6 +78,20 @@ export interface CreateTecodeApiDeps { * 10.1, design.md §12's "no-active-editor no-ops with a status-bar * notice"). */ sink: StatusSink; + /** + * Backs `tecode.ui.registerView` (Req 6.3, 10.1; design.md §8.2; Task + * 1.14) — the live slot registry the Shell's regions render from. + * Optional: a caller that has not wired discovery/activation yet (every + * existing test in this suite, and any future caller that only needs the + * namespace's shape) gets a registry built with no pending manifest + * views and no activation hook — `registerView`'s register/dispose + * symmetry still holds fully; only lazy-view activation + * (`ui/slotRegistry.ts`'s `requestActivation`) has nothing to do. `cli`'s + * real startup wiring (Task 1.15) passes the registry built alongside + * `host/registration.ts`'s `LoadExtensionsResult.pendingViews` and + * `host/activation.ts`'s `activateExtension`. + */ + slotRegistry?: SlotRegistry; } /** @@ -144,14 +159,25 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { const editorNamespace: EditorNamespace = Object.freeze(createEditorStub({ sink: deps.sink })); - const uiStub = createUiStub({ getTheme: () => themesNamespace.current }); + // No slot registry injected (see CreateTecodeApiDeps.slotRegistry's + // TSDoc) — build one with no pending manifest views and no activation + // hook rather than falling back to a disposable-only stub; registerView + // still round-trips correctly, and callers that DO need lazy-view + // activation (the real CLI startup, Task 1.15) pass their own. + const slotRegistry = deps.slotRegistry ?? createSlotRegistry({}); const uiNamespace: UiNamespace = Object.freeze({ - registerView: uiStub.registerView, - useTheme: uiStub.useTheme, - List: uiStub.List, - Tree: uiStub.Tree, - Input: uiStub.Input, - Tabs: uiStub.Tabs, + registerView: slotRegistry.registerView, + // A plain, non-hook getter (Req 10.1) — NOT the real React hook + // `ui/theme.ts` exports under the same conceptual name. See + // `ui/theme.ts`'s TSDoc ("Two different useThemes, deliberately") for + // why `tecode.ui.useTheme()` must stay callable from plain extension + // code (the contract test's fixture extension calls it from + // `activate(ctx)`, outside any React render). + useTheme: () => themesNamespace.current, + List, + Tree, + Input, + Tabs, }); const languagesStub = createLanguagesStub(); diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 5cdad34..c17f588 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -12,12 +12,9 @@ export { createEditorStub, createLanguagesStub, createThemesStub, - createUiStub, createWindowStub, type LanguagesStub, - type RegisteredView, type ThemesStub, - type UiStub, type WindowStub, } from "./stubs"; export { registerTecodeAlias } from "./alias"; diff --git a/packages/core/src/api/stubs.test.ts b/packages/core/src/api/stubs.test.ts index a53f336..22afe8a 100644 --- a/packages/core/src/api/stubs.test.ts +++ b/packages/core/src/api/stubs.test.ts @@ -5,7 +5,6 @@ import { createEditorStub, createLanguagesStub, createThemesStub, - createUiStub, createWindowStub, } from "./stubs"; @@ -126,33 +125,11 @@ test("editor stub: a throwing sink does not make revealLine/insertSnippet/applyE expect(() => editor.applyEdits([])).not.toThrow(); }); -test("ui.registerView: register/dispose symmetry", () => { - const ui = createUiStub({ getTheme: createBaseTheme }); - const component = () => undefined; - - const sub = ui.registerView("sidebar.view", "test.view", component); - expect(ui.registeredViews()).toEqual([{ slot: "sidebar.view", id: "test.view", component }]); - - sub.dispose(); - expect(ui.registeredViews()).toEqual([]); - expect(() => sub.dispose()).not.toThrow(); -}); - -test("ui.useTheme delegates to the injected getTheme", () => { - const theme = createBaseTheme(); - const ui = createUiStub({ getTheme: () => theme }); - - expect(ui.useTheme()).toBe(theme); -}); - -test("ui stub's List/Tree/Input/Tabs are inert placeholder components", () => { - const ui = createUiStub({ getTheme: createBaseTheme }); - - expect(ui.List({})).toBeUndefined(); - expect(ui.Tree({})).toBeUndefined(); - expect(ui.Input({})).toBeUndefined(); - expect(ui.Tabs({})).toBeUndefined(); -}); +// `ui.registerView`/`useTheme`/`List`/`Tree`/`Input`/`Tabs` were stubbed +// here through Task 1.13; Task 1.14 gives them real backing instead (the +// slot registry, `ui/slotRegistry.test.ts`; the real components, +// `ui/components.test.tsx`) — see `stubs.ts`'s and `create.ts`'s TSDoc for +// the wiring. test("languages.register: register/dispose symmetry, getLanguageId always 'plaintext'", () => { const languages = createLanguagesStub(); diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts index f1ff089..5776800 100644 --- a/packages/core/src/api/stubs.ts +++ b/packages/core/src/api/stubs.ts @@ -19,11 +19,11 @@ * hardcoded base palette (design.md §12's own note that `ThemeProvider` * starts with "a hardcoded base palette for now", Task 1.14) until a real * theme loader can resolve one. - * - `ui.registerView` is likewise a real, disposable registration with no - * renderer behind it yet (the UI shell's slot registry, Task 1.14); - * `List`/`Tree`/`Input`/`Tabs` are inert placeholder components (no - * dependency on React here — `@tecode/api`'s `ComponentType` is - * deliberately framework-agnostic, design.md §12). + * - `ui` is no longer stubbed here as of Task 1.14: `tecode.ui.registerView` + * delegates to the real `ui/slotRegistry.ts` (a live, rendered slot + * registry, not just a disposable-returning placeholder), and `List`/ + * `Tree`/`Input`/`Tabs` are the real OpenTUI/React components in + * `ui/components.ts` — see `create.ts` for the wiring. * * None of this throws: every method here follows the same never-throw * discipline as the rest of core (`registry.ts`, `documentManager.ts`, @@ -32,7 +32,6 @@ */ import type { - ComponentType, Disposable, EditorNamespace, LanguageContribution, @@ -40,12 +39,10 @@ import type { Position, ResolvedTheme, RGB, - SlotId, StatusBarItem, ThemeContribution, ThemesNamespace, UiColorKey, - UiNamespace, WindowNamespace, } from "@tecode/api"; import type { StatusSink } from "../host/errors"; @@ -268,51 +265,6 @@ export function createEditorStub(deps: { sink: StatusSink }): EditorNamespace { }; } -/** An inert placeholder `ComponentType` — `@tecode/api` has no dependency - * on React (or any UI framework, design.md §12), and no renderer exists - * yet to give `List`/`Tree`/`Input`/`Tabs` real behavior. */ -const notImplementedComponent: ComponentType = () => undefined; - -/** One registered `ui.registerView` call. */ -export interface RegisteredView { - slot: SlotId; - id: string; - component: ComponentType; -} - -/** {@link createUiStub}'s return type — see {@link WindowStub}'s TSDoc for - * why a stub factory returns more than its `@tecode/api` namespace type. */ -export interface UiStub extends UiNamespace { - /** Every currently-registered view; an entry is gone once its - * `Disposable` has been disposed. */ - registeredViews(): readonly RegisteredView[]; -} - -/** - * Build the `tecode.ui` stub (Req 10.1, 6.3). `registerView` is a real, - * disposable registration (the UI shell's slot registry, Task 1.14, is the - * eventual consumer); `useTheme` reads whatever `getTheme` currently - * returns, so it stays in sync with `tecode.themes.current` without this - * module depending on `themes.ts` directly (the two are wired together in - * `create.ts`). - */ -export function createUiStub(deps: { getTheme: () => ResolvedTheme }): UiStub { - const views = createRegistrySet(); - return { - registerView(slot: SlotId, id: string, component: ComponentType) { - return views.register({ slot, id, component }); - }, - useTheme() { - return deps.getTheme(); - }, - List: notImplementedComponent, - Tree: notImplementedComponent, - Input: notImplementedComponent, - Tabs: notImplementedComponent, - registeredViews: views.entries, - }; -} - /** {@link createLanguagesStub}'s return type — see {@link WindowStub}'s * TSDoc for why a stub factory returns more than its `@tecode/api` * namespace type. */ diff --git a/packages/core/src/host/index.ts b/packages/core/src/host/index.ts index f30ead2..c80817a 100644 --- a/packages/core/src/host/index.ts +++ b/packages/core/src/host/index.ts @@ -17,6 +17,7 @@ export { getUserConfigDir, getUserExtensionsDir, getUserKeybindingsPath, + getUserLayoutStatePath, getUserSettingsPath, getWorkspaceExtensionsDir, getWorkspaceSettingsPath, diff --git a/packages/core/src/host/paths.test.ts b/packages/core/src/host/paths.test.ts index f7ffeb2..2b804af 100644 --- a/packages/core/src/host/paths.test.ts +++ b/packages/core/src/host/paths.test.ts @@ -5,6 +5,7 @@ import { getUserConfigDir, getUserExtensionsDir, getUserKeybindingsPath, + getUserLayoutStatePath, getUserSettingsPath, getWorkspaceExtensionsDir, getWorkspaceSettingsPath, @@ -82,4 +83,8 @@ describe("derived file paths", () => { join("/home/user/project", ".tecode", "extensions"), ); }); + + test("getUserLayoutStatePath appends state.json to the config dir", () => { + expect(getUserLayoutStatePath()).toBe(join(getUserConfigDir(), "state.json")); + }); }); diff --git a/packages/core/src/host/paths.ts b/packages/core/src/host/paths.ts index f6a80ab..11d0456 100644 --- a/packages/core/src/host/paths.ts +++ b/packages/core/src/host/paths.ts @@ -35,6 +35,14 @@ export function getUserKeybindingsPath(): string { return join(getUserConfigDir(), "keybindings.json"); } +/** Path to the user-level `state.json` — persisted UI layout state (sidebar + * width/visibility, panel height/visibility, active view — Req 6.4, + * design.md §8.2: "Layout state ... persists to `~/.config/tecode/state.json` + * on change (debounced) and on exit"). */ +export function getUserLayoutStatePath(): string { + return join(getUserConfigDir(), "state.json"); +} + /** Path to a workspace's `.tecode/settings.json`, overlaid on top of user * settings when the workspace declares one (Req 9.2). `workspaceRoot` is * the workspace's root directory (an absolute path). */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afc9a42..9adc153 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -99,7 +99,55 @@ export { createFileSystem, type FileSystemDeps, } from "./buffer/index"; -export { UI_PLACEHOLDER } from "./ui/index"; +export { + ActivityBar, + ContextFocusTracker, + createLayoutStateService, + createSlotRegistry, + DEFAULT_LAYOUT_STATE, + EditorArea, + Input, + List, + Panel, + RegisteredView, + Shell, + Sidebar, + StatusBar, + Tabs, + ThemeProvider, + toColorInput, + Tree, + styleToTextColors, + useFocusTracking, + useTheme, + type ActivityBarProps, + type ContextFocusTrackerProps, + type EditorAreaProps, + type FocusEmitter, + type InputProps, + type LayoutState, + type LayoutStateFs, + type LayoutStateService, + type LayoutStateServiceDeps, + type LayoutStateTimer, + type ListItem, + type ListProps, + type PanelProps, + type RegisterViewMeta, + type ShellProps, + type SidebarPair, + type SidebarProps, + type SlotRegistry, + type SlotRegistryDeps, + type SlotViewEntry, + type StatusBarPlacement, + type StatusBarProps, + type TabItem, + type TabsProps, + type ThemeProviderProps, + type TreeNode, + type TreeProps, +} from "./ui/index"; export { createConfigService, parseJsonc, @@ -116,13 +164,10 @@ export { createLanguagesStub, createTecodeApi, createThemesStub, - createUiStub, createWindowStub, registerTecodeAlias, type CreateTecodeApiDeps, type LanguagesStub, - type RegisteredView, type ThemesStub, - type UiStub, type WindowStub, } from "./api/index"; diff --git a/packages/core/src/ui/components.test.tsx b/packages/core/src/ui/components.test.tsx new file mode 100644 index 0000000..c53dcd8 --- /dev/null +++ b/packages/core/src/ui/components.test.tsx @@ -0,0 +1,91 @@ +/** + * Structural/snapshot tests for the minimal common component library + * (`tecode.ui.List`/`Tree`/`Input`/`Tabs`, Req 10.1, 6.3) — headless via + * `@opentui/react`'s `testRender` (see `shell.test.tsx`'s top-of-file TSDoc + * for the full "what OpenTUI headless-testing API we use" writeup). + */ + +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { List, RegisteredView, Tabs, Tree, type ListItem, type TabItem, type TreeNode } from "./components"; + +describe("List (tecode.ui.List)", () => { + test("renders every item's label", async () => { + const items: ListItem[] = [ + { id: "a", label: "Alpha" }, + { id: "b", label: "Beta" }, + ]; + const { renderOnce, captureCharFrame } = await testRender(, { + width: 30, + height: 20, + }); + await renderOnce(); + const frame = captureCharFrame(); + expect(frame).toContain("Alpha"); + expect(frame).toContain("Beta"); + }); +}); + +describe("Tree (tecode.ui.Tree)", () => { + test("renders top-level node labels; collapsed children stay hidden", async () => { + const nodes: TreeNode[] = [ + { id: "root", label: "src", children: [{ id: "child", label: "index.ts" }] }, + ]; + const { renderOnce, captureCharFrame } = await testRender(, { + width: 30, + height: 6, + }); + await renderOnce(); + const frame = captureCharFrame(); + expect(frame).toContain("src"); + expect(frame).not.toContain("index.ts"); + }); + + test("defaultExpanded reveals children immediately", async () => { + const nodes: TreeNode[] = [ + { id: "root", label: "src", children: [{ id: "child", label: "index.ts" }] }, + ]; + const { renderOnce, captureCharFrame } = await testRender( + , + { width: 30, height: 6 }, + ); + await renderOnce(); + const frame = captureCharFrame(); + expect(frame).toContain("src"); + expect(frame).toContain("index.ts"); + }); +}); + +describe("Tabs (tecode.ui.Tabs)", () => { + test("renders every tab's label", async () => { + const tabs: TabItem[] = [ + { id: "a", label: "main.ts" }, + { id: "b", label: "utils.ts" }, + ]; + const { renderOnce, captureCharFrame } = await testRender(, { + width: 60, + height: 3, + }); + await renderOnce(); + const frame = captureCharFrame(); + expect(frame).toContain("main.ts"); + expect(frame).toContain("utils.ts"); + }); +}); + +describe("RegisteredView (bridging @tecode/api's ComponentType)", () => { + test("invokes the registered component with the given props", async () => { + const seen: Record[] = []; + const component = (props: Record) => { + seen.push(props); + return {String(props["label"])}; + }; + const { renderOnce, captureCharFrame } = await testRender( + , + { width: 40, height: 3 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("hello from extension"); + expect(seen).toEqual([{ label: "hello from extension" }]); + }); +}); diff --git a/packages/core/src/ui/components.tsx b/packages/core/src/ui/components.tsx new file mode 100644 index 0000000..a4c2948 --- /dev/null +++ b/packages/core/src/ui/components.tsx @@ -0,0 +1,279 @@ +/** + * The common component library `tecode.ui.List`/`Tree`/`Input`/`Tabs` + * expose (Req 10.1, 6.3; design.md §8.1; Task 1.14) — minimal + * implementations over OpenTUI's own ``/ + * `` intrinsics, all colors sourced from {@link useTheme} (Req 7.3). + * Deliberately minimal (tasks.md's Task 1.14: "later tasks extend") — no + * virtualization, no keyboard-navigation customization beyond what the + * underlying OpenTUI renderable already provides. + * + * **Prop typing, and why it looks loose**: `@tecode/api`'s + * `ComponentType

> = (props: P) => unknown` is + * deliberately React-free (`namespaces.ts`'s TSDoc). `UiNamespace.List` + * etc. are typed as the bare `ComponentType` (i.e. `P` defaults to + * `Record`), and TypeScript's strict function-parameter + * contravariance means only a function whose parameter accepts *at least* + * `Record` is assignable there — a function typed to take + * a narrower, concrete props interface is not. Every component below is + * therefore declared as `(rawProps: Record) => ReactNode` + * and casts internally to its own documented props interface; callers using + * JSX (``) stay fully ergonomic regardless, since an + * object literal with known, narrower-typed properties is always assignable + * to `Record` (every property value is assignable to + * `unknown`). + */ + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { SelectOption, TabSelectOption, TabSelectRenderable } from "@opentui/core"; +import type { ComponentType } from "@tecode/api"; +import { toColorInput, useTheme } from "./theme"; + +/* ------------------------------------------------------------------ */ +/* Bridging @tecode/api's ComponentType to a real React element */ +/* ------------------------------------------------------------------ */ + +/** + * Render a registered `tecode.ui.registerView`/`List`/`Tree`/`Input`/`Tabs` + * component (a plain `(props) => unknown` function, not a JSX component + * type) as part of a real React tree (design.md §12's "bridge `@tecode/ + * api`'s React-free `ComponentType` to real React component types in + * core"). Calling `component(props)` directly inside this component's own + * render body — rather than trying to use `component` as a JSX tag, which + * its `unknown` return type is not statically compatible with — means any + * hooks `component` itself calls (e.g. `tecode.ui.useTheme()`) still run in + * a stable position during this component's render, exactly as if it had + * been inlined. + */ +export function RegisteredView(props: { + component: ComponentType; + viewProps?: Record; +}): ReactNode { + return props.component(props.viewProps ?? {}) as ReactNode; +} + +/* ------------------------------------------------------------------ */ +/* List */ +/* ------------------------------------------------------------------ */ + +/** One selectable row (the concrete shape {@link List} expects in its + * `items` prop, cast from the loosely-typed `Record` — + * see this module's TSDoc). */ +export interface ListItem { + id: string; + label: string; + description?: string; +} + +/** {@link List}'s props. */ +export interface ListProps { + items?: ListItem[]; + selectedId?: string; + onSelect?: (id: string) => void; + focused?: boolean; +} + +/** A minimal selectable list (`tecode.ui.List`, Req 10.1), over OpenTUI's + * ` only shows as many rows as its own assigned + // height, defaulting very small when unconstrained; size it to fit + // every item unless a parent layout (flexGrow, an explicit height) + // overrides this via `style`. + height={Math.max(items.length, 1)} + selectedIndex={selectedIndex >= 0 ? selectedIndex : undefined} + focused={props.focused} + showDescription={items.some((item) => item.description)} + backgroundColor={toColorInput(theme.colors["sideBar.background"])} + textColor={toColorInput(theme.colors["sideBar.foreground"])} + selectedBackgroundColor={toColorInput(theme.colors["list.activeSelectionBackground"])} + selectedTextColor={toColorInput(theme.colors["list.activeSelectionForeground"])} + onSelect={(_index, option) => { + if (option && typeof option.value === "string") props.onSelect?.(option.value); + }} + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Tree */ +/* ------------------------------------------------------------------ */ + +/** One node of a {@link Tree} (the concrete shape {@link Tree} expects — + * see this module's TSDoc). */ +export interface TreeNode { + id: string; + label: string; + children?: TreeNode[]; +} + +/** {@link Tree}'s props. */ +export interface TreeProps { + nodes?: TreeNode[]; + selectedId?: string; + onSelect?: (id: string) => void; + /** Node ids expanded by default (uncontrolled after mount — this is a + * minimal MVP component, not a fully controlled tree). */ + defaultExpanded?: string[]; +} + +/** A minimal expand/collapse tree (`tecode.ui.Tree`, Req 10.1). No native + * OpenTUI tree renderable exists yet, so this composes ``/`` + * directly with hand-rolled indentation and local expand/collapse state. */ +export function Tree(rawProps: Record): ReactNode { + const props = rawProps as TreeProps; + const theme = useTheme(); + const [expanded, setExpanded] = useState>( + () => new Set(props.defaultExpanded ?? []), + ); + + function toggle(id: string): void { + setExpanded((prev: Set) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function renderNode(node: TreeNode, depth: number): ReactNode { + const hasChildren = (node.children?.length ?? 0) > 0; + const isExpanded = expanded.has(node.id); + const isSelected = props.selectedId === node.id; + const glyph = hasChildren ? (isExpanded ? "▾ " : "▸ ") : " "; + return ( + + { + if (hasChildren) toggle(node.id); + props.onSelect?.(node.id); + }} + > + {" ".repeat(depth) + glyph + node.label} + + {hasChildren && isExpanded + ? (node.children ?? []).map((child) => renderNode(child, depth + 1)) + : null} + + ); + } + + return {(props.nodes ?? []).map((n) => renderNode(n, 0))}; +} + +/* ------------------------------------------------------------------ */ +/* Input */ +/* ------------------------------------------------------------------ */ + +/** {@link Input}'s props. */ +export interface InputProps { + value?: string; + placeholder?: string; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; + focused?: boolean; +} + +/** A minimal single-line text input (`tecode.ui.Input`, Req 10.1), over + * OpenTUI's ``. */ +export function Input(rawProps: Record): ReactNode { + const props = rawProps as InputProps; + const theme = useTheme(); + // OpenTUI's `` intrinsic tag name collides with React's built-in + // DOM `` element, so its declared `onSubmit` type is the + // intersection of both worlds' shapes (`(e: SubmitEvent) => void & + // (value: string) => void`). A handler typed to accept `unknown` is + // assignable to either half by contravariance, so it satisfies both at + // once without an unsound cast. + const onSubmit = props.onSubmit + ? (value: unknown) => { + if (typeof value === "string") props.onSubmit?.(value); + } + : undefined; + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Tabs */ +/* ------------------------------------------------------------------ */ + +/** One tab (the concrete shape {@link Tabs} expects — see this module's + * TSDoc). */ +export interface TabItem { + id: string; + label: string; +} + +/** {@link Tabs}'s props. */ +export interface TabsProps { + tabs?: TabItem[]; + activeId?: string; + onSelect?: (id: string) => void; + focused?: boolean; +} + +/** A minimal tab strip (`tecode.ui.Tabs`, Req 10.1, 6.5), over OpenTUI's + * `` — used by `EditorArea`'s `TabBar` (design.md §8.1). */ +export function Tabs(rawProps: Record): ReactNode { + const props = rawProps as TabsProps; + const theme = useTheme(); + const tabs = props.tabs ?? []; + const options: TabSelectOption[] = tabs.map((tab) => ({ + name: tab.label, + description: "", + value: tab.id, + })); + const selectedIndex = props.activeId ? tabs.findIndex((tab) => tab.id === props.activeId) : -1; + const ref = useRef(null); + + // `` only accepts its selected tab as an imperative + // `setSelectedIndex` call (`TabSelectRenderable`), not a constructor/JSX + // option — so `activeId` is applied via a ref effect rather than a prop. + useEffect(() => { + if (selectedIndex >= 0) ref.current?.setSelectedIndex(selectedIndex); + }, [selectedIndex]); + + return ( + { + if (option && typeof option.value === "string") props.onSelect?.(option.value); + }} + /> + ); +} diff --git a/packages/core/src/ui/focus.test.tsx b/packages/core/src/ui/focus.test.tsx new file mode 100644 index 0000000..17801bb --- /dev/null +++ b/packages/core/src/ui/focus.test.tsx @@ -0,0 +1,81 @@ +/** + * `ContextFocusTracker`/`useFocusTracking` tests (Req 4.6, design.md §8.1). + * Exercises the REAL `@opentui/core` `Renderable` focus mechanism (a real + * ``'s `.focus()`/`.blur()`, via `testRender`) rather than a fake + * event emitter, so this proves the actual `RenderableEvents.FOCUSED`/ + * `BLURRED` wiring, not just this module's own logic in isolation. + */ + +import { describe, expect, test } from "bun:test"; +import type { BoxRenderable } from "@opentui/core"; +import { testRender } from "@opentui/react/test-utils"; +import { createContextService } from "../keymap/context"; +import { ContextFocusTracker, useFocusTracking, type FocusEmitter } from "./focus"; + +function Probe(props: { onNode: (node: BoxRenderable | null) => void }) { + const trackingRef = useFocusTracking("testFocus"); + return ( + { + trackingRef(node as unknown as FocusEmitter | null); + props.onNode(node); + }} + /> + ); +} + +describe("ContextFocusTracker / useFocusTracking (Req 4.6)", () => { + test("focus() sets the context key true; blur() sets it false", async () => { + const context = createContextService(); + let captured: BoxRenderable | null = null; + + const { renderOnce } = await testRender( + + (captured = node)} /> + , + { width: 10, height: 3 }, + ); + await renderOnce(); + + expect(context.get("testFocus")).toBeUndefined(); + expect(captured).not.toBeNull(); + + captured!.focus(); + expect(context.get("testFocus")).toBe(true); + + captured!.blur(); + expect(context.get("testFocus")).toBe(false); + }); + + test("used outside a ContextFocusTracker, it attaches without throwing and reports nothing", async () => { + let captured: BoxRenderable | null = null; + const { renderOnce } = await testRender( (captured = node)} />, { + width: 10, + height: 3, + }); + await renderOnce(); + + expect(() => captured!.focus()).not.toThrow(); + }); + + test("the ref callback tolerates being called with null (React's unmount cleanup)", async () => { + const context = createContextService(); + let trackingRef: ((node: unknown) => void) | undefined; + + function Probe2() { + trackingRef = useFocusTracking("testFocus") as unknown as (node: unknown) => void; + return ; + } + + const { renderOnce } = await testRender( + + + , + { width: 10, height: 3 }, + ); + await renderOnce(); + + expect(() => trackingRef!(null)).not.toThrow(); + }); +}); diff --git a/packages/core/src/ui/focus.tsx b/packages/core/src/ui/focus.tsx new file mode 100644 index 0000000..4d9147e --- /dev/null +++ b/packages/core/src/ui/focus.tsx @@ -0,0 +1,95 @@ +/** + * `ContextFocusTracker`/`useFocusTracking()` (Req 4.6, design.md §8.1: "maps + * OpenTUI focus → context keys"; Task 1.14). No new context store is + * introduced — every focus change is reported through the existing + * `createContextService().set` (`keymap/context.ts`), the same store `when` + * clauses already read (design.md §6.4), so a keybinding's `when: + * "editorFocus"` works identically whether the context key was set by the + * keymap service or by this module. + * + * **How it observes focus**: OpenTUI's `Renderable` (`@opentui/core`) + * extends Node's `EventEmitter` and emits `RenderableEvents.FOCUSED`/ + * `BLURRED` on itself (not a single renderer-wide focus event) when + * `.focus()`/`.blur()` runs — including the OpenTUI-internal single-focus + * bookkeeping that blurs whatever was previously focused. {@link useFocusTracking} + * returns a `ref` callback a component attaches to one focusable OpenTUI + * intrinsic (``); it + * subscribes to that specific instance's `FOCUSED`/`BLURRED` events (handling + * both focus gain AND loss, per this task's requirement) and unsubscribes + * from the previous instance whenever the ref target changes or the owning + * component unmounts. + */ + +import { createContext, useCallback, useContext, useRef, type ReactNode } from "react"; +import { RenderableEvents } from "@opentui/core"; +import type { ContextService } from "../keymap/context"; + +/** The narrow slice of an OpenTUI `Renderable` {@link useFocusTracking} + * needs — `Renderable`'s own `EventEmitter`-derived `on`/`off` (matches how + * little of the underlying type most of this codebase's seams need). */ +export interface FocusEmitter { + on(event: string, listener: () => void): unknown; + off(event: string, listener: () => void): unknown; +} + +const FocusContextServiceContext = createContext(undefined); + +/** Props for {@link ContextFocusTracker}. */ +export interface ContextFocusTrackerProps { + /** The context service focus changes are reported to (`tecode.context`'s + * backing implementation, `keymap/context.ts`). */ + context: ContextService; + children?: ReactNode; +} + +/** + * Makes `context` available to every {@link useFocusTracking} call beneath + * it (design.md §8.1's component tree: `` wraps + * ``, inside ``). + */ +export function ContextFocusTracker(props: ContextFocusTrackerProps): ReactNode { + return ( + + {props.children} + + ); +} + +/** + * Returns a `ref` callback that reports `key`'s value (`true`/`false`) to + * the {@link ContextFocusTracker}-provided context service whenever the + * attached OpenTUI node gains or loses focus (this module's TSDoc). A + * no-op ref (attaches, but reports nothing) when called outside a + * {@link ContextFocusTracker} — matches this codebase's never-throwing + * discipline rather than requiring every isolated component test to wrap + * itself in a provider it does not care about. + */ +export function useFocusTracking(key: string): (node: FocusEmitter | null) => void { + const context = useContext(FocusContextServiceContext); + // Remembers the exact listener closures registered on the currently + // attached node — `.off()` only removes a listener given the SAME + // function reference passed to `.on()`, so cleanup must reuse these + // rather than constructing fresh closures at detach time. + const attached = useRef<{ node: FocusEmitter; onFocused: () => void; onBlurred: () => void } | null>( + null, + ); + + return useCallback( + (node: FocusEmitter | null) => { + if (attached.current) { + const { node: previousNode, onFocused, onBlurred } = attached.current; + previousNode.off(RenderableEvents.FOCUSED, onFocused); + previousNode.off(RenderableEvents.BLURRED, onBlurred); + attached.current = null; + } + if (node) { + const onFocused = () => context?.set(key, true); + const onBlurred = () => context?.set(key, false); + node.on(RenderableEvents.FOCUSED, onFocused); + node.on(RenderableEvents.BLURRED, onBlurred); + attached.current = { node, onFocused, onBlurred }; + } + }, + [context, key], + ); +} diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index e95db05..38ddb18 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,2 +1,69 @@ -// Placeholder for Shell, slot registry, theme provider, common components. -export const UI_PLACEHOLDER = true; +// The UI shell (Req 6.1-6.5, 7.3, design.md §8, §9; Task 1.14): slot +// registry, layout-state persistence, theming, focus tracking, the common +// component library, and the Shell/ActivityBar/Sidebar/EditorArea/Panel/ +// StatusBar components themselves. + +export { + createSlotRegistry, + type RegisterViewMeta, + type SidebarPair, + type SlotRegistry, + type SlotRegistryDeps, + type SlotViewEntry, + type StatusBarPlacement, +} from "./slotRegistry"; + +export { + createLayoutStateService, + DEFAULT_LAYOUT_STATE, + type LayoutState, + type LayoutStateFs, + type LayoutStateService, + type LayoutStateServiceDeps, + type LayoutStateTimer, +} from "./layoutState"; + +export { + ThemeProvider, + toColorInput, + styleToTextColors, + useTheme, + type ThemeProviderProps, +} from "./theme"; + +export { + ContextFocusTracker, + useFocusTracking, + type ContextFocusTrackerProps, + type FocusEmitter, +} from "./focus"; + +export { + Input, + List, + RegisteredView, + Tabs, + Tree, + type InputProps, + type ListItem, + type ListProps, + type TabItem, + type TabsProps, + type TreeNode, + type TreeProps, +} from "./components"; + +export { + ActivityBar, + EditorArea, + Panel, + Shell, + Sidebar, + StatusBar, + type ActivityBarProps, + type EditorAreaProps, + type PanelProps, + type ShellProps, + type SidebarProps, + type StatusBarProps, +} from "./shell"; diff --git a/packages/core/src/ui/layoutState.test.ts b/packages/core/src/ui/layoutState.test.ts new file mode 100644 index 0000000..da0768d --- /dev/null +++ b/packages/core/src/ui/layoutState.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "bun:test"; +import type { HostError } from "../host/errors"; +import { createHostLog } from "../host/errors"; +import { + createLayoutStateService, + DEFAULT_LAYOUT_STATE, + type LayoutState, + type LayoutStateFs, + type LayoutStateTimer, +} from "./layoutState"; + +/** A `StatusSink` stub that records every error it receives (matches + * `config/service.test.ts`'s `createRecordingSink`). */ +function createRecordingSink() { + const errors: HostError[] = []; + return { + errors, + sink: { + error(err: HostError) { + errors.push(err); + }, + }, + }; +} + +/** An in-memory {@link LayoutStateFs}: `readFile` serves whatever + * `setFile` last stored (or ENOENT); `writeFile` records every write so + * tests can assert on it directly instead of round-tripping through a real + * file (matches `config/service.test.ts`'s `createFakeFs`). */ +function createFakeFs(initial: Record = {}): { + fs: LayoutStateFs; + writes(): { path: string; data: string }[]; + setFile(path: string, content: string): void; +} { + const files = new Map(Object.entries(initial)); + const writes: { path: string; data: string }[] = []; + return { + fs: { + async readFile(path) { + const content = files.get(path); + if (content === undefined) { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" }); + } + return content; + }, + async mkdir() { + // No-op — the fake has no real directories to create. + }, + async writeFile(path, data) { + writes.push({ path, data }); + files.set(path, data); + }, + }, + writes: () => writes, + setFile(path, content) { + files.set(path, content); + }, + }; +} + +/** A manually-driven {@link LayoutStateTimer}: `schedule` records the + * callback instead of running it on a real clock; the test fires it later + * via `runScheduled()` — deterministic, no real waiting (design.md §16's + * "no flaky timing"). */ +function createManualTimer(): { + timer: LayoutStateTimer; + scheduledCount(): number; + cancelledCount(): number; + runScheduled(): void; +} { + let nextHandle = 0; + const pending = new Map void>(); + let cancelledCount = 0; + return { + timer: { + schedule(fn) { + const handle = nextHandle++; + pending.set(handle, fn); + return handle; + }, + cancel(handle) { + if (pending.delete(handle as number)) cancelledCount += 1; + }, + }, + scheduledCount: () => nextHandle, + cancelledCount: () => cancelledCount, + runScheduled() { + const entries = Array.from(pending.entries()); + pending.clear(); + for (const [, fn] of entries) fn(); + }, + }; +} + +describe("createLayoutStateService — load (Req 6.4)", () => { + test("no file yet: ready resolves and get() reports DEFAULT_LAYOUT_STATE", async () => { + const { fs } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + + await service.ready; + expect(service.get()).toEqual(DEFAULT_LAYOUT_STATE); + }); + + test("a well-formed file overrides the defaults", async () => { + const stored: LayoutState = { + sidebarVisible: false, + sidebarWidth: 42, + panelVisible: true, + panelHeight: 12, + activeView: "explorer", + }; + const { fs } = createFakeFs({ "/state.json": JSON.stringify(stored) }); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + + await service.ready; + expect(service.get()).toEqual(stored); + }); + + test("a corrupt file keeps last-good defaults and reports through log/sink", async () => { + const { fs } = createFakeFs({ "/state.json": "{ not json" }); + const log = createHostLog(); + const { sink, errors } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + + await service.ready; + expect(service.get()).toEqual(DEFAULT_LAYOUT_STATE); + expect(errors.some((e) => e.path === "/state.json")).toBe(true); + expect(log.entries().some((e) => e.level === "error")).toBe(true); + }); + + test("a partially-typed file falls back per-field to defaults", async () => { + const { fs } = createFakeFs({ + "/state.json": JSON.stringify({ sidebarWidth: "not a number", panelVisible: true }), + }); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + + await service.ready; + const state = service.get(); + expect(state.sidebarWidth).toBe(DEFAULT_LAYOUT_STATE.sidebarWidth); + expect(state.panelVisible).toBe(true); + expect(state.sidebarVisible).toBe(DEFAULT_LAYOUT_STATE.sidebarVisible); + }); +}); + +describe("createLayoutStateService — update()/debounce/flush (Req 6.4, design.md §8.2)", () => { + test("update() reflects immediately in get() before any write happens", async () => { + const { fs, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const { timer } = createManualTimer(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs, timer }); + await service.ready; + + service.update({ sidebarWidth: 99 }); + expect(service.get().sidebarWidth).toBe(99); + expect(writes()).toEqual([]); + }); + + test("the write only happens once the debounce timer fires", async () => { + const { fs, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const { timer, runScheduled } = createManualTimer(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs, timer }); + await service.ready; + + service.update({ sidebarWidth: 99 }); + expect(writes()).toEqual([]); + + runScheduled(); + await service.flush(); // drains the chain scheduleSave() appended + + expect(writes()).toHaveLength(1); + const written = JSON.parse(writes()[0]!.data) as LayoutState; + expect(written.sidebarWidth).toBe(99); + }); + + test("a burst of update() calls before the timer fires produces exactly one write, with the latest values", async () => { + const { fs, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const { timer, runScheduled, cancelledCount } = createManualTimer(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs, timer }); + await service.ready; + + service.update({ sidebarWidth: 10 }); + service.update({ sidebarWidth: 20 }); + service.update({ sidebarWidth: 30, activeView: "explorer" }); + + // Each update() cancels the previous debounce timer and starts a new + // one — two of the three scheduled timers get cancelled. + expect(cancelledCount()).toBe(2); + + runScheduled(); + await service.flush(); + + expect(writes()).toHaveLength(1); + const written = JSON.parse(writes()[0]!.data) as LayoutState; + expect(written.sidebarWidth).toBe(30); + expect(written.activeView).toBe("explorer"); + }); + + test("flush() cancels the pending debounce and writes immediately (the shutdown path)", async () => { + const { fs, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const { timer, cancelledCount } = createManualTimer(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs, timer }); + await service.ready; + + service.update({ panelVisible: true }); + await service.flush(); + + expect(cancelledCount()).toBe(1); + expect(writes()).toHaveLength(1); + const written = JSON.parse(writes()[0]!.data) as LayoutState; + expect(written.panelVisible).toBe(true); + }); + + test("flush() with nothing pending resolves without writing", async () => { + const { fs, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + await service.ready; + + await service.flush(); + expect(writes()).toEqual([]); + }); + + test("a write failure is reported through log/sink and never throws", async () => { + const log = createHostLog(); + const { sink, errors } = createRecordingSink(); + const fs: LayoutStateFs = { + async readFile() { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + async mkdir() { + // No-op. + }, + async writeFile() { + throw new Error("disk full"); + }, + }; + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + await service.ready; + + service.update({ sidebarWidth: 5 }); + await expect(service.flush()).resolves.toBeUndefined(); + expect(errors.some((e) => e.message.includes("disk full"))).toBe(true); + }); + + test("a roundtrip across a simulated restart: flush(), then a fresh service reads it back", async () => { + const { fs, setFile, writes } = createFakeFs(); + const log = createHostLog(); + const { sink } = createRecordingSink(); + + const first = createLayoutStateService({ log, sink, path: "/state.json", fs }); + await first.ready; + first.update({ sidebarWidth: 77, activeView: "search" }); + await first.flush(); + + expect(writes()).toHaveLength(1); + // Simulate a fresh process by pointing a brand-new service at the same + // (now-populated) fake file store. + setFile("/state.json", writes()[0]!.data); + const second = createLayoutStateService({ log, sink, path: "/state.json", fs }); + await second.ready; + + expect(second.get().sidebarWidth).toBe(77); + expect(second.get().activeView).toBe("search"); + }); +}); diff --git a/packages/core/src/ui/layoutState.ts b/packages/core/src/ui/layoutState.ts new file mode 100644 index 0000000..cf64ce2 --- /dev/null +++ b/packages/core/src/ui/layoutState.ts @@ -0,0 +1,316 @@ +/** + * The layout-state service (Req 6.4, design.md §8.2): persists + * `{ sidebarVisible, sidebarWidth, panelVisible, panelHeight, activeView }` + * to `~/.config/tecode/state.json` on change (debounced) and on shutdown + * (`flush()`). + * + * Built with {@link createLayoutStateService} rather than a class, per + * house convention (matches `createConfigService`, `createContextService`). + * Mirrors `config/service.ts`'s initialization design choice: construction + * does no I/O before returning — the initial read starts in the + * background, and `get()` is always safe to call immediately, returning + * {@link DEFAULT_LAYOUT_STATE} until {@link LayoutStateService.ready} + * settles (a caller that needs the persisted state before first render + * awaits `ready`; the Shell does not have to). + * + * **Debounced, serialized writes**: {@link LayoutStateService.update} merges + * its argument into the in-memory state immediately (so `get()` reflects it + * right away) and (re)starts a debounce timer via the injectable + * {@link LayoutStateTimer} seam; when the timer fires, the write is + * appended to a serialized `saveChain` (`saveChain = saveChain.then(doSave, + * doSave)`, matching `config/service.ts`'s per-file reload chains) so a + * burst of `update()` calls whose debounce windows overlap never runs two + * overlapping writes. {@link LayoutStateService.flush} cancels any pending + * timer and appends an immediate save to the same chain, resolving once + * every write chained so far (including one already in flight) has + * settled — the shutdown path (design.md §8.2's "on exit"). + * + * **Parse failure → last-good defaults**: exactly `config/service.ts`'s + * policy — a missing file is `{}`-equivalent (this module's + * {@link DEFAULT_LAYOUT_STATE}), and a corrupt/unreadable file keeps + * whatever the in-memory state already holds (the compile-time default on + * first load) rather than throwing or wiping user layout preferences. + * + * Never-throwing public API: every method is guarded so a broken injected + * `fs`/`timer`/`log` cannot make a caller's `update()`/`flush()` throw. + */ + +import { readFile as nodeReadFile, writeFile as nodeWriteFile, mkdir as nodeMkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { HostError, HostLog, StatusSink } from "../host/errors"; +import { getUserLayoutStatePath } from "../host/paths"; + +/** Persisted UI layout state (Req 6.4). */ +export interface LayoutState { + sidebarVisible: boolean; + sidebarWidth: number; + panelVisible: boolean; + panelHeight: number; + /** The `id` of the currently active `sidebar.view`, or `undefined` if + * none has been selected yet. */ + activeView: string | undefined; +} + +/** The layout state a fresh install (or a corrupt/missing `state.json`) + * starts from (Req 6.4). Not exported as a shared mutable reference — + * {@link createLayoutStateService} copies it into its own state so no + * caller can mutate the shared default. */ +const DEFAULT_LAYOUT_STATE: Readonly = Object.freeze({ + sidebarVisible: true, + sidebarWidth: 30, + panelVisible: false, + panelHeight: 10, + activeView: undefined, +}); + +/** The narrow filesystem seam {@link createLayoutStateService} needs — + * exists as an injectable seam (defaulting to `node:fs/promises`) so tests + * can simulate a corrupt file, a slow write, or a write failure without + * touching the real filesystem (matches `config/service.ts`'s + * `ConfigServiceFs`, `documentManager.ts`'s `DocumentManagerFs`). Not part + * of the public API surface. */ +export interface LayoutStateFs { + readFile(path: string): Promise; + /** Create `path`'s parent directory if it does not exist (the user + * config dir may not exist yet on a fresh install — matches the + * `{ recursive: true }` idiom). */ + mkdir(path: string): Promise; + writeFile(path: string, data: string): Promise; +} + +function createNodeLayoutFs(): LayoutStateFs { + return { + readFile: (path) => nodeReadFile(path, "utf8"), + mkdir: (path) => nodeMkdir(path, { recursive: true }).then(() => undefined), + writeFile: (path, data) => nodeWriteFile(path, data, "utf8"), + }; +} + +/** The debounce/scheduling seam {@link createLayoutStateService} needs for + * `update()`'s debounce window — an injectable seam (defaulting to real + * `setTimeout`/`clearTimeout`) so tests can control exactly when a + * debounced save fires without a real (and therefore flaky) wait (matches + * this codebase's other injectable-seam conventions). Not part of the + * public API surface. */ +export interface LayoutStateTimer { + schedule(fn: () => void, ms: number): unknown; + cancel(handle: unknown): void; +} + +function createRealTimer(): LayoutStateTimer { + return { + schedule: (fn, ms) => setTimeout(fn, ms), + cancel: (handle) => clearTimeout(handle as ReturnType), + }; +} + +/** Dependencies for {@link createLayoutStateService}. */ +export interface LayoutStateServiceDeps { + /** Structured log for read/write/parse failures (design.md §14). */ + log: HostLog; + /** Where user-facing persistence errors are surfaced (design.md §14). */ + sink: StatusSink; + /** Overrides `state.json`'s path — tests use a temp file; production + * defaults to {@link getUserLayoutStatePath}. */ + path?: string; + /** Filesystem seam — see {@link LayoutStateFs}. Defaults to + * `node:fs/promises`. */ + fs?: LayoutStateFs; + /** Debounce/scheduling seam — see {@link LayoutStateTimer}. Defaults to + * real timers. */ + timer?: LayoutStateTimer; + /** Debounce window, in milliseconds, between the last `update()` call + * and the write it schedules. Defaults to 250. */ + debounceMs?: number; +} + +/** The layout-state service — the implementation behind the Shell's + * persisted layout (Req 6.4). */ +export interface LayoutStateService { + /** The current in-memory state — always safe to call, never blocks. + * Before {@link ready} settles this is {@link DEFAULT_LAYOUT_STATE}. */ + get(): LayoutState; + /** Merge `partial` into the current state and schedule a debounced, + * serialized write (this module's TSDoc). Never throws. */ + update(partial: Partial): void; + /** Resolves once the initial read of `state.json` has completed (or + * failed, keeping defaults) — see this module's TSDoc for why this is a + * promise rather than an `async` factory. */ + ready: Promise; + /** Cancel any pending debounce timer and write the current state now. + * Resolves once that write (and anything already chained ahead of it) + * has settled — the shutdown path (Req 6.4). Never rejects. */ + flush(): Promise; +} + +/** Render a caught `unknown` as a message string without risking a second + * throw (matches `config/service.ts`'s/`registry.ts`'s `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Extract an errno-style `code` (matches `documentManager.ts`'s/ + * `config/service.ts`'s `errorCode`). */ +function errorCode(err: unknown): string | undefined { + if (typeof err === "object" && err !== null && "code" in err) { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") return code; + } + return undefined; +} + +/** Best-effort field-by-field validation of a parsed `state.json` (mirrors + * `config/service.ts`'s `matchesSchemaType` policy): a field with the wrong + * runtime shape falls back to `fallback`'s value for that field rather than + * failing the whole load. */ +function coerceLayoutState(value: unknown, fallback: LayoutState): LayoutState { + if (typeof value !== "object" || value === null) return { ...fallback }; + const raw = value as Record; + return { + sidebarVisible: + typeof raw["sidebarVisible"] === "boolean" ? raw["sidebarVisible"] : fallback.sidebarVisible, + sidebarWidth: + typeof raw["sidebarWidth"] === "number" ? raw["sidebarWidth"] : fallback.sidebarWidth, + panelVisible: + typeof raw["panelVisible"] === "boolean" ? raw["panelVisible"] : fallback.panelVisible, + panelHeight: + typeof raw["panelHeight"] === "number" ? raw["panelHeight"] : fallback.panelHeight, + activeView: typeof raw["activeView"] === "string" ? raw["activeView"] : fallback.activeView, + }; +} + +/** Build the layout-state service (Req 6.4). `deps.log`/`deps.sink` are + * required; everything else is optional (see + * {@link LayoutStateServiceDeps}). */ +export function createLayoutStateService(deps: LayoutStateServiceDeps): LayoutStateService { + const { log, sink } = deps; + const path = deps.path ?? getUserLayoutStatePath(); + const fs = deps.fs ?? createNodeLayoutFs(); + const timer = deps.timer ?? createRealTimer(); + const debounceMs = deps.debounceMs ?? 250; + + let state: LayoutState = { ...DEFAULT_LAYOUT_STATE }; + let pendingTimer: unknown; + // Serialized write chain (this module's TSDoc) — every scheduled save + // (debounced or flushed) is appended here so writes never overlap. + let saveChain: Promise = Promise.resolve(); + + function logSafely(level: "error" | "warning", err: HostError): void { + try { + log.append(level, err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } + } + + function notifySafely(err: HostError): void { + try { + sink.error(err); + } catch { + // Swallowed — see logSafely. + } + } + + async function doSave(): Promise { + // Snapshot at write time (not at schedule time): the freshest state as + // of when the debounce/flush actually runs is what gets written, so a + // rapid burst of update() calls only ever produces one write of the + // latest values. + const snapshot = state; + try { + await fs.mkdir(dirname(path)); + await fs.writeFile(path, JSON.stringify(snapshot, null, 2)); + } catch (cause) { + const message = `Failed to write layout state (${path}): ${describeError(cause)}`; + logSafely("error", { message, path }); + notifySafely({ message, path }); + } + } + + function scheduleSave(): void { + saveChain = saveChain.then(doSave, doSave); + } + + function get(): LayoutState { + return { ...state }; + } + + function update(partial: Partial): void { + state = { ...state, ...partial }; + if (pendingTimer !== undefined) { + try { + timer.cancel(pendingTimer); + } catch { + // Best-effort — a broken timer seam must not stop the new one + // from being scheduled below. + } + } + try { + pendingTimer = timer.schedule(() => { + pendingTimer = undefined; + scheduleSave(); + }, debounceMs); + } catch (cause) { + // A timer seam that throws on schedule() must not lose the update + // permanently — save it directly instead of debouncing. + pendingTimer = undefined; + logSafely("warning", { + message: `Layout state debounce timer failed, saving immediately: ${describeError(cause)}`, + }); + scheduleSave(); + } + } + + async function flush(): Promise { + if (pendingTimer !== undefined) { + try { + timer.cancel(pendingTimer); + } catch { + // Best-effort. + } + pendingTimer = undefined; + scheduleSave(); + } + await saveChain; + } + + async function load(): Promise { + let text: string; + try { + text = await fs.readFile(path); + } catch (cause) { + if (errorCode(cause) !== "ENOENT") { + const message = `Failed to read layout state (${path}): ${describeError(cause)}`; + logSafely("error", { message, path }); + notifySafely({ message, path }); + } + return; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (cause) { + const message = `Failed to parse layout state (${path}): ${describeError(cause)}`; + logSafely("error", { message, path }); + notifySafely({ message, path }); + return; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + } + + state = coerceLayoutState(parsed, state); + } + + return { + get, + update, + ready: load(), + flush, + }; +} + +export { DEFAULT_LAYOUT_STATE }; diff --git a/packages/core/src/ui/shell.test.tsx b/packages/core/src/ui/shell.test.tsx new file mode 100644 index 0000000..0bb1af5 --- /dev/null +++ b/packages/core/src/ui/shell.test.tsx @@ -0,0 +1,309 @@ +/** + * Shell snapshot/integration tests (Req 6.1-6.5; design.md §16; Task 1.14). + * + * **The headless-rendering API used, and how it was found**: `@opentui/core` + * ships a genuine headless test renderer at `@opentui/core/testing` + * (`createTestRenderer`, discovered by reading + * `node_modules/@opentui/core/testing/test-renderer.d.ts`) that runs the + * real terminal-cell-buffer pipeline against an in-memory `CliRenderer` — + * no real TTY is opened. `@opentui/react` wraps it for React trees as + * `testRender` (`@opentui/react/test-utils`, confirmed by reading its + * `test-utils.d.ts`), returning: + * - `renderOnce()` — flush one frame synchronously (no timers, no + * polling — deterministic). + * - `captureCharFrame()` — the actual rendered cell grid as one string + * (rows joined by `\n`), used below as design.md §16's "snapshot the + * cell grid" — every assertion here reads real rendered terminal + * output, not a shallow React tree. + * - `captureSpans()` — per-cell style spans, for tests that need to + * assert on color/attributes rather than characters (unused here — no + * assertion in this file needs to distinguish colors, only content and + * layout). + * This is a full, working headless cell-grid renderer, not a fallback — + * design.md §16's ask ("OpenTUI's headless renderer renders the Shell... + * and snapshots the cell grid") is met directly with the real API, with one + * documented gap below. + * + * **Coverage gap, documented explicitly (design.md §16's instruction)**: + * `RenderableEvents.FOCUSED`/`BLURRED` fire from an OpenTUI node's own + * `.focus()`/`.blur()` calls (see `focus.test.tsx`, which exercises this + * directly against a real ``). Driving that same transition through + * the Shell end-to-end would additionally require simulating a mouse click + * or Tab-key traversal through `testRender`'s `mockMouse`/`mockInput` and + * OpenTUI's own focus-manager keyboard/mouse wiring, which is outside this + * task's scope (the Shell does not yet register any focus-manager + * keybindings — that is a later editor-focused task). The "focus change + * updates context keys" case below therefore calls `.focus()`/`.blur()` + * directly on a `ref`-captured region root (exactly as `focus.test.tsx` + * does), through the Shell's *actual* rendered tree rather than an isolated + * `` — proving the Shell wires `useFocusTracking` correctly on its + * regions, short of also re-proving OpenTUI's own input-to-focus dispatch + * (which is `@opentui/core`'s contract, not this module's). + * + * **Determinism**: no real timers, no `sleep`, no polling loops — every + * test drives exactly one `renderOnce()` (or two, when a state change must + * flush before the next assertion) with no wall-clock dependency. + * + * **Cosmetic `act(...)` console warnings**: `testRender`'s own initial + * mount (`@opentui/react/test-utils`) wraps `root.render(node)` in a + * *synchronous* `act()`, which does not flush passive effects scheduled + * for after paint — `useLayoutState`'s `layoutState.ready.then(setState)` + * effect settles on a later microtask outside that window, so React logs + * an act-wrapping warning for it even though every assertion in this file + * runs after an explicit `await act(async () => { await renderOnce() })` + * and is not flaky. This is a quirk of the installed `@opentui/react` + * version's `test-utils`, not of the Shell; noted here rather than + * silenced, since suppressing it would risk hiding a real one later. + */ + +import { describe, expect, test } from "bun:test"; +import { act } from "react"; +import type { BoxRenderable } from "@opentui/core"; +import { testRender } from "@opentui/react/test-utils"; +import { createCommandRegistry } from "../commands/registry"; +import { createHostLog } from "../host/errors"; +import { createContextService } from "../keymap/context"; +import { ContextFocusTracker } from "./focus"; +import { createLayoutStateService, type LayoutStateFs } from "./layoutState"; +import { createSlotRegistry } from "./slotRegistry"; +import { Shell } from "./shell"; +import { ThemeProvider } from "./theme"; + +function createRecordingSink() { + return { error() {} }; +} + +/** An in-memory {@link LayoutStateFs} that starts with no `state.json` (so + * every test gets {@link DEFAULT_LAYOUT_STATE} deterministically, with no + * real filesystem involved). */ +function createEmptyLayoutFs(): LayoutStateFs { + return { + async readFile() { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + async mkdir() {}, + async writeFile() {}, + }; +} + +function createHarness() { + const log = createHostLog(); + const sink = createRecordingSink(); + const slotRegistry = createSlotRegistry({ log }); + const layoutState = createLayoutStateService({ log, sink, path: "/state.json", fs: createEmptyLayoutFs() }); + const context = createContextService(); + const commands = createCommandRegistry({ log, sink }); + return { slotRegistry, layoutState, context, commands }; +} + +const noopComponent = () => undefined; + +describe("Shell — empty shell renders every region (Req 6.1, design.md §16)", () => { + test("renders ActivityBar/Sidebar/EditorArea/Panel/StatusBar with no registered views", async () => { + const { slotRegistry, layoutState, context } = createHarness(); + await layoutState.ready; + + const { renderOnce, captureCharFrame } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + + const frame = captureCharFrame(); + // The placeholder EditorView renders even with no tabs (Req 6.5, + // design.md §8.3 — "no visible editing yet" is expected at this task). + expect(frame).toContain("No editor open."); + // A non-blank frame of the requested dimensions proves every region + // actually laid out (an empty/crashed tree would render nothing). + expect(frame.split("\n").length).toBeGreaterThanOrEqual(20); + }); +}); + +describe("Shell — registering a view re-renders its region (Req 6.3, design.md §8.2)", () => { + test("registering a sidebar.view + activityBar.item pair shows it once selected", async () => { + const { slotRegistry, layoutState, context, commands } = createHarness(); + await layoutState.ready; + + const { renderOnce, captureCharFrame } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + expect(captureCharFrame()).not.toContain("Explorer Panel Content"); + + act(() => { + slotRegistry.registerView("activityBar.item", "explorer", noopComponent, { + title: "Explorer", + icon: "E", + }); + slotRegistry.registerView( + "sidebar.view", + "explorer", + () => Explorer Panel Content, + { title: "Explorer" }, + ); + }); + await act(async () => { await renderOnce(); }); // lets the workbench.view.explorer command finish registering + + // Selecting the view goes through the Shell's own state (this module's + // TSDoc: "the Shell is the layout service's one and only writer") — + // exercised here via the `workbench.view.` command (Req 6.2) + // exactly as a real activity-bar click would drive it. + await act(async () => { + await commands.execute("workbench.view.explorer"); + }); + await act(async () => { await renderOnce(); }); + + expect(captureCharFrame()).toContain("Explorer Panel Content"); + }); + + test("registering a statusBar.item re-renders the StatusBar", async () => { + const { slotRegistry, layoutState, context } = createHarness(); + await layoutState.ready; + + const { renderOnce, captureCharFrame } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + expect(captureCharFrame()).not.toContain("Ln 1, Col 1"); + + act(() => { + slotRegistry.registerView( + "statusBar.item", + "cursor.position", + () => Ln 1, Col 1, + { statusBar: { side: "right", priority: 0 } }, + ); + }); + await act(async () => { await renderOnce(); }); + + expect(captureCharFrame()).toContain("Ln 1, Col 1"); + }); + + test("a lazy sidebar.view (pending manifest contribution) requests extension activation once selected", async () => { + const { layoutState, context, commands } = createHarness(); + await layoutState.ready; + const activated: string[] = []; + const slotRegistry = createSlotRegistry({ + pendingViews: [ + { extensionId: "demo.ext", view: { id: "demo.view", title: "Demo", slot: "sidebar" } }, + ], + activateExtension: async (id) => { + activated.push(id); + }, + }); + + const { renderOnce } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + expect(activated).toEqual([]); // not selected yet — no activation requested + + await act(async () => { + await commands.execute("workbench.view.demo.view"); + }); + await act(async () => { await renderOnce(); }); + + expect(activated).toEqual(["demo.ext"]); + }); +}); + +describe("Shell — workbench.view. command switches the sidebar (Req 6.2)", () => { + test("executing the command activates the same-id sidebar view", async () => { + const { slotRegistry, layoutState, context, commands } = createHarness(); + await layoutState.ready; + slotRegistry.registerView("activityBar.item", "search", noopComponent, { title: "Search" }); + slotRegistry.registerView("sidebar.view", "search", () => Search Panel); + + const { renderOnce, captureCharFrame } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + expect(captureCharFrame()).not.toContain("Search Panel"); + + await act(async () => { + await commands.execute("workbench.view.search"); + }); + await act(async () => { await renderOnce(); }); + + expect(captureCharFrame()).toContain("Search Panel"); + }); +}); + +describe("Shell — focus change updates context keys (Req 4.6, design.md §8.1)", () => { + test("focusing a Shell region's root box sets its context key true, blurring sets it false", async () => { + const { slotRegistry, layoutState, context } = createHarness(); + await layoutState.ready; + + const { renderOnce, renderer } = await testRender( + + + + + , + { width: 60, height: 20 }, + ); + await act(async () => { await renderOnce(); }); + + // Every `focusable` box in the rendered tree is one Shell region wired + // through `useFocusTracking` (Sidebar/EditorArea/Panel — Panel starts + // hidden in this fixture, so exactly Sidebar and EditorArea are found). + // Focusing each and checking exactly one recognized context key flips + // proves the Shell's own `useFocusTracking` wiring end-to-end, without + // depending on which region happens to come first in the tree. + const focusables = findAllFocusable(renderer.root) as BoxRenderable[]; + expect(focusables.length).toBeGreaterThanOrEqual(2); + + for (const key of ["sidebarFocus", "editorFocus"]) { + expect(context.get(key)).toBeUndefined(); + } + + for (const node of focusables) { + node.focus(); + const setKeys = ["sidebarFocus", "editorFocus", "panelFocus"].filter( + (key) => context.get(key) === true, + ); + expect(setKeys).toHaveLength(1); + node.blur(); + expect(context.get(setKeys[0]!)).toBe(false); + } + }); +}); + +/** Depth-first collection of every `focusable` descendant, used only by the + * focus test above to locate the Shell's region roots without Shell + * exposing test-only refs on its public props. */ +function findAllFocusable(node: unknown): unknown[] { + const candidate = node as { + focusable?: boolean; + getChildren?: () => unknown[]; + }; + const found: unknown[] = candidate?.focusable ? [candidate] : []; + for (const child of candidate?.getChildren?.() ?? []) { + found.push(...findAllFocusable(child)); + } + return found; +} diff --git a/packages/core/src/ui/shell.tsx b/packages/core/src/ui/shell.tsx new file mode 100644 index 0000000..8d4df27 --- /dev/null +++ b/packages/core/src/ui/shell.tsx @@ -0,0 +1,435 @@ +/** + * The UI shell (Req 6.1-6.5; design.md §8.1, §8.2; Task 1.14): the VS + * Code-style arrangement of `ActivityBar` / `Sidebar` / `EditorArea` + * (`TabBar` + a placeholder `EditorView`) / `Panel` / `StatusBar`, wired to + * the {@link SlotRegistry} (Req 6.2, 6.3) and the {@link LayoutStateService} + * (Req 6.4). + * + * **Component tree** (design.md §8.1 — `ThemeProvider`/`ContextFocusTracker` + * wrap this from the outside, at the assembly layer, not inside this + * module): + * + * ``` + * + * // ActivityBar | Sidebar | EditorArea + * + * + * + * + * + * + * + * ``` + * + * **Reactivity**: every region subscribes to `SlotRegistry.onDidChange` for + * the slot(s) it renders (via {@link useSlotViews}/{@link useSidebarPairs}/ + * {@link useStatusBarItems} below) so a `tecode.ui.registerView` call + * re-renders exactly the affected region — the Shell itself never polls. + * + * **Layout persistence** (Req 6.4): {@link useLayoutState} seeds React state + * from `LayoutStateService.get()` (already populated with defaults even + * before `ready` settles) and re-seeds once `ready` resolves; every + * mutating action (toggling the sidebar, switching the active view, + * resizing the panel) calls both `LayoutStateService.update()` (persisted, + * debounced) and the local `setState` in the same handler — the Shell is + * the layout service's one and only writer, so no `onDidChange` round-trip + * is needed to stay in sync with itself. + * + * **`EditorView` is a placeholder** (design.md §8.3 — the real + * cursor/selection/gutter-rendering editor is a later task; tasks.md's + * Phase 1 exit criterion is explicitly "no visible editing yet"). `TabBar` + * accordingly renders whatever `tabs` `EditorArea` is given (empty by + * default) rather than reading from a document manager this task does not + * wire in. + */ + +import { useCallback, useEffect, useReducer, useState, type ReactNode } from "react"; +import type { Disposable, SlotId } from "@tecode/api"; +import type { CommandRegistry } from "../commands/registry"; +import { RegisteredView, Tabs, type TabItem } from "./components"; +import { useFocusTracking } from "./focus"; +import type { LayoutState, LayoutStateService } from "./layoutState"; +import type { SidebarPair, SlotRegistry, SlotViewEntry } from "./slotRegistry"; +import { toColorInput, useTheme } from "./theme"; + +/* ------------------------------------------------------------------ */ +/* Shared reactive-subscription hooks */ +/* ------------------------------------------------------------------ */ + +/** Re-renders the calling component whenever `slotRegistry` reports a + * change to `slot`, and returns that slot's current views (design.md + * §8.2's "shell regions subscribe and re-render on registration"). */ +function useSlotViews(slotRegistry: SlotRegistry, slot: SlotId): readonly SlotViewEntry[] { + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + const sub = slotRegistry.onDidChange((changed) => { + if (changed === slot) forceRender(); + }); + return () => sub.dispose(); + }, [slotRegistry, slot]); + return slotRegistry.getViews(slot); +} + +/** Same as {@link useSlotViews}, but for the `activityBar.item` ↔ + * `sidebar.view` pairing (Req 6.2) — re-renders on a change to either + * slot. */ +function useSidebarPairs(slotRegistry: SlotRegistry): readonly SidebarPair[] { + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + const sub = slotRegistry.onDidChange((changed) => { + if (changed === "activityBar.item" || changed === "sidebar.view") forceRender(); + }); + return () => sub.dispose(); + }, [slotRegistry]); + return slotRegistry.listSidebarPairs(); +} + +/** Same as {@link useSlotViews}, but for the sorted `statusBar.item` + * enumeration (design.md §8.2). */ +function useStatusBarItems(slotRegistry: SlotRegistry): readonly SlotViewEntry[] { + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + const sub = slotRegistry.onDidChange((changed) => { + if (changed === "statusBar.item") forceRender(); + }); + return () => sub.dispose(); + }, [slotRegistry]); + return slotRegistry.listStatusBarItems(); +} + +/** Seeds React state from `layoutState.get()` (Req 6.4) and keeps it in + * sync with the service across `ready` and every local `update` call (this + * module's TSDoc). */ +function useLayoutState( + layoutState: LayoutStateService, +): [LayoutState, (partial: Partial) => void] { + const [state, setState] = useState(() => layoutState.get()); + + useEffect(() => { + let cancelled = false; + void layoutState.ready.then(() => { + if (!cancelled) setState(layoutState.get()); + }); + return () => { + cancelled = true; + }; + }, [layoutState]); + + const update = useCallback( + (partial: Partial) => { + layoutState.update(partial); + setState((prev) => ({ ...prev, ...partial })); + }, + [layoutState], + ); + + return [state, update]; +} + +/* ------------------------------------------------------------------ */ +/* ActivityBar */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link ActivityBar}. */ +export interface ActivityBarProps { + slotRegistry: SlotRegistry; + activeView: string | undefined; + onSelectView: (id: string) => void; +} + +/** The activity bar (Req 6.1, 6.2): one icon per `activityBar.item` ↔ + * `sidebar.view` pair, highlighting the active one. */ +export function ActivityBar(props: ActivityBarProps): ReactNode { + const theme = useTheme(); + const pairs = useSidebarPairs(props.slotRegistry); + + return ( + + {pairs.map((pair) => { + const isActive = pair.id === props.activeView; + const item = pair.activityItem; + if (item?.component) { + return ( + props.onSelectView(pair.id)}> + + + ); + } + const glyph = item?.icon ?? item?.title?.slice(0, 1) ?? pair.id.slice(0, 1); + return ( + props.onSelectView(pair.id)} + > + {` ${glyph} `} + + ); + })} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Sidebar */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link Sidebar}. */ +export interface SidebarProps { + slotRegistry: SlotRegistry; + visible: boolean; + width: number; + activeView: string | undefined; +} + +/** The sidebar (Req 6.1, 6.2, 6.4): renders the `sidebar.view` paired with + * `activeView`, requesting lazy activation if it has no component yet + * (design.md §8.2). */ +export function Sidebar(props: SidebarProps): ReactNode { + const theme = useTheme(); + const pairs = useSidebarPairs(props.slotRegistry); + const focusRef = useFocusTracking("sidebarFocus"); + + if (!props.visible) return null; + + const active = props.activeView ? pairs.find((p) => p.id === props.activeView) : undefined; + const view = active?.sidebarView; + + if (view && view.lazy && !view.component) { + // Fire-and-forget lazy activation (this module's TSDoc; design.md + // §8.2's "activating the owning extension lazily if needed"). Safe to + // call on every render — requestActivation de-duplicates in-flight + // requests itself (slotRegistry.ts). + props.slotRegistry.requestActivation("sidebar.view", view.id); + } + + return ( + + {view?.title ? ( + {view.title} + ) : null} + {view?.component ? ( + + ) : ( + + {view ? "Activating…" : ""} + + )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* EditorArea (TabBar + placeholder EditorView) */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link EditorArea}. */ +export interface EditorAreaProps { + /** Open editor tabs — one editor group, N tabs (Req 6.5). Empty by + * default: no document manager is wired into the shell yet (this + * module's TSDoc). */ + tabs?: TabItem[]; + activeTabId?: string; + onSelectTab?: (id: string) => void; +} + +/** The editor area (Req 6.1, 6.5): a `TabBar` over a placeholder + * `EditorView` (design.md §8.3 — the real editor is a later task). */ +export function EditorArea(props: EditorAreaProps): ReactNode { + const theme = useTheme(); + const focusRef = useFocusTracking("editorFocus"); + const tabs = props.tabs ?? []; + + return ( + + {tabs.length > 0 ? ( + + ) : null} + + + {tabs.length > 0 ? "" : "No editor open."} + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Panel */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link Panel}. */ +export interface PanelProps { + slotRegistry: SlotRegistry; + visible: boolean; + height: number; +} + +/** The bottom panel (Req 6.1, 6.2, 6.4): one tab per `panel.tab` + * registration. */ +export function Panel(props: PanelProps): ReactNode { + const theme = useTheme(); + const views = useSlotViews(props.slotRegistry, "panel.tab"); + const focusRef = useFocusTracking("panelFocus"); + const [activeTabId, setActiveTabId] = useState(undefined); + + if (!props.visible) return null; + + const activeId = activeTabId && views.some((v) => v.id === activeTabId) ? activeTabId : views[0]?.id; + const active = views.find((v) => v.id === activeId); + + if (active && active.lazy && !active.component) { + props.slotRegistry.requestActivation("panel.tab", active.id); + } + + const tabs: TabItem[] = views.map((v) => ({ id: v.id, label: v.title ?? v.id })); + + return ( + + {tabs.length > 0 ? : null} + {active?.component ? : null} + + ); +} + +/* ------------------------------------------------------------------ */ +/* StatusBar */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link StatusBar}. */ +export interface StatusBarProps { + slotRegistry: SlotRegistry; +} + +/** The status bar (Req 6.1, 6.2): every `statusBar.item`, sorted by side + * and priority (design.md §8.2). */ +export function StatusBar(props: StatusBarProps): ReactNode { + const theme = useTheme(); + const items = useStatusBarItems(props.slotRegistry); + const left = items.filter((i) => (i.statusBar?.side ?? "left") === "left"); + const right = items.filter((i) => (i.statusBar?.side ?? "left") === "right"); + + function renderItem(item: SlotViewEntry): ReactNode { + if (item.component) return ; + return ( + + {item.title ?? item.id} + + ); + } + + return ( + + {left.map(renderItem)} + {right.map(renderItem)} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Shell */ +/* ------------------------------------------------------------------ */ + +/** Props for {@link Shell}. */ +export interface ShellProps { + slotRegistry: SlotRegistry; + layoutState: LayoutStateService; + /** Registers a `workbench.view.` command per known sidebar pair (Req + * 6.2: "the `workbench.view.` command switches to the same-id + * sidebar view"), kept in sync as pairs come and go. Optional — a caller + * with no command registry wired yet (e.g. an isolated component test) + * simply gets activity-bar-click switching without the command. */ + commands?: CommandRegistry; + editorTabs?: TabItem[]; + activeEditorTabId?: string; + onSelectEditorTab?: (id: string) => void; +} + +/** The UI shell (Req 6.1-6.5, design.md §8.1): the top-level VS Code-style + * layout. `ThemeProvider`/`ContextFocusTracker` wrap this from the outside + * (this module's TSDoc) — `Shell` itself only needs the slot registry and + * layout state. */ +export function Shell(props: ShellProps): ReactNode { + const [layout, updateLayout] = useLayoutState(props.layoutState); + const pairs = useSidebarPairs(props.slotRegistry); + + const selectSidebarView = useCallback( + (id: string) => { + if (layout.activeView === id) { + // Clicking the already-active item toggles the sidebar shut, VS + // Code-style, rather than doing nothing. + updateLayout({ sidebarVisible: !layout.sidebarVisible }); + return; + } + updateLayout({ activeView: id, sidebarVisible: true }); + }, + [layout.activeView, layout.sidebarVisible, updateLayout], + ); + + // Req 6.2: a `workbench.view.` command per known pair, added/removed + // as pairs come and go (an extension activating late, or unregistering). + useEffect(() => { + if (!props.commands) return undefined; + const commands = props.commands; + const disposables: Disposable[] = pairs.map((pair) => + commands.register(`workbench.view.${pair.id}`, () => selectSidebarView(pair.id)), + ); + return () => { + for (const disposable of disposables) disposable.dispose(); + }; + }, [props.commands, pairs, selectSidebarView]); + + return ( + + + + + + + + + + ); +} diff --git a/packages/core/src/ui/slotRegistry.test.ts b/packages/core/src/ui/slotRegistry.test.ts new file mode 100644 index 0000000..0e56c0f --- /dev/null +++ b/packages/core/src/ui/slotRegistry.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test } from "bun:test"; +import type { HostError } from "../host/errors"; +import { createHostLog } from "../host/errors"; +import type { PendingViewContribution } from "../host/registration"; +import { createSlotRegistry } from "./slotRegistry"; + +/** Records every `log.append` call, in order (matches + * `commands/registry.test.ts`'s own recording-log pattern). */ +function createRecordingLog() { + const log = createHostLog(); + return log; +} + +function warnings(log: ReturnType): HostError[] { + return log + .entries() + .filter((e) => e.level === "warning") + .map((e) => e.error); +} + +const noopComponent = () => undefined; + +describe("createSlotRegistry — registerView/getViews (Req 6.2, 6.3)", () => { + test("register/dispose symmetry: a disposed view is gone from getViews", () => { + const registry = createSlotRegistry(); + const sub = registry.registerView("sidebar.view", "demo.view", noopComponent); + + expect(registry.getViews("sidebar.view").map((v) => v.id)).toEqual(["demo.view"]); + expect(registry.getView("sidebar.view", "demo.view")?.component).toBe(noopComponent); + + sub.dispose(); + expect(registry.getViews("sidebar.view")).toEqual([]); + expect(registry.getView("sidebar.view", "demo.view")).toBeUndefined(); + + // Idempotent. + expect(() => sub.dispose()).not.toThrow(); + }); + + test("dispose is a no-op if a later registration has already superseded it (identity-checked)", () => { + const registry = createSlotRegistry(); + const first = registry.registerView("sidebar.view", "demo.view", noopComponent); + const secondComponent = () => "second"; + registry.registerView("sidebar.view", "demo.view", secondComponent); + + first.dispose(); + + // The second registration is still present — first.dispose() only + // removes itself if it is still the current entry. + expect(registry.getView("sidebar.view", "demo.view")?.component).toBe(secondComponent); + }); + + test("last-wins on a duplicate (slot, id): the newer component wins and a warning is logged", () => { + const log = createRecordingLog(); + const registry = createSlotRegistry({ log }); + const first = () => "first"; + const second = () => "second"; + + registry.registerView("panel.tab", "demo.tab", first); + registry.registerView("panel.tab", "demo.tab", second); + + expect(registry.getView("panel.tab", "demo.tab")?.component).toBe(second); + expect(warnings(log).some((e) => e.message.includes("demo.tab"))).toBe(true); + }); + + test("getViews preserves registration order", () => { + const registry = createSlotRegistry(); + registry.registerView("activityBar.item", "a", noopComponent); + registry.registerView("activityBar.item", "b", noopComponent); + registry.registerView("activityBar.item", "c", noopComponent); + + expect(registry.getViews("activityBar.item").map((v) => v.id)).toEqual(["a", "b", "c"]); + }); + + test("registerView accepts optional title/icon metadata", () => { + const registry = createSlotRegistry(); + registry.registerView("sidebar.view", "demo.view", noopComponent, { + title: "Demo", + icon: "★", + }); + + const entry = registry.getView("sidebar.view", "demo.view"); + expect(entry?.title).toBe("Demo"); + expect(entry?.icon).toBe("★"); + }); +}); + +describe("createSlotRegistry — onDidChange (Req 6.2, 6.3)", () => { + test("fires with the changed slot on register and on dispose", () => { + const registry = createSlotRegistry(); + const events: string[] = []; + registry.onDidChange((slot) => events.push(slot)); + + const sub = registry.registerView("panel.tab", "demo.tab", noopComponent); + expect(events).toEqual(["panel.tab"]); + + sub.dispose(); + expect(events).toEqual(["panel.tab", "panel.tab"]); + }); + + test("a disposed listener does not fire again; other listeners are unaffected", () => { + const registry = createSlotRegistry(); + const events: string[] = []; + const sub = registry.onDidChange(() => events.push("first")); + registry.onDidChange(() => events.push("second")); + + sub.dispose(); + registry.registerView("panel.tab", "x", noopComponent); + + expect(events).toEqual(["second"]); + }); + + test("a throwing listener does not stop other listeners or propagate", () => { + const registry = createSlotRegistry(); + const events: string[] = []; + registry.onDidChange(() => { + throw new Error("boom"); + }); + registry.onDidChange(() => events.push("still ran")); + + expect(() => registry.registerView("panel.tab", "x", noopComponent)).not.toThrow(); + expect(events).toEqual(["still ran"]); + }); +}); + +describe("createSlotRegistry — lazy views from pendingViews (Req 2.5, 6.2, design.md §8.2)", () => { + function pendingSidebarView(overrides: Partial = {}) { + const pending: PendingViewContribution = { + extensionId: "demo.ext", + view: { id: "demo.view", title: "Demo", slot: "sidebar", icon: "★", ...overrides }, + }; + return pending; + } + + test("a pending sidebar view seeds a lazy sidebar.view entry with no component", () => { + const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()] }); + + const entry = registry.getView("sidebar.view", "demo.view"); + expect(entry?.lazy).toBe(true); + expect(entry?.component).toBeUndefined(); + expect(entry?.extensionId).toBe("demo.ext"); + expect(entry?.title).toBe("Demo"); + expect(entry?.icon).toBe("★"); + }); + + test("a pending sidebar view also synthesizes an activityBar.item entry immediately (Req 6.2)", () => { + const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()] }); + + const item = registry.getView("activityBar.item", "demo.view"); + expect(item?.lazy).toBe(false); + expect(item?.title).toBe("Demo"); + expect(item?.icon).toBe("★"); + }); + + test("a pending panel view seeds a lazy panel.tab entry and no activityBar.item", () => { + const registry = createSlotRegistry({ + pendingViews: [ + { extensionId: "demo.ext", view: { id: "demo.panel", title: "Demo Panel", slot: "panel" } }, + ], + }); + + expect(registry.getView("panel.tab", "demo.panel")?.lazy).toBe(true); + expect(registry.getView("activityBar.item", "demo.panel")).toBeUndefined(); + }); + + test("a real registerView call for the same id resolves the lazy entry (last-wins, no duplicate warning)", () => { + const log = createRecordingLog(); + const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()], log }); + + const realComponent = () => "real"; + registry.registerView("sidebar.view", "demo.view", realComponent); + + const entry = registry.getView("sidebar.view", "demo.view"); + expect(entry?.lazy).toBe(false); + expect(entry?.component).toBe(realComponent); + // Resolving a lazy placeholder is the expected activation flow, not a + // collision — no warning should be logged for it. + expect(warnings(log)).toEqual([]); + }); + + test("requestActivation calls activateExtension for a lazy, unresolved view exactly once until it resolves", async () => { + const calls: string[] = []; + let resolveActivation: () => void = () => {}; + const activation = new Promise((resolve) => { + resolveActivation = resolve; + }); + const registry = createSlotRegistry({ + pendingViews: [pendingSidebarView()], + activateExtension: async (id) => { + calls.push(id); + await activation; + }, + }); + + registry.requestActivation("sidebar.view", "demo.view"); + registry.requestActivation("sidebar.view", "demo.view"); // still in flight — must not double-call + expect(calls).toEqual(["demo.ext"]); + + resolveActivation(); + await activation; + // Let the in-flight promise's .finally() run. + await Promise.resolve(); + await Promise.resolve(); + + registry.registerView("sidebar.view", "demo.view", noopComponent); + registry.requestActivation("sidebar.view", "demo.view"); // now resolved — must not call again + expect(calls).toEqual(["demo.ext"]); + }); + + test("requestActivation never throws and is a no-op with no activateExtension wired", () => { + const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()] }); + expect(() => registry.requestActivation("sidebar.view", "demo.view")).not.toThrow(); + }); + + test("requestActivation is a no-op for an entry that already has a component", () => { + let calls = 0; + const registry = createSlotRegistry({ + activateExtension: async () => { + calls += 1; + }, + }); + registry.registerView("sidebar.view", "demo.view", noopComponent); + registry.requestActivation("sidebar.view", "demo.view"); + expect(calls).toBe(0); + }); + + test("requestActivation is a no-op for an unknown (slot, id)", () => { + const registry = createSlotRegistry(); + expect(() => registry.requestActivation("sidebar.view", "nope")).not.toThrow(); + }); +}); + +describe("createSlotRegistry — activityBar/sidebar pairing (Req 6.2)", () => { + test("listSidebarPairs unions ids from both slots, matched by id", () => { + const registry = createSlotRegistry(); + registry.registerView("activityBar.item", "explorer", noopComponent); + registry.registerView("sidebar.view", "explorer", noopComponent); + registry.registerView("activityBar.item", "search", noopComponent); // no sidebar view yet + + const pairs = registry.listSidebarPairs(); + const byId = new Map(pairs.map((p) => [p.id, p])); + + expect(byId.get("explorer")?.activityItem).toBeDefined(); + expect(byId.get("explorer")?.sidebarView).toBeDefined(); + expect(byId.get("search")?.activityItem).toBeDefined(); + expect(byId.get("search")?.sidebarView).toBeUndefined(); + }); + + test("a pending sidebar view is paired immediately even before activation", () => { + const registry = createSlotRegistry({ + pendingViews: [ + { + extensionId: "demo.ext", + view: { id: "demo.view", title: "Demo", slot: "sidebar" }, + }, + ], + }); + + const pair = registry.listSidebarPairs().find((p) => p.id === "demo.view"); + expect(pair?.activityItem).toBeDefined(); + expect(pair?.sidebarView?.lazy).toBe(true); + }); +}); + +describe("createSlotRegistry — statusBar.item sides/priorities (design.md §8.2)", () => { + test("listStatusBarItems sorts left before right, then by descending priority", () => { + const registry = createSlotRegistry(); + registry.registerView("statusBar.item", "right-low", noopComponent, { + statusBar: { side: "right", priority: 1 }, + }); + registry.registerView("statusBar.item", "left-high", noopComponent, { + statusBar: { side: "left", priority: 10 }, + }); + registry.registerView("statusBar.item", "left-low", noopComponent, { + statusBar: { side: "left", priority: 1 }, + }); + registry.registerView("statusBar.item", "right-high", noopComponent, { + statusBar: { side: "right", priority: 10 }, + }); + + expect(registry.listStatusBarItems().map((e) => e.id)).toEqual([ + "left-high", + "left-low", + "right-high", + "right-low", + ]); + }); + + test("ties break by registration order", () => { + const registry = createSlotRegistry(); + registry.registerView("statusBar.item", "first", noopComponent, { + statusBar: { side: "left", priority: 0 }, + }); + registry.registerView("statusBar.item", "second", noopComponent, { + statusBar: { side: "left", priority: 0 }, + }); + + expect(registry.listStatusBarItems().map((e) => e.id)).toEqual(["first", "second"]); + }); + + test("a plain registerView call with no meta defaults to { side: 'left', priority: 0 }", () => { + const registry = createSlotRegistry(); + registry.registerView("statusBar.item", "plain", noopComponent); + + expect(registry.getView("statusBar.item", "plain")?.statusBar).toEqual({ + side: "left", + priority: 0, + }); + }); +}); diff --git a/packages/core/src/ui/slotRegistry.ts b/packages/core/src/ui/slotRegistry.ts new file mode 100644 index 0000000..03b54a0 --- /dev/null +++ b/packages/core/src/ui/slotRegistry.ts @@ -0,0 +1,425 @@ +/** + * The UI slot registry (Req 6.2, 6.3; design.md §8.2; Task 1.14): an ordered + * map, per {@link SlotId}, of the views registered into it. Backs + * `tecode.ui.registerView` (`api/create.ts` delegates its `registerView` + * straight to {@link SlotRegistry.registerView}) and is what the Shell's + * regions (`ActivityBar`, `Sidebar`, `Panel`, `StatusBar`) subscribe to via + * {@link SlotRegistry.onDidChange} to know when to re-render. + * + * Built with {@link createSlotRegistry} rather than a class, per house + * convention (matches `createCommandRegistry`, `createContextService`). + * + * **Lazy views from manifests** (design.md §4.1's "registerLazy pattern", + * mirrored here from `commands/registry.ts`): a manifest's + * `contributes.views` entries reach this module as + * {@link PendingViewContribution}s (`host/registration.ts`) at construction + * time, before the owning extension has run any code. Each becomes a lazy + * entry — present in `getViews`, carrying the manifest's `title`/`icon` so + * the Shell can render a placeholder immediately, but with no `component` + * until the extension actually activates and calls + * `tecode.ui.registerView(slot, id, Component)` itself, which overrides the + * lazy entry in place (last-wins, exactly like a plain re-registration). + * {@link SlotRegistry.requestActivation} is the hook the Shell calls when a + * lazy view is first needed (selected in the sidebar, or a panel tab + * brought to front) — it activates the owning extension and lets that + * `registerView` call complete the entry. + * + * **`activityBar.item` ↔ `sidebar.view` pairing** (Req 6.2): a `"sidebar"` + * manifest view registers into `sidebar.view` (lazy, per above) AND + * synthesizes an `activityBar.item` entry from the same `id`/`title`/`icon` + * — the activity bar icon can render immediately from static manifest data, + * without waiting for activation, exactly like VS Code's activity bar. + * {@link SlotRegistry.listSidebarPairs} is the enumeration helper the + * `ActivityBar`/`Sidebar` components iterate together. + * + * **`statusBar.item` side/priority**: `tecode.ui.registerView`'s public, + * frozen 3-argument signature (`@tecode/api`'s `UiNamespace.registerView`) + * carries no side/priority — only `tecode.window.setStatusBarItem` does + * (`api/stubs.ts`'s `WindowStub`). {@link SlotRegistry.registerView} accepts + * an optional 4th `meta` argument (host-internal; extra optional trailing + * parameters do not break its assignability to the narrower + * `UiNamespace.registerView` type `create.ts` exposes to extensions) so + * core-internal callers can attach `{ side, priority }` to a `statusBar.item` + * entry; a plain extension call defaults to `{ side: "left", priority: 0 }`. + * {@link SlotRegistry.listStatusBarItems} is the sorted-enumeration helper + * (design.md §8.2: "Status bar items carry `{ side, priority }` and render + * sorted"). + */ + +import type { ComponentType, Disposable, Event, Listener, SlotId } from "@tecode/api"; +import type { PendingViewContribution } from "../host/registration"; +import type { HostError, HostLog } from "../host/errors"; + +/** Which side of the status bar an entry renders on, and its sort priority + * within that side (higher first) — mirrors `@tecode/api`'s + * `StatusBarItem` shape (Req 6.2). */ +export interface StatusBarPlacement { + side: "left" | "right"; + priority: number; +} + +/** Optional host-internal metadata a {@link SlotRegistry.registerView} call + * can attach beyond the frozen `UiNamespace.registerView(slot, id, + * component)` signature (see this module's TSDoc). */ +export interface RegisterViewMeta { + /** Display title — falls back to the pending manifest view's `title`, if + * any, when a real registration does not supply one. */ + title?: string; + /** Icon glyph — same fallback behavior as `title`. */ + icon?: string; + /** Placement for a `statusBar.item` entry (ignored for every other + * slot). Defaults to `{ side: "left", priority: 0 }`. */ + statusBar?: StatusBarPlacement; +} + +/** One entry in a slot's ordered map (design.md §8.2). `component` is + * `undefined` while the entry is `lazy` and its owning extension has not + * yet activated (or has none — a `register()` call always has a + * `component`, so this only ever happens for pending manifest views). */ +export interface SlotViewEntry { + slot: SlotId; + id: string; + component?: ComponentType; + lazy: boolean; + /** The extension that owns this entry, for lazy activation + * (`requestActivation`). `undefined` for a plain runtime `registerView` + * call with no manifest attribution. */ + extensionId?: string; + title?: string; + icon?: string; + statusBar?: StatusBarPlacement; +} + +/** One `{ id, activityItem?, sidebarView? }` pairing (Req 6.2) — + * {@link SlotRegistry.listSidebarPairs}'s element type. Either side can be + * absent: an `activityBar.item` registered with no matching `sidebar.view` + * (or vice versa) still shows up, just with the other half `undefined`. */ +export interface SidebarPair { + id: string; + activityItem?: SlotViewEntry; + sidebarView?: SlotViewEntry; +} + +/** Dependencies for {@link createSlotRegistry}. */ +export interface SlotRegistryDeps { + /** Manifest-declared views, attributed to their owning extension + * (`host/registration.ts`'s `LoadExtensionsResult.pendingViews`), + * consumed once at construction as lazy entries (see this module's + * TSDoc). Defaults to `[]` — a slot registry built ahead of extension + * discovery (e.g. `createTecodeApi`'s own internal default) simply starts + * with no lazy entries. */ + pendingViews?: PendingViewContribution[]; + /** Activate the extension owning a lazy, not-yet-resolved view (Req 2.5, + * this module's TSDoc). Supplied by `host/activation.ts`'s + * `createExtensionHost(...).activateExtension` at the assembly layer — + * see `commands/registry.ts`'s `CommandRegistryDeps.activateExtension` + * for the same wiring pattern. Optional: omitted (as in every unit test + * with no host in the picture), {@link SlotRegistry.requestActivation} + * simply does nothing for a lazy entry. Documented to never throw/reject; + * guarded anyway so a misbehaving implementation can't break this + * module's never-throwing contract. */ + activateExtension?: (extensionId: string) => Promise; + /** Structured log for duplicate-registration warnings and activation + * failures (design.md §14). Defaults to a discarding no-op log so tests + * that do not care about logging need not supply one. */ + log?: HostLog; +} + +/** The slot registry's public surface. */ +export interface SlotRegistry { + /** + * Register `component` as the content for view `id` in `slot` (Req 6.3). + * Last-wins on a duplicate `(slot, id)` — a warning is logged, mirroring + * `commands/registry.ts`'s `storeEntry` — and completes a lazy entry seeded + * from a pending manifest view in place, preserving its `title`/`icon` + * unless `meta` overrides them. Returns a {@link Disposable} that removes + * the entry; idempotent, and a no-op if a later registration has already + * superseded it (identity-checked, same as `storeEntry`). + */ + registerView( + slot: SlotId, + id: string, + component: ComponentType, + meta?: RegisterViewMeta, + ): Disposable; + /** Every entry currently registered in `slot`, in registration order. */ + getViews(slot: SlotId): readonly SlotViewEntry[]; + /** One entry by `(slot, id)`, or `undefined` if nothing is registered + * there. */ + getView(slot: SlotId, id: string): SlotViewEntry | undefined; + /** + * Call when a lazy view is first needed (design.md §8.2: "activating the + * owning extension lazily if needed") — e.g. the Sidebar switching to a + * view, or a Panel tab being brought to front. A no-op, and never + * throws, unless the entry exists, is still `lazy`, has no `component` + * yet, and carries an `extensionId` with {@link SlotRegistryDeps.activateExtension} + * wired in. Fire-and-forget: does not await the activation's completion + * (the entry updates via the normal `registerView` → `onDidChange` path + * once the extension's own `activate(ctx)` calls it). + */ + requestActivation(slot: SlotId, id: string): void; + /** Fires with the {@link SlotId} whose entries changed, on every + * registration, disposal, or lazy-entry resolution. */ + onDidChange: Event; + /** Enumerate every `activityBar.item` ↔ `sidebar.view` pairing by shared + * `id` (Req 6.2) — the union of ids present in either slot. */ + listSidebarPairs(): readonly SidebarPair[]; + /** Every `statusBar.item` entry, sorted by side (`"left"` before + * `"right"`) then by descending priority, ties broken by registration + * order (design.md §8.2). */ + listStatusBarItems(): readonly SlotViewEntry[]; +} + +const ALL_SLOTS: readonly SlotId[] = [ + "activityBar.item", + "sidebar.view", + "panel.tab", + "statusBar.item", + "editor.viewType", +]; + +const DEFAULT_STATUS_BAR_PLACEMENT: StatusBarPlacement = { side: "left", priority: 0 }; + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `commands/registry.ts`'s `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** A discarding {@link HostLog} — the default when no log is injected + * (matches this module's `SlotRegistryDeps.log` TSDoc). */ +function createNoopLog(): HostLog { + return { + append() { + // Intentionally discarded. + }, + entries() { + return []; + }, + }; +} + +/** Build a slot registry (Req 6.2, 6.3, 10.1). */ +export function createSlotRegistry(deps: SlotRegistryDeps = {}): SlotRegistry { + const log = deps.log ?? createNoopLog(); + const activateExtension = deps.activateExtension; + + const slots = new Map>( + ALL_SLOTS.map((slot) => [slot, new Map()]), + ); + const listeners = new Set>(); + // Guards against re-requesting activation for the same still-lazy entry + // on every render (the host's own activateExtension is idempotent, but + // this avoids a redundant call — and its log noise — on every + // `requestActivation` for an entry already in flight). + const activationRequested = new Set(); + + function logSafely(level: "error" | "warning", err: HostError): void { + try { + log.append(level, err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } + } + + function slotKey(slot: SlotId, id: string): string { + return `${slot}${id}`; + } + + function fireChange(slot: SlotId): void { + // Snapshot before iterating: a listener that disposes itself (or + // another listener) mid-dispatch must not perturb this loop (matches + // keymap/context.ts's onDidChange pattern). + for (const listener of Array.from(listeners)) { + try { + listener(slot); + } catch { + // Isolate listener failures — one throwing listener must not stop + // the rest or propagate out of a registration call. + } + } + } + + function storeEntry(slot: SlotId, id: string, entry: SlotViewEntry): Disposable { + const map = slots.get(slot); + // ALL_SLOTS seeds every SlotId up front, so this is unreachable for a + // well-typed caller — guarded defensively rather than asserted, since + // a Disposable must still be handed back either way. + if (!map) { + return { + dispose() { + // No-op: ALL_SLOTS seeds every SlotId at construction, so this + // branch is unreachable for a well-typed caller — nothing was + // ever stored to remove. + }, + }; + } + + const existing = map.get(id); + if (existing && !existing.lazy) { + logSafely("warning", { + extensionId: entry.extensionId, + message: `View re-registered, replacing previous component: ${slot}/${id}`, + }); + } + map.set(id, entry); + activationRequested.delete(slotKey(slot, id)); + fireChange(slot); + + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + if (map.get(id) === entry) { + map.delete(id); + fireChange(slot); + } + }, + }; + } + + function registerView( + slot: SlotId, + id: string, + component: ComponentType, + meta?: RegisterViewMeta, + ): Disposable { + const existing = slots.get(slot)?.get(id); + const entry: SlotViewEntry = { + slot, + id, + component, + lazy: false, + extensionId: existing?.extensionId, + title: meta?.title ?? existing?.title, + icon: meta?.icon ?? existing?.icon, + statusBar: + slot === "statusBar.item" + ? (meta?.statusBar ?? existing?.statusBar ?? DEFAULT_STATUS_BAR_PLACEMENT) + : undefined, + }; + return storeEntry(slot, id, entry); + } + + function getViews(slot: SlotId): readonly SlotViewEntry[] { + return Array.from(slots.get(slot)?.values() ?? []); + } + + function getView(slot: SlotId, id: string): SlotViewEntry | undefined { + return slots.get(slot)?.get(id); + } + + function requestActivation(slot: SlotId, id: string): void { + const entry = slots.get(slot)?.get(id); + if (!entry || !entry.lazy || entry.component || !entry.extensionId || !activateExtension) { + return; + } + const key = slotKey(slot, id); + if (activationRequested.has(key)) return; + activationRequested.add(key); + try { + const result = activateExtension(entry.extensionId); + // activateExtension is documented to never throw/reject; guard + // anyway (matches commands/registry.ts's execute()) so a + // misbehaving implementation can't break this never-throwing + // contract, and so a rejected activation lets a later + // requestActivation try again instead of being permanently stuck. + Promise.resolve(result) + .catch((cause: unknown) => { + logSafely("error", { + extensionId: entry.extensionId, + message: `activateExtension("${entry.extensionId}") threw: ${describeError(cause)}`, + }); + }) + .finally(() => { + activationRequested.delete(key); + }); + } catch (cause) { + activationRequested.delete(key); + logSafely("error", { + extensionId: entry.extensionId, + message: `activateExtension("${entry.extensionId}") threw: ${describeError(cause)}`, + }); + } + } + + function onDidChange(listener: Listener): Disposable { + listeners.add(listener); + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + listeners.delete(listener); + }, + }; + } + + function listSidebarPairs(): readonly SidebarPair[] { + const activityItems = slots.get("activityBar.item") ?? new Map(); + const sidebarViews = slots.get("sidebar.view") ?? new Map(); + const ids = new Set([...activityItems.keys(), ...sidebarViews.keys()]); + return Array.from(ids).map((id) => ({ + id, + activityItem: activityItems.get(id), + sidebarView: sidebarViews.get(id), + })); + } + + function listStatusBarItems(): readonly SlotViewEntry[] { + const entries = getViews("statusBar.item"); + return entries + .map((entry, index) => ({ entry, index })) + .sort((a, b) => { + const placementA = a.entry.statusBar ?? DEFAULT_STATUS_BAR_PLACEMENT; + const placementB = b.entry.statusBar ?? DEFAULT_STATUS_BAR_PLACEMENT; + if (placementA.side !== placementB.side) { + return placementA.side === "left" ? -1 : 1; + } + if (placementA.priority !== placementB.priority) { + return placementB.priority - placementA.priority; + } + return a.index - b.index; + }) + .map(({ entry }) => entry); + } + + // Seed lazy entries from manifest-declared views (this module's TSDoc). + for (const pending of deps.pendingViews ?? []) { + const targetSlot: SlotId = pending.view.slot === "sidebar" ? "sidebar.view" : "panel.tab"; + storeEntry(targetSlot, pending.view.id, { + slot: targetSlot, + id: pending.view.id, + lazy: true, + extensionId: pending.extensionId, + title: pending.view.title, + icon: pending.view.icon, + }); + if (pending.view.slot === "sidebar") { + storeEntry("activityBar.item", pending.view.id, { + slot: "activityBar.item", + id: pending.view.id, + lazy: false, + extensionId: pending.extensionId, + title: pending.view.title, + icon: pending.view.icon, + }); + } + } + + return { + registerView, + getViews, + getView, + requestActivation, + onDidChange, + listSidebarPairs, + listStatusBarItems, + }; +} diff --git a/packages/core/src/ui/theme.tsx b/packages/core/src/ui/theme.tsx new file mode 100644 index 0000000..69f982f --- /dev/null +++ b/packages/core/src/ui/theme.tsx @@ -0,0 +1,82 @@ +/** + * `ThemeProvider`/`useTheme()` (Req 7.3, design.md §8.1, §9; Task 1.14): + * supplies a {@link ResolvedTheme} to the component tree via React context. + * Every OpenTUI/React component in `core/ui` reads colors through + * {@link useTheme} rather than hard-coding literals (Req 7.3). + * + * **This task's scope**: a real theme loader (`ThemeRegistry`, quantization + * for 256-color terminals, `theme.select` live preview) is design.md §9's + * job for a later task (2.6) — `ThemeProvider` here defaults to the same + * hardcoded base palette `api/stubs.ts`'s `createBaseTheme` already builds + * for `tecode.themes.current` (Task 1.13), reused rather than duplicated so + * the two never drift (tasks.md's Task 1.14: "`ThemeProvider` (hardcoded + * base palette for now)"). + * + * **Two different `useTheme`s, deliberately** (do not confuse them): + * `tecode.ui.useTheme()` (`@tecode/api`'s `UiNamespace.useTheme`, + * `api/create.ts`) is a plain synchronous getter — extension code calls it + * from anywhere, including outside a React render (the contract test + * suite's fixture extension calls it from a plain `async activate(ctx)`), + * so it cannot be a real hook obeying the rules of hooks. This module's + * {@link useTheme} *is* a real hook (`useContext` under the hood) meant for + * `core`'s own OpenTUI/React components (`Shell`, `Sidebar`, ...) — it must + * only ever be called during render. The two are wired to the same + * underlying theme value at the assembly layer, not merged into one + * function. + */ + +import { createContext, useContext, type ReactNode } from "react"; +import { RGBA } from "@opentui/core"; +import type { ResolvedTheme, RGB, Style } from "@tecode/api"; +import { createBaseTheme } from "../api/stubs"; + +/** Convert a theme {@link RGB} (0-255 per channel, as `@tecode/api` models + * it) to the `RGBA` OpenTUI's renderables accept for `backgroundColor`/ + * `borderColor`/`fg`/`bg` props. Fully opaque (`a = 255`) — the theme format + * has no alpha channel (Req 7.2). */ +export function toColorInput(rgb: RGB): RGBA { + return RGBA.fromInts(rgb.r, rgb.g, rgb.b, 255); +} + +/** Resolve a syntax capture {@link Style} to the `fg`/`bg` OpenTUI props for + * a ``/`` node. `bold`/`italic`/`underline` are not yet mapped + * (no consumer needs them until the real `EditorView` — a later task — + * renders syntax-highlighted spans); documented here as the single spot + * that will grow that mapping. */ +export function styleToTextColors(style: Style | undefined): { fg?: RGBA; bg?: RGBA } { + if (!style) return {}; + return { + fg: style.foreground ? toColorInput(style.foreground) : undefined, + bg: style.background ? toColorInput(style.background) : undefined, + }; +} + +const ThemeContext = createContext(createBaseTheme()); + +/** Props for {@link ThemeProvider}. */ +export interface ThemeProviderProps { + /** Overrides the default hardcoded base palette (this module's TSDoc) — + * a later task's real theme loader supplies the active `ResolvedTheme` + * here. Defaults to {@link createBaseTheme}'s result. */ + theme?: ResolvedTheme; + children?: ReactNode; +} + +/** Supplies a {@link ResolvedTheme} to the tree beneath it via React + * context (design.md §8.1's component tree: `` wraps + * everything else). */ +export function ThemeProvider(props: ThemeProviderProps): ReactNode { + const theme = props.theme ?? createBaseTheme(); + return {props.children}; +} + +/** + * Read the active {@link ResolvedTheme} from context (Req 7.3). A real + * React hook — only call this during render, inside a component beneath + * {@link ThemeProvider} (or accept the default base-palette fallback + * outside one, e.g. in isolated component tests). See this module's TSDoc + * for why `tecode.ui.useTheme()` is a *different* function, not this one. + */ +export function useTheme(): ResolvedTheme { + return useContext(ThemeContext); +} diff --git a/tsconfig.json b/tsconfig.json index b5d3b74..5f73f08 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "target": "ESNext", "lib": ["ESNext"], "types": ["bun"], + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, From 592b99fb140cacb97b63523a48f538d0d3df74bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 02:06:45 +0000 Subject: [PATCH 2/2] Fix CodeRabbit findings on the UI shell: hook-safety, layout-state race, registry warnings - RegisteredView now renders the registered component as a real JSX element instead of calling it as a plain function, so its hooks get their own fiber; Sidebar/Panel/ActivityBar key their RegisteredView by view id so switching views cleanly unmounts/remounts instead of reusing state across differently-registered components. - layoutState.ts tracks fields update() touches before the initial load settles and re-applies them after load()'s merge, so an update() landing mid-load can no longer be clobbered by the persisted file. - shell.tsx's slot-registry subscription hooks re-render once immediately after subscribing, closing the window between a component's render and its effect subscribing where a registry change could otherwise be missed. - The synthesized activityBar.item placeholder is now lazy, so the extension's later real registerView call no longer logs a spurious "View re-registered" warning. - Raised the @opentui/core and @opentui/react minimums to ^0.1.107 (the installed version) since ./test-utils was added after 0.1.30 on the registry; synced bun.lock. - Nitpicks: Tabs' selection-sync effect also depends on tabs.length, the focus.test.tsx no-tracker test asserts against an independent context service, RegisteredView's prop-passing test no longer requires an exact render count, slotRegistry.test.ts drops the redundant recording-log helper and gains a rejected-activation retry test, and listSidebarPairs's map fallbacks are explicitly typed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- bun.lock | 4 +- packages/core/package.json | 4 +- packages/core/src/ui/components.test.tsx | 68 +++++++++++++++++++++- packages/core/src/ui/components.tsx | 28 ++++++--- packages/core/src/ui/focus.test.tsx | 6 ++ packages/core/src/ui/layoutState.test.ts | 53 +++++++++++++++++ packages/core/src/ui/layoutState.ts | 33 ++++++++++- packages/core/src/ui/shell.tsx | 40 +++++++++++-- packages/core/src/ui/slotRegistry.test.ts | 71 +++++++++++++++++++---- packages/core/src/ui/slotRegistry.ts | 15 ++++- 10 files changed, 289 insertions(+), 33 deletions(-) diff --git a/bun.lock b/bun.lock index 087d4ac..de65a37 100644 --- a/bun.lock +++ b/bun.lock @@ -38,8 +38,8 @@ "name": "@tecode/core", "version": "0.1.0", "dependencies": { - "@opentui/core": "^0.1.30", - "@opentui/react": "^0.1.30", + "@opentui/core": "^0.1.107", + "@opentui/react": "^0.1.107", "@tecode/api": "workspace:*", "react": "^19.0.0", }, diff --git a/packages/core/package.json b/packages/core/package.json index 7c4106a..0b20fa3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,8 +7,8 @@ "types": "src/index.ts", "dependencies": { "@tecode/api": "workspace:*", - "@opentui/core": "^0.1.30", - "@opentui/react": "^0.1.30", + "@opentui/core": "^0.1.107", + "@opentui/react": "^0.1.107", "react": "^19.0.0" }, "devDependencies": { diff --git a/packages/core/src/ui/components.test.tsx b/packages/core/src/ui/components.test.tsx index c53dcd8..4b69dbe 100644 --- a/packages/core/src/ui/components.test.tsx +++ b/packages/core/src/ui/components.test.tsx @@ -6,7 +6,9 @@ */ import { describe, expect, test } from "bun:test"; +import { act, useEffect, useState } from "react"; import { testRender } from "@opentui/react/test-utils"; +import type { ComponentType } from "@tecode/api"; import { List, RegisteredView, Tabs, Tree, type ListItem, type TabItem, type TreeNode } from "./components"; describe("List (tecode.ui.List)", () => { @@ -86,6 +88,70 @@ describe("RegisteredView (bridging @tecode/api's ComponentType)", () => { ); await renderOnce(); expect(captureCharFrame()).toContain("hello from extension"); - expect(seen).toEqual([{ label: "hello from extension" }]); + // Rendering as a real element (rather than calling `component` inline — + // see components.tsx's TSDoc) means React itself decides how many + // times the underlying component body runs; asserting "at least once, + // with the right props" is what actually matters here, not an exact + // render count React's own scheduling is free to change. + expect(seen.length).toBeGreaterThan(0); + expect(seen[0]).toEqual({ label: "hello from extension" }); + }); + + test("swaps cleanly between registered views with different hook counts at the same position, with no state bleed", async () => { + // Reproduces the exact scenario components.tsx's TSDoc warns about: + // calling `component(props)` directly inside RegisteredView's own + // render body would attach `ComponentA`'s `useState` to RegisteredView's + // *own* hook state, so swapping to a zero-hook `ComponentB` at the same + // JSX position would throw "Rendered fewer hooks than expected". + // Rendering as `` instead gives each registered + // component its own fiber, so the swap must render fine both ways and + // never leak state across the swap. + let setActive: ((id: "a" | "b") => void) | undefined; + + const ComponentA: ComponentType = () => { + const [count, setCount] = useState(0); + // Mutates its own state once after mount so a later remount is + // provably starting fresh (0, then bumped to 1) rather than + // resuming some stale, shared fiber's state. + useEffect(() => { + setCount(1); + }, []); + return {`A:${count}`}; + }; + const ComponentB: ComponentType = () => B (no hooks); + + function Harness() { + const [active, setActiveState] = useState<"a" | "b">("a"); + setActive = setActiveState; + return ; + } + + const { renderOnce, captureCharFrame } = await testRender(, { width: 40, height: 3 }); + await act(async () => { + await renderOnce(); + }); + await act(async () => { + await renderOnce(); + }); // flushes ComponentA's mount effect (count 0 -> 1) + expect(captureCharFrame()).toContain("A:1"); + + // Swap to the zero-hook component — must not throw. + expect(() => { + act(() => setActive?.("b")); + }).not.toThrow(); + await act(async () => { + await renderOnce(); + }); + expect(captureCharFrame()).toContain("B (no hooks)"); + + // Swap back to A: a fresh mount runs its `useState(0)` + mount effect + // from scratch, landing on the same "A:1" a first mount would — not + // some other value a shared/stale fiber might have carried over, + // proving no state bled through the swap. + act(() => setActive?.("a")); + await act(async () => { + await renderOnce(); + }); + expect(captureCharFrame()).toContain("A:1"); }); }); diff --git a/packages/core/src/ui/components.tsx b/packages/core/src/ui/components.tsx index a4c2948..5564a5d 100644 --- a/packages/core/src/ui/components.tsx +++ b/packages/core/src/ui/components.tsx @@ -37,18 +37,24 @@ import { toColorInput, useTheme } from "./theme"; * component (a plain `(props) => unknown` function, not a JSX component * type) as part of a real React tree (design.md §12's "bridge `@tecode/ * api`'s React-free `ComponentType` to real React component types in - * core"). Calling `component(props)` directly inside this component's own - * render body — rather than trying to use `component` as a JSX tag, which - * its `unknown` return type is not statically compatible with — means any - * hooks `component` itself calls (e.g. `tecode.ui.useTheme()`) still run in - * a stable position during this component's render, exactly as if it had - * been inlined. + * core"). `component` is cast to a real React function-component type and + * rendered as a JSX element (``) — never called directly as + * a plain function. Calling it directly would attach any hooks it calls + * (e.g. `tecode.ui.useTheme()`) to *this* component's own hook state + * instead of a fiber of its own; swapping in a differently-registered + * `component` with a different hook count at the same call site would then + * throw ("Rendered fewer hooks than expected") and, when the hook counts + * happen to match, leak state across unrelated views. Rendering it as a + * real element gives every registered component its own fiber, keyed by + * the caller (see `shell.tsx`'s `key={view.id}` usage) so switching views + * cleanly unmounts the old one and mounts the new one. */ export function RegisteredView(props: { component: ComponentType; viewProps?: Record; }): ReactNode { - return props.component(props.viewProps ?? {}) as ReactNode; + const Component = props.component as unknown as (p: Record) => ReactNode; + return ; } /* ------------------------------------------------------------------ */ @@ -260,7 +266,13 @@ export function Tabs(rawProps: Record): ReactNode { // option — so `activeId` is applied via a ref effect rather than a prop. useEffect(() => { if (selectedIndex >= 0) ref.current?.setSelectedIndex(selectedIndex); - }, [selectedIndex]); + // `tabs.length` is included alongside `selectedIndex`: when the tab + // list itself changes (a tab added/removed) but `selectedIndex` + // happens to come out the same number, this effect would otherwise + // skip re-applying it — even though ``'s own options just + // changed underneath the same numeric index — leaving the wrong tab + // visually selected. + }, [selectedIndex, tabs.length]); return ( { }); test("used outside a ContextFocusTracker, it attaches without throwing and reports nothing", async () => { + // An independent context service, never passed anywhere near the + // rendered tree below — proves focusing outside a ContextFocusTracker + // truly reports to nothing, not merely that some in-tree context + // happens to stay untouched. + const context = createContextService(); let captured: BoxRenderable | null = null; const { renderOnce } = await testRender( (captured = node)} />, { width: 10, @@ -57,6 +62,7 @@ describe("ContextFocusTracker / useFocusTracking (Req 4.6)", () => { await renderOnce(); expect(() => captured!.focus()).not.toThrow(); + expect(context.get("testFocus")).toBeUndefined(); }); test("the ref callback tolerates being called with null (React's unmount cleanup)", async () => { diff --git a/packages/core/src/ui/layoutState.test.ts b/packages/core/src/ui/layoutState.test.ts index da0768d..4dc6675 100644 --- a/packages/core/src/ui/layoutState.test.ts +++ b/packages/core/src/ui/layoutState.test.ts @@ -276,4 +276,57 @@ describe("createLayoutStateService — update()/debounce/flush (Req 6.4, design. expect(second.get().sidebarWidth).toBe(77); expect(second.get().activeView).toBe("search"); }); + + test("an update() that lands mid-load wins over the persisted value for that field, without losing other persisted fields", async () => { + const stored: LayoutState = { + sidebarVisible: false, + sidebarWidth: 42, + panelVisible: true, + panelHeight: 12, + activeView: "explorer", + }; + let releaseRead: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseRead = resolve; + }); + const writes: { path: string; data: string }[] = []; + const fs: LayoutStateFs = { + async readFile() { + await gate; // Blocks until the test calls releaseRead() below. + return JSON.stringify(stored); + }, + async mkdir() { + // No-op. + }, + async writeFile(p, data) { + writes.push({ path: p, data }); + }, + }; + const log = createHostLog(); + const { sink } = createRecordingSink(); + const service = createLayoutStateService({ log, sink, path: "/state.json", fs }); + + // update() arrives while `load()` is still awaiting `fs.readFile` — + // the exact race this test guards against. + service.update({ sidebarWidth: 99 }); + expect(service.get().sidebarWidth).toBe(99); + + releaseRead?.(); + await service.ready; + + // The local update must survive load()'s merge... + expect(service.get().sidebarWidth).toBe(99); + // ...while every other field still came from the persisted file. + expect(service.get().panelVisible).toBe(true); + expect(service.get().panelHeight).toBe(12); + expect(service.get().activeView).toBe("explorer"); + expect(service.get().sidebarVisible).toBe(false); + + await service.flush(); + expect(writes).toHaveLength(1); + const written = JSON.parse(writes[0]!.data) as LayoutState; + expect(written.sidebarWidth).toBe(99); + expect(written.panelVisible).toBe(true); + expect(written.activeView).toBe("explorer"); + }); }); diff --git a/packages/core/src/ui/layoutState.ts b/packages/core/src/ui/layoutState.ts index cf64ce2..258584d 100644 --- a/packages/core/src/ui/layoutState.ts +++ b/packages/core/src/ui/layoutState.ts @@ -199,6 +199,16 @@ export function createLayoutStateService(deps: LayoutStateServiceDeps): LayoutSt // Serialized write chain (this module's TSDoc) — every scheduled save // (debounced or flushed) is appended here so writes never overlap. let saveChain: Promise = Promise.resolve(); + // True once `load()` has settled (successfully or not). While false, any + // field an `update()` call touches is also recorded in `localOverrides` + // below — guarding against the race where `update()` lands while `load()` + // is still awaiting `fs.readFile`: without this, `load()`'s merge (which + // must still apply the *persisted* value for every field the caller + // hasn't locally touched) would blindly overwrite that field with + // whatever `state.json` says, clobbering the just-arrived local update + // even though it happened after the read started. + let loaded = false; + let localOverrides: Partial = {}; function logSafely(level: "error" | "warning", err: HostError): void { try { @@ -242,6 +252,13 @@ export function createLayoutStateService(deps: LayoutStateServiceDeps): LayoutSt function update(partial: Partial): void { state = { ...state, ...partial }; + if (!loaded) { + // Still mid-`load()` (or not yet started) — remember exactly which + // fields this update touched so `load()`'s merge below can exclude + // them from the persisted values, whether or not it awaits again + // before returning. + localOverrides = { ...localOverrides, ...partial }; + } if (pendingTimer !== undefined) { try { timer.cancel(pendingTimer); @@ -289,7 +306,8 @@ export function createLayoutStateService(deps: LayoutStateServiceDeps): LayoutSt logSafely("error", { message, path }); notifySafely({ message, path }); } - return; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + loaded = true; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + return; } let parsed: unknown; @@ -299,10 +317,19 @@ export function createLayoutStateService(deps: LayoutStateServiceDeps): LayoutSt const message = `Failed to parse layout state (${path}): ${describeError(cause)}`; logSafely("error", { message, path }); notifySafely({ message, path }); - return; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + loaded = true; // Keep DEFAULT_LAYOUT_STATE (last-good policy). + return; } - state = coerceLayoutState(parsed, state); + // Coerce against `state` as the fallback (so any local override already + // applied to a field the file doesn't set, or sets invalidly, survives + // as before) and then re-apply `localOverrides` on top — those are + // exactly the fields an in-flight `update()` touched while this read + // was pending, which must win over the persisted value even when the + // file does validly set that same field (this function's TSDoc / this + // module's race-condition TSDoc above `localOverrides`'s declaration). + state = { ...coerceLayoutState(parsed, state), ...localOverrides }; + loaded = true; } return { diff --git a/packages/core/src/ui/shell.tsx b/packages/core/src/ui/shell.tsx index 8d4df27..dfe9b5c 100644 --- a/packages/core/src/ui/shell.tsx +++ b/packages/core/src/ui/shell.tsx @@ -58,13 +58,33 @@ import { toColorInput, useTheme } from "./theme"; /** Re-renders the calling component whenever `slotRegistry` reports a * change to `slot`, and returns that slot's current views (design.md - * §8.2's "shell regions subscribe and re-render on registration"). */ + * §8.2's "shell regions subscribe and re-render on registration"). + * + * **The subscribe-after-render race, and how this closes it**: the render + * that reads `slotRegistry.getViews(slot)` below happens *before* this + * component's `useEffect` runs and actually subscribes (React always + * commits/paints before running effects) — a registration landing in that + * gap fires `onDidChange` to no listener yet and is lost, leaving this + * component on a stale snapshot until some *later*, unrelated change + * happens to trigger a re-render. The fix is the unconditional + * `forceRender()` right after subscribing below: it does not compare + * "did anything change" (this hook has no cheap way to know, short of the + * full `useSyncExternalStore` machinery, which risks its own subtle + * infinite-render bugs if the snapshot isn't cached correctly — not worth + * it for what is otherwise a one-line seam) — it just re-renders once, + * unconditionally, right after the subscription is live, so this render's + * `getViews(slot)` call is always guaranteed to be fresh as of a point in + * time no earlier than "subscribed". */ function useSlotViews(slotRegistry: SlotRegistry, slot: SlotId): readonly SlotViewEntry[] { const [, forceRender] = useReducer((n: number) => n + 1, 0); useEffect(() => { const sub = slotRegistry.onDidChange((changed) => { if (changed === slot) forceRender(); }); + // Close the subscribe-after-render race (this function's TSDoc): + // re-render now that the subscription is live, in case a change landed + // in the gap between this render and this effect running. + forceRender(); return () => sub.dispose(); }, [slotRegistry, slot]); return slotRegistry.getViews(slot); @@ -79,6 +99,8 @@ function useSidebarPairs(slotRegistry: SlotRegistry): readonly SidebarPair[] { const sub = slotRegistry.onDidChange((changed) => { if (changed === "activityBar.item" || changed === "sidebar.view") forceRender(); }); + // Closes the subscribe-after-render race — see useSlotViews's TSDoc. + forceRender(); return () => sub.dispose(); }, [slotRegistry]); return slotRegistry.listSidebarPairs(); @@ -92,6 +114,8 @@ function useStatusBarItems(slotRegistry: SlotRegistry): readonly SlotViewEntry[] const sub = slotRegistry.onDidChange((changed) => { if (changed === "statusBar.item") forceRender(); }); + // Closes the subscribe-after-render race — see useSlotViews's TSDoc. + forceRender(); return () => sub.dispose(); }, [slotRegistry]); return slotRegistry.listStatusBarItems(); @@ -154,7 +178,7 @@ export function ActivityBar(props: ActivityBarProps): ReactNode { if (item?.component) { return ( props.onSelectView(pair.id)}> - + ); } @@ -223,7 +247,12 @@ export function Sidebar(props: SidebarProps): ReactNode { {view.title} ) : null} {view?.component ? ( - + // Keyed by view.id (not just position) so switching the active + // sidebar view — same conditional slot, different registered + // component — unmounts the old view's fiber instead of reusing it + // (components.tsx's RegisteredView TSDoc: this is what keeps hook + // state from leaking across views). + ) : ( {view ? "Activating…" : ""} @@ -313,7 +342,10 @@ export function Panel(props: PanelProps): ReactNode { borderColor={toColorInput(theme.colors["panel.border"])} > {tabs.length > 0 ? : null} - {active?.component ? : null} + {/* Keyed by active.id for the same reason as Sidebar above — switching + * the active panel tab must remount rather than reuse the previous + * tab's fiber. */} + {active?.component ? : null} ); } diff --git a/packages/core/src/ui/slotRegistry.test.ts b/packages/core/src/ui/slotRegistry.test.ts index 0e56c0f..ebbe5f3 100644 --- a/packages/core/src/ui/slotRegistry.test.ts +++ b/packages/core/src/ui/slotRegistry.test.ts @@ -4,13 +4,6 @@ import { createHostLog } from "../host/errors"; import type { PendingViewContribution } from "../host/registration"; import { createSlotRegistry } from "./slotRegistry"; -/** Records every `log.append` call, in order (matches - * `commands/registry.test.ts`'s own recording-log pattern). */ -function createRecordingLog() { - const log = createHostLog(); - return log; -} - function warnings(log: ReturnType): HostError[] { return log .entries() @@ -50,7 +43,7 @@ describe("createSlotRegistry — registerView/getViews (Req 6.2, 6.3)", () => { }); test("last-wins on a duplicate (slot, id): the newer component wins and a warning is logged", () => { - const log = createRecordingLog(); + const log = createHostLog(); const registry = createSlotRegistry({ log }); const first = () => "first"; const second = () => "second"; @@ -146,7 +139,12 @@ describe("createSlotRegistry — lazy views from pendingViews (Req 2.5, 6.2, des const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()] }); const item = registry.getView("activityBar.item", "demo.view"); - expect(item?.lazy).toBe(false); + // `lazy: true` — a placeholder, not a real registration (this entry's + // `component` is always undefined at this point); ActivityBar renders + // on `component` presence, not `lazy` (shell.tsx), so this has no + // rendering effect, but it matters for `storeEntry`'s duplicate- + // registration warning: see the "no spurious warning" test below. + expect(item?.lazy).toBe(true); expect(item?.title).toBe("Demo"); expect(item?.icon).toBe("★"); }); @@ -163,7 +161,7 @@ describe("createSlotRegistry — lazy views from pendingViews (Req 2.5, 6.2, des }); test("a real registerView call for the same id resolves the lazy entry (last-wins, no duplicate warning)", () => { - const log = createRecordingLog(); + const log = createHostLog(); const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()], log }); const realComponent = () => "real"; @@ -177,6 +175,25 @@ describe("createSlotRegistry — lazy views from pendingViews (Req 2.5, 6.2, des expect(warnings(log)).toEqual([]); }); + test("a real registerView call for the synthesized activityBar.item placeholder logs no spurious warning", () => { + // Regression test: the synthesized activityBar.item placeholder is + // `lazy: true` (not a real registration), so an extension's later, real + // `registerView("activityBar.item", id, ...)` call must resolve it + // exactly like any other lazy entry — not trip `storeEntry`'s + // duplicate-registration warning, which only fires against a non-lazy + // existing entry. + const log = createHostLog(); + const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()], log }); + + const realComponent = () => "real icon"; + registry.registerView("activityBar.item", "demo.view", realComponent); + + const entry = registry.getView("activityBar.item", "demo.view"); + expect(entry?.lazy).toBe(false); + expect(entry?.component).toBe(realComponent); + expect(warnings(log)).toEqual([]); + }); + test("requestActivation calls activateExtension for a lazy, unresolved view exactly once until it resolves", async () => { const calls: string[] = []; let resolveActivation: () => void = () => {}; @@ -206,6 +223,40 @@ describe("createSlotRegistry — lazy views from pendingViews (Req 2.5, 6.2, des expect(calls).toEqual(["demo.ext"]); }); + test("a rejected activation is logged, and a later requestActivation for the same view retries", async () => { + const log = createHostLog(); + const calls: string[] = []; + let attempt = 0; + const registry = createSlotRegistry({ + pendingViews: [pendingSidebarView()], + log, + activateExtension: async (id) => { + calls.push(id); + attempt += 1; + if (attempt === 1) throw new Error("activation boom"); + }, + }); + + registry.requestActivation("sidebar.view", "demo.view"); + expect(calls).toEqual(["demo.ext"]); + + // Let the rejected promise's .catch()/.finally() run. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect( + log + .entries() + .some((e) => e.level === "error" && e.error.message.includes("activation boom")), + ).toBe(true); + + // The failed attempt must not leave the view stuck — a later + // requestActivation for the same still-unresolved view retries. + registry.requestActivation("sidebar.view", "demo.view"); + expect(calls).toEqual(["demo.ext", "demo.ext"]); + }); + test("requestActivation never throws and is a no-op with no activateExtension wired", () => { const registry = createSlotRegistry({ pendingViews: [pendingSidebarView()] }); expect(() => registry.requestActivation("sidebar.view", "demo.view")).not.toThrow(); diff --git a/packages/core/src/ui/slotRegistry.ts b/packages/core/src/ui/slotRegistry.ts index 03b54a0..617ecbf 100644 --- a/packages/core/src/ui/slotRegistry.ts +++ b/packages/core/src/ui/slotRegistry.ts @@ -362,8 +362,8 @@ export function createSlotRegistry(deps: SlotRegistryDeps = {}): SlotRegistry { } function listSidebarPairs(): readonly SidebarPair[] { - const activityItems = slots.get("activityBar.item") ?? new Map(); - const sidebarViews = slots.get("sidebar.view") ?? new Map(); + const activityItems = slots.get("activityBar.item") ?? new Map(); + const sidebarViews = slots.get("sidebar.view") ?? new Map(); const ids = new Set([...activityItems.keys(), ...sidebarViews.keys()]); return Array.from(ids).map((id) => ({ id, @@ -402,10 +402,19 @@ export function createSlotRegistry(deps: SlotRegistryDeps = {}): SlotRegistry { icon: pending.view.icon, }); if (pending.view.slot === "sidebar") { + // `lazy: true`, not `false`: this is a synthesized placeholder (Req + // 6.2's activityBar.item/sidebar.view pairing, this module's TSDoc), + // not a real registration. `storeEntry`'s duplicate-registration + // warning only fires against a non-lazy existing entry, so marking + // this one `lazy: false` would make the extension's later, real + // `registerView("activityBar.item", id, ...)` call log a spurious + // "View re-registered" warning for a view that was never actually + // registered twice. `ActivityBar` (shell.tsx) renders on `component` + // presence, not `lazy`, so this has no rendering effect. storeEntry("activityBar.item", pending.view.id, { slot: "activityBar.item", id: pending.view.id, - lazy: false, + lazy: true, extensionId: pending.extensionId, title: pending.view.title, icon: pending.view.icon,