diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 5bc46d92a..19f419271 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -89,6 +89,14 @@ import { VIRTUALIZATION_REACT_CONFIG, VIRTUALIZATION_VANILLA_DIFF, } from '../docs/Virtualization/constants'; +import { + VUE_API_MULTI_FILE_DIFF, + VUE_API_PATCH_DIFF, + VUE_API_SLOTS, + VUE_API_SSR, + VUE_API_VIRTUALIZER, + VUE_API_WORKER_POOL, +} from '../docs/VueAPI/constants'; import { WORKER_POOL_API_REFERENCE, WORKER_POOL_ARCHITECTURE_ASCII, @@ -117,7 +125,7 @@ import { renderMDX } from '@/lib/mdx'; const docsTitle = 'Diffs docs'; const docsDescription = - 'Documentation for @pierre/diffs: React and vanilla APIs, virtualization, theming, token hooks, the worker pool, and SSR hydration.'; + 'Documentation for @pierre/diffs: React, Vue, and vanilla APIs, virtualization, theming, token hooks, the worker pool, and SSR hydration.'; // Next.js replaces (does not deep-merge) nested metadata objects like // `openGraph` and `twitter` from parent segments. Re-declare `images` here @@ -149,6 +157,7 @@ export default function DocsPage() { + @@ -324,6 +333,36 @@ async function VanillaAPISection() { return {content}; } +async function VueAPISection() { + const [ + vueAPIMultiFileDiff, + vueAPIPatchDiff, + vueAPISlots, + vueAPIVirtualizer, + vueAPIWorkerPool, + vueAPISsr, + ] = await Promise.all([ + preloadFile(VUE_API_MULTI_FILE_DIFF), + preloadFile(VUE_API_PATCH_DIFF), + preloadFile(VUE_API_SLOTS), + preloadFile(VUE_API_VIRTUALIZER), + preloadFile(VUE_API_WORKER_POOL), + preloadFile(VUE_API_SSR), + ]); + const content = await renderMDX({ + filePath: '(diffs)/docs/VueAPI/content.mdx', + scope: { + vueAPIMultiFileDiff, + vueAPIPatchDiff, + vueAPISlots, + vueAPISsr, + vueAPIVirtualizer, + vueAPIWorkerPool, + }, + }); + return {content}; +} + async function VirtualizationSection() { const [ reactVirtualizerBasic, diff --git a/apps/docs/app/(diffs)/docs/Installation/content.mdx b/apps/docs/app/(diffs)/docs/Installation/content.mdx index 037838c91..e9efc9b68 100644 --- a/apps/docs/app/(diffs)/docs/Installation/content.mdx +++ b/apps/docs/app/(diffs)/docs/Installation/content.mdx @@ -14,5 +14,6 @@ The package provides several entry points for different use cases: | ---------------------- | ------------------------------------------------------------------------------------------------------------ | | `@pierre/diffs` | [Vanilla JS components](#vanilla-js-api) and [utility functions](#utilities) for parsing and rendering diffs | | `@pierre/diffs/react` | [React components](#react-api) for rendering diffs with full interactivity | +| `@pierre/diffs/vue` | [Vue components](#vue-api) for rendering diffs with full interactivity | | `@pierre/diffs/ssr` | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting | | `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) for offloading syntax highlighting to background threads | diff --git a/apps/docs/app/(diffs)/docs/Overview/content.mdx b/apps/docs/app/(diffs)/docs/Overview/content.mdx index 1ca3402a5..31fb355ac 100644 --- a/apps/docs/app/(diffs)/docs/Overview/content.mdx +++ b/apps/docs/app/(diffs)/docs/Overview/content.mdx @@ -19,17 +19,17 @@ We have an opinionated stance in our architecture: **browsers are rather efficient at rendering raw HTML**. We lean into this by having all the lower level APIs purely rendering strings (the raw HTML) that are then consumed by higher-order components and utilities. This gives us great performance and -flexibility to support popular libraries like React as well as provide great -tools if you want to stick to vanilla JavaScript and HTML. The higher-order -components render all this out into Shadow DOM and CSS grid layout. +flexibility to support popular libraries like React and Vue as well as provide +great tools if you want to stick to vanilla JavaScript and HTML. The +higher-order components render all this out into Shadow DOM and CSS grid layout. Generally speaking, you're probably going to want to use the higher level components since they provide an easy-to-use API that you can get started with -rather quickly. We currently only have components for vanilla JavaScript and -React, but will add more if there's demand. +rather quickly. Diffs includes runtime lanes for React, Vue, and vanilla +JavaScript. For this overview, we'll talk about the vanilla JavaScript components for now -but there are React equivalents for all of these. +but there are React and Vue equivalents for all of these. ## Rendering Diffs @@ -43,6 +43,7 @@ There are two ways to render diffs with `FileDiff`: 2. Consume a patch file You can see examples of these approaches below, in both JavaScript and React. +For Vue usage, see the [Vue API](#vue-api). }> Inputs used for pre-rendering must exactly match what's rendered in the client - component. We recommend spreading the entire result object into your File or - Diff component to ensure the client receives the same inputs that were used to - generate the pre-rendered HTML. + component. Keep the client props aligned with the preload result so the client + receives the same inputs that were used to generate the pre-rendered HTML. #### Server Component diff --git a/apps/docs/app/(diffs)/docs/Virtualization/content.mdx b/apps/docs/app/(diffs)/docs/Virtualization/content.mdx index 232650cfc..d8b945728 100644 --- a/apps/docs/app/(diffs)/docs/Virtualization/content.mdx +++ b/apps/docs/app/(diffs)/docs/Virtualization/content.mdx @@ -27,11 +27,10 @@ window). Directly inside that container, add a content wrapper that holds all diff/file instances and any other content you render. The virtualizer uses this wrapper to track content size changes. -Inside that scroll container, render the `VirtualizedFile` and -`VirtualizedFileDiff` components. In React, this is handled automatically by the -built-in `Virtualizer` context. In vanilla JS, you manage this explicitly by -creating a `Virtualizer` instance and wiring it to `VirtualizedFile` / -`VirtualizedFileDiff` instead of the traditional APIs. +Inside that scroll container, render file and diff components. In React and Vue, +this is handled automatically by the built-in `Virtualizer` provider. In vanilla +JS, you manage this explicitly by creating a `Virtualizer` instance and wiring +it to `VirtualizedFile` / `VirtualizedFileDiff` instead of the traditional APIs. }> These APIs are still early and are subject to change. We may merge related @@ -39,14 +38,13 @@ creating a `Virtualizer` instance and wiring it to `VirtualizedFile` / components before shipping, so please share feedback as you test these APIs. -### React +### React and Vue In React, wrap your diff/file components in `Virtualizer` from -`@pierre/diffs/react`. The `Virtualizer` component is your scroll container. -Currently, the React wrapper does not support window scrolling unless you -orchestrate your own provider via `VirtualizerContext.Provider` (from -`@pierre/diffs/react`) and pass a manually created `Virtualizer` instance (from -`@pierre/diffs`). +`@pierre/diffs/react`. In Vue, use `Virtualizer` from `@pierre/diffs/vue`. The +`Virtualizer` component is your scroll container. Currently, framework wrappers +do not support window scrolling unless you orchestrate your own provider and +pass a manually created `Virtualizer` instance from `@pierre/diffs`. @@ -61,6 +59,9 @@ You can tune virtualization behavior with the `config` prop. - `className` / `style`: applied to the outer scroll root - `contentClassName` / `contentStyle`: applied to the inner content wrapper +In Vue templates, use `class` / `style` on the outer component and +`content-class` / `content-style` for the inner content wrapper. + ### Vanilla JavaScript In vanilla JS, create a `Virtualizer` instance and pass it into diff --git a/apps/docs/app/(diffs)/docs/VueAPI/constants.ts b/apps/docs/app/(diffs)/docs/VueAPI/constants.ts new file mode 100644 index 000000000..11a846968 --- /dev/null +++ b/apps/docs/app/(diffs)/docs/VueAPI/constants.ts @@ -0,0 +1,149 @@ +import type { PreloadFileOptions } from '@pierre/diffs/ssr'; + +export const VUE_API_MULTI_FILE_DIFF: PreloadFileOptions = { + file: { + name: 'multi-file-diff.vue', + contents: ` + +`, + }, +}; + +export const VUE_API_PATCH_DIFF: PreloadFileOptions = { + file: { + name: 'patch-diff.vue', + contents: ` + +`, + }, +}; + +export const VUE_API_SLOTS: PreloadFileOptions = { + file: { + name: 'annotated-diff.vue', + contents: ` + +`, + }, +}; + +export const VUE_API_VIRTUALIZER: PreloadFileOptions = { + file: { + name: 'virtualized-diffs.vue', + contents: ` + +`, + }, +}; + +export const VUE_API_WORKER_POOL: PreloadFileOptions = { + file: { + name: 'worker-pool.vue', + contents: ` + +`, + }, +}; + +export const VUE_API_SSR: PreloadFileOptions = { + file: { + name: 'hydrated-file.vue', + contents: ` + +`, + }, +}; diff --git a/apps/docs/app/(diffs)/docs/VueAPI/content.mdx b/apps/docs/app/(diffs)/docs/VueAPI/content.mdx new file mode 100644 index 000000000..585f35211 --- /dev/null +++ b/apps/docs/app/(diffs)/docs/VueAPI/content.mdx @@ -0,0 +1,55 @@ +## Vue API + +}> + Import Vue components from `@pierre/diffs/vue`. + + +The Vue API mirrors the React component lane while using Vue props and slots. +Use `File` for a single file, `FileDiff` for parsed diff metadata, +`MultiFileDiff` for two file versions, `PatchDiff` for one patch string, and +`UnresolvedFile` for merge-conflict files. + +### Rendering Diffs + +Use `MultiFileDiff` when you have both file versions and want Diffs to parse the +change for you. + + + +Use `PatchDiff` when you have a single-file unified patch string. + + + +### Slots + +Slots replace React render props. Header slots receive the file or diff object, +annotation slots receive the annotation, and gutter utility slots receive a +`getHoveredLine` function. + + + +`UnresolvedFile` also exposes +`#merge-conflict-utility="{ action, context, getInstance }"`. Call +`context.resolveConflict('current' | 'incoming' | 'both')` from your slot UI to +resolve a conflict. + +### Virtualization + +Wrap large lists in `Virtualizer`. Nested `File` and `FileDiff` components use +virtualized renderer instances automatically. + + + +### Worker Pool + +Use `WorkerPoolProvider` when syntax highlighting should run off the main +thread. Nested Vue Diffs components inherit the shared worker pool. + + + +### SSR + +Server preload helpers return `prerenderedHTML`. Pass it to the matching Vue +component so the client can hydrate the existing Shadow DOM. + + diff --git a/apps/docs/app/(diffs)/docs/WorkerPool/content.mdx b/apps/docs/app/(diffs)/docs/WorkerPool/content.mdx index af476172f..795a716e8 100644 --- a/apps/docs/app/(diffs)/docs/WorkerPool/content.mdx +++ b/apps/docs/app/(diffs)/docs/WorkerPool/content.mdx @@ -16,8 +16,8 @@ we've provided some APIs to run all syntax highlighting in worker threads. The main thread will still attempt to render plain text synchronously and then apply the syntax highlighting when we get a response from the worker threads. -Basic usage differs a bit depending on if you're using React or Vanilla JS APIs, -so continue reading for more details. +Basic usage differs a bit depending on if you're using React, Vue, or Vanilla JS +APIs, so continue reading for more details. ### Setup @@ -113,10 +113,11 @@ reference it directly: With your `workerFactory` function created, you can integrate it with our provided APIs. In React, you'll want to pass this `workerFactory` to a -`` so all components can inherit the pool -automatically. If you're using the Vanilla JS APIs, we provide a -`getOrCreateWorkerPoolSingleton` helper that ensures a single pool instance that -you can then manually pass to all your File/FileDiff instances. +``. In Vue, use ``. In both +framework lanes, nested components inherit the pool automatically. If you're +using the Vanilla JS APIs, we provide a `getOrCreateWorkerPoolSingleton` helper +that ensures a single pool instance that you can then manually pass to all your +File/FileDiff instances. } variant="warning"> When using the worker pool, the `theme`, `lineDiffType`, @@ -159,6 +160,12 @@ hook to access the pool manager and call `setRenderOptions()`. +#### Vue + +Wrap your component tree with `WorkerPoolProvider` from `@pierre/diffs/vue`. +Nested Vue Diffs components automatically use the shared worker pool. See +[Vue API](#vue-api) for an example. + #### Vanilla JS Use `getOrCreateWorkerPoolSingleton` to spin up a singleton worker pool. Then @@ -181,7 +188,7 @@ To change themes or other render options dynamically, call The worker pool can cache rendered AST results to avoid redundant highlighting work. When a file or diff has a `cacheKey`, subsequent requests with the same key will return cached results immediately instead of reprocessing through a -worker. This works automatically for both React and Vanilla JS APIs. +worker. This works automatically for React, Vue, and Vanilla JS APIs. }> Caching is enabled per-file/diff by setting a `cacheKey` property. Files and @@ -195,8 +202,8 @@ worker. This works automatically for both React and Vanilla JS APIs. ### API Reference These methods are exposed for advanced use cases. In most scenarios, you should -use the `WorkerPoolContextProvider` for React or pass the pool instance via the -`workerPool` option for Vanilla JS rather than calling these methods directly. +use the framework providers or pass the pool instance via the `workerPool` +option for Vanilla JS rather than calling these methods directly. diff --git a/apps/docs/lib/product-config.ts b/apps/docs/lib/product-config.ts index 3825d04bf..f8afbea45 100644 --- a/apps/docs/lib/product-config.ts +++ b/apps/docs/lib/product-config.ts @@ -23,9 +23,9 @@ export const PRODUCTS: Record = { name: 'Diffs', tagline: 'A diff rendering library', description: - "@pierre/diffs is an open source diff and code rendering library. It's built on Shiki for syntax highlighting and theming, is super customizable, and comes packed with features.", + "@pierre/diffs is an open source diff and code rendering library. It's built on Shiki for syntax highlighting and theming, includes React, Vue, and vanilla APIs, and comes packed with features.", llmsDescription: - 'An open source diff and code rendering library for the web. Built on Shiki for syntax highlighting, with React and vanilla JS APIs, virtualization, SSR support, and extensive theming.', + 'An open source diff and code rendering library for the web. Built on Shiki for syntax highlighting, with React, Vue, and vanilla JS APIs, virtualization, SSR support, and extensive theming.', basePath: '', docsPath: '/docs', themePath: '/theme', diff --git a/apps/docs/scripts/generate-llms-txt.ts b/apps/docs/scripts/generate-llms-txt.ts index 364949c2a..2eee5c34f 100644 --- a/apps/docs/scripts/generate-llms-txt.ts +++ b/apps/docs/scripts/generate-llms-txt.ts @@ -41,6 +41,7 @@ const DIFFS_SECTIONS = [ 'Installation', 'CoreTypes', 'ReactAPI', + 'VueAPI', 'VanillaAPI', 'Virtualization', 'CustomHunkSeparators', @@ -79,6 +80,8 @@ const SECTION_DESCRIPTIONS: Record> = { 'FileContents, FileDiffMetadata, and creating diffs from files or patches', ReactAPI: 'MultiFileDiff, PatchDiff, FileDiff, File components and shared props', + VueAPI: + 'Vue File, FileDiff, MultiFileDiff, PatchDiff, UnresolvedFile, Virtualizer, WorkerPoolProvider, slots, and SSR props', VanillaAPI: 'FileDiff and File classes, props, deprecated vanilla custom hunk separators, and low-level renderers', Virtualization: 'Virtual scrolling for large diffs and files', @@ -180,6 +183,7 @@ function extToLang(filename: string): string { '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx', + '.vue': 'vue', '.css': 'css', '.json': 'json', '.sh': 'bash', diff --git a/bun.lock b/bun.lock index 8811217ce..10b74e310 100644 --- a/bun.lock +++ b/bun.lock @@ -104,7 +104,7 @@ }, "packages/diffs": { "name": "@pierre/diffs", - "version": "1.1.19", + "version": "1.1.20", "dependencies": { "@pierre/theme": "catalog:", "@shikijs/transformers": "^3.0.0", @@ -116,9 +116,12 @@ "devDependencies": { "@arethetypeswrong/core": "catalog:", "@types/hast": "catalog:", + "@types/jsdom": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vue/server-renderer": "catalog:", "autoprefixer": "catalog:", + "jsdom": "catalog:", "lightningcss": "catalog:", "postcss": "catalog:", "postcss-calc": "catalog:", @@ -127,11 +130,18 @@ "react-dom": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", + "vue": "catalog:", }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", + "vue": "^3.5.0", }, + "optionalPeers": [ + "react", + "react-dom", + "vue", + ], }, "packages/path-store": { "name": "@pierre/path-store", @@ -290,7 +300,9 @@ "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260128.1", "@vitejs/plugin-react": "5.0.3", + "@vitejs/plugin-vue": "6.0.6", "@vscode/web-custom-data": "0.6.3", + "@vue/server-renderer": "3.5.33", "autoprefixer": "10.4.22", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.14", @@ -338,6 +350,7 @@ "typescript": "5.9.2", "unist-util-visit": "5.0.0", "vite": "npm:rolldown-vite@7.1.12", + "vue": "3.5.33", "zod": "4.1.11", }, "packages": { @@ -891,6 +904,24 @@ "@vscode/web-custom-data": ["@vscode/web-custom-data@0.6.3", "", {}, "sha512-3pDUAPGVkra1KjR2L5m3b7BgzLTlWdep4ijsRoqeLcrp+e7cJcyjnae8IkAdF/xS6Zo3B1YZSmIBIhRAEYBIog=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.33", "", { "dependencies": { "@vue/compiler-core": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.33", "@vue/compiler-dom": "3.5.33", "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.10", "source-map-js": "^1.2.1" } }, "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.33", "", { "dependencies": { "@vue/shared": "3.5.33" } }, "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/runtime-core": "3.5.33", "@vue/shared": "3.5.33", "csstype": "^3.2.3" } }, "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.33", "", { "dependencies": { "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "vue": "3.5.33" } }, "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw=="], + + "@vue/shared": ["@vue/shared@3.5.33", "", {}, "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1147,7 +1178,7 @@ "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1945,7 +1976,7 @@ "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.24.7", "", {}, "sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1995,6 +2026,8 @@ "vite": ["rolldown-vite@7.1.12", "", { "dependencies": { "@oxc-project/runtime": "0.90.0", "fdir": "^6.5.0", "lightningcss": "^1.30.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.39", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-JREtUS+Lpa3s5Ha3ajf2F4LMS4BFxlVjpGz0k0ZR8rV3ZO3tzk5hukqyi9yRBcrvnTUg/BEForyCDahALFYAZA=="], + "vue": ["vue@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", "@vue/runtime-dom": "3.5.33", "@vue/server-renderer": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -2061,6 +2094,8 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@mdx-js/mdx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "@mdx-js/mdx/unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], "@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], @@ -2079,7 +2114,15 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/jsdom/undici-types": ["undici-types@7.24.7", "", {}, "sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ=="], + "@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@vue/compiler-sfc/postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -2097,6 +2140,8 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "estree-util-build-jsx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], diff --git a/package.json b/package.json index 0b891242e..ead1c6f59 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@typescript/native-preview": "7.0.0-dev.20260128.1", "@vscode/web-custom-data": "0.6.3", "@vitejs/plugin-react": "5.0.3", + "@vitejs/plugin-vue": "6.0.6", + "@vue/server-renderer": "3.5.33", "autoprefixer": "10.4.22", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.14", @@ -93,6 +95,7 @@ "typescript": "5.9.2", "unist-util-visit": "5.0.0", "vite": "npm:rolldown-vite@7.1.12", + "vue": "3.5.33", "zod": "4.1.11" } }, diff --git a/packages/diffs/README.md b/packages/diffs/README.md index 8dd99ee35..cdc0b9f68 100644 --- a/packages/diffs/README.md +++ b/packages/diffs/README.md @@ -4,7 +4,7 @@ [Shiki](https://shiki.style/). It's super customizable and packed with the features you need. Made with love by [The Pierre Computer Company](https://pierre.computer). Available as vanilla -JavaScript and React components. +JavaScript, React, and Vue components. **View examples and read documentation on [Diffs.com](https://diffs.com).** @@ -27,6 +27,29 @@ JavaScript and React components. bun i @pierre/diffs ``` +## Vue + +```vue + + + +``` + ## Development Technically you can use the package manager of your choice, but we use diff --git a/packages/diffs/package.json b/packages/diffs/package.json index ee804dca5..b25a2c02d 100644 --- a/packages/diffs/package.json +++ b/packages/diffs/package.json @@ -19,6 +19,9 @@ "react": [ "dist/react/index.d.ts" ], + "vue": [ + "dist/vue/index.d.ts" + ], "ssr": [ "dist/ssr/index.d.ts" ], @@ -36,6 +39,10 @@ "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/vue/index.js" + }, "./ssr": { "types": "./dist/ssr/index.d.ts", "import": "./dist/ssr/index.js" @@ -72,9 +79,12 @@ "devDependencies": { "@arethetypeswrong/core": "catalog:", "@types/hast": "catalog:", + "@types/jsdom": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vue/server-renderer": "catalog:", "autoprefixer": "catalog:", + "jsdom": "catalog:", "lightningcss": "catalog:", "postcss": "catalog:", "postcss-calc": "catalog:", @@ -82,10 +92,23 @@ "react": "catalog:", "react-dom": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vue": "catalog:" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", - "react-dom": "^18.3.1 || ^19.0.0" + "react-dom": "^18.3.1 || ^19.0.0", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } } } diff --git a/packages/diffs/src/vue/File.ts b/packages/diffs/src/vue/File.ts new file mode 100644 index 000000000..a4f522186 --- /dev/null +++ b/packages/diffs/src/vue/File.ts @@ -0,0 +1,274 @@ +import { + defineComponent, + h, + markRaw, + type PropType, + toRaw, + type VNodeChild, + type VNodeRef, +} from 'vue'; + +import { File as FileClass, type FileOptions } from '../components/File'; +import { VirtualizedFile } from '../components/VirtualizedFile'; +import { type Virtualizer } from '../components/Virtualizer'; +import type { + GetHoveredLineResult, + SelectedLineRange, +} from '../managers/InteractionManager'; +import type { + FileContents, + LineAnnotation, + VirtualFileMetrics, +} from '../types'; +import { areOptionsEqual } from '../utils/areOptionsEqual'; +import type { WorkerPoolManager } from '../worker'; +import { VirtualizerInjectionKey, WorkerPoolInjectionKey } from './context'; +import { + DIFFS_TAG_NAME, + hasExistingPrerenderedContent, + hasSlot, + noopRender, + raw, + renderFileSlots, + renderPrerenderedTemplate, +} from './utils'; + +type FileInstance = + | FileClass + | VirtualizedFile; + +export interface FileProps { + disableWorkerPool?: boolean; + file: FileContents; + lineAnnotations?: LineAnnotation[]; + metrics?: VirtualFileMetrics; + options?: FileOptions; + prerenderedHtml?: string; + prerenderedHTML?: string; + selectedLines?: SelectedLineRange | null; +} + +export function mergeFileOptions({ + hasCustomHeader, + hasGutterUtility, + options, +}: { + hasCustomHeader: boolean; + hasGutterUtility: boolean; + options: FileOptions | undefined; +}): FileOptions | undefined { + if (hasCustomHeader || hasGutterUtility) { + return { + ...options, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, + renderGutterUtility: hasGutterUtility ? noopRender : undefined, + }; + } + + return options; +} + +export const File = defineComponent({ + name: 'File', + inheritAttrs: false, + inject: { + virtualizer: { default: undefined, from: VirtualizerInjectionKey }, + workerPool: { default: undefined, from: WorkerPoolInjectionKey }, + }, + props: { + disableWorkerPool: { + default: false, + type: Boolean, + }, + file: { + required: true, + type: Object as PropType, + }, + lineAnnotations: { + required: false, + type: Array as PropType[]>, + }, + metrics: { + required: false, + type: Object as PropType, + }, + options: { + required: false, + type: Object as PropType>, + }, + prerenderedHTML: { + required: false, + type: String, + }, + prerenderedHtml: { + required: false, + type: String, + }, + selectedLines: { + default: undefined, + type: Object as PropType, + }, + }, + data(): { + hostElement: HTMLElement | null; + instance: FileInstance | null; + slotState: { hasCustomHeader: boolean }; + shouldRenderPrerenderedTemplate: boolean; + } { + return { + hostElement: null, + instance: null, + slotState: markRaw({ hasCustomHeader: false }), + shouldRenderPrerenderedTemplate: + (this.prerenderedHtml ?? this.prerenderedHTML) != null, + }; + }, + computed: { + hasGutterUtility(): boolean { + return hasSlot(this.$slots, 'gutter-utility'); + }, + rawFile(): FileContents { + return raw(this.file); + }, + rawLineAnnotations(): LineAnnotation[] | undefined { + return this.lineAnnotations == null + ? undefined + : raw(this.lineAnnotations); + }, + rawMetrics(): VirtualFileMetrics | undefined { + return this.metrics == null ? undefined : raw(this.metrics); + }, + rawOptions(): FileOptions | undefined { + return this.options == null ? undefined : raw(this.options); + }, + resolvedPrerenderedHTML(): string | undefined { + return this.prerenderedHtml ?? this.prerenderedHTML; + }, + rawSelectedLines(): SelectedLineRange | null | undefined { + return this.selectedLines == null + ? this.selectedLines + : toRaw(this.selectedLines); + }, + }, + mounted(): void { + this.mountOrHydrate(); + this.shouldRenderPrerenderedTemplate = false; + }, + updated(): void { + this.renderInstance(); + }, + beforeUnmount(): void { + this.instance?.cleanUp(); + this.instance = null; + }, + methods: { + createInstance(): FileInstance { + if (this.instance != null) { + return this.instance as FileInstance; + } + + const virtualizer = toRaw(this.virtualizer) as Virtualizer | undefined; + const workerPool = this.disableWorkerPool + ? undefined + : (toRaw(this.workerPool) as WorkerPoolManager | undefined); + this.instance = markRaw( + virtualizer == null + ? new FileClass(this.getResolvedOptions(), workerPool, true) + : new VirtualizedFile( + this.getResolvedOptions(), + virtualizer, + this.rawMetrics, + workerPool, + true + ) + ) as FileInstance; + return this.instance as FileInstance; + }, + getHoveredLine(): GetHoveredLineResult<'file'> | undefined { + return this.instance?.getHoveredLine(); + }, + mountOrHydrate(): void { + const hostElement = this.hostElement; + if (hostElement == null) { + return; + } + + const instance = this.createInstance(); + if ( + this.resolvedPrerenderedHTML != null && + hasExistingPrerenderedContent(hostElement) + ) { + instance.hydrate({ + file: this.rawFile, + fileContainer: hostElement, + lineAnnotations: this.rawLineAnnotations, + prerenderedHTML: this.resolvedPrerenderedHTML, + }); + } else { + instance.render({ + file: this.rawFile, + fileContainer: hostElement, + lineAnnotations: this.rawLineAnnotations, + }); + } + + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + renderInstance(): void { + const instance = this.instance; + if (instance == null) { + return; + } + + const forceRender = !areOptionsEqual( + instance.options, + this.getResolvedOptions() + ); + instance.setOptions(this.getResolvedOptions()); + instance.render({ + file: this.rawFile, + forceRender, + lineAnnotations: this.rawLineAnnotations, + }); + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + setHostElement(node: Element | null): void { + this.hostElement = node instanceof HTMLElement ? node : null; + }, + getResolvedOptions(): FileOptions | undefined { + return mergeFileOptions({ + hasCustomHeader: this.slotState.hasCustomHeader, + hasGutterUtility: this.hasGutterUtility, + options: this.rawOptions, + }); + }, + }, + render(): VNodeChild { + const children = this.shouldRenderPrerenderedTemplate + ? renderPrerenderedTemplate(this.resolvedPrerenderedHTML) + : []; + const renderedSlots = renderFileSlots({ + file: this.rawFile, + getHoveredLine: this.getHoveredLine, + lineAnnotations: this.rawLineAnnotations, + slots: this.$slots, + }); + this.slotState.hasCustomHeader = renderedSlots.hasCustomHeader; + children.push(...renderedSlots.children); + + return h( + DIFFS_TAG_NAME, + { + ...this.$attrs, + 'data-allow-mismatch': + this.resolvedPrerenderedHTML == null ? undefined : 'children', + ref: this.setHostElement as VNodeRef, + }, + children + ); + }, +}); diff --git a/packages/diffs/src/vue/FileDiff.ts b/packages/diffs/src/vue/FileDiff.ts new file mode 100644 index 000000000..d288849f6 --- /dev/null +++ b/packages/diffs/src/vue/FileDiff.ts @@ -0,0 +1,280 @@ +import { + defineComponent, + h, + markRaw, + type PropType, + toRaw, + type VNodeChild, + type VNodeRef, +} from 'vue'; + +import { + FileDiff as FileDiffClass, + type FileDiffOptions, +} from '../components/FileDiff'; +import { VirtualizedFileDiff } from '../components/VirtualizedFileDiff'; +import type { Virtualizer } from '../components/Virtualizer'; +import type { + GetHoveredLineResult, + SelectedLineRange, +} from '../managers/InteractionManager'; +import type { + DiffLineAnnotation, + FileDiffMetadata, + VirtualFileMetrics, +} from '../types'; +import { areOptionsEqual } from '../utils/areOptionsEqual'; +import type { WorkerPoolManager } from '../worker'; +import { VirtualizerInjectionKey, WorkerPoolInjectionKey } from './context'; +import { + DIFFS_TAG_NAME, + hasExistingPrerenderedContent, + hasSlot, + noopRender, + raw, + renderDiffSlots, + renderPrerenderedTemplate, +} from './utils'; + +type FileDiffInstance = + | FileDiffClass + | VirtualizedFileDiff; + +export interface DiffBaseProps { + disableWorkerPool?: boolean; + lineAnnotations?: DiffLineAnnotation[]; + metrics?: VirtualFileMetrics; + options?: FileDiffOptions; + prerenderedHtml?: string; + prerenderedHTML?: string; + selectedLines?: SelectedLineRange | null; +} + +export interface FileDiffProps extends DiffBaseProps { + fileDiff: FileDiffMetadata; +} + +export function mergeFileDiffOptions({ + hasCustomHeader, + hasGutterUtility, + options, +}: { + hasCustomHeader: boolean; + hasGutterUtility: boolean; + options: FileDiffOptions | undefined; +}): FileDiffOptions | undefined { + if (hasCustomHeader || hasGutterUtility) { + return { + ...options, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, + renderGutterUtility: hasGutterUtility ? noopRender : undefined, + }; + } + + return options; +} + +export const FileDiff = defineComponent({ + name: 'FileDiff', + inheritAttrs: false, + inject: { + virtualizer: { default: undefined, from: VirtualizerInjectionKey }, + workerPool: { default: undefined, from: WorkerPoolInjectionKey }, + }, + props: { + disableWorkerPool: { + default: false, + type: Boolean, + }, + fileDiff: { + required: true, + type: Object as PropType, + }, + lineAnnotations: { + required: false, + type: Array as PropType[]>, + }, + metrics: { + required: false, + type: Object as PropType, + }, + options: { + required: false, + type: Object as PropType>, + }, + prerenderedHTML: { + required: false, + type: String, + }, + prerenderedHtml: { + required: false, + type: String, + }, + selectedLines: { + default: undefined, + type: Object as PropType, + }, + }, + data(): { + hostElement: HTMLElement | null; + instance: FileDiffInstance | null; + slotState: { hasCustomHeader: boolean }; + shouldRenderPrerenderedTemplate: boolean; + } { + return { + hostElement: null, + instance: null, + slotState: markRaw({ hasCustomHeader: false }), + shouldRenderPrerenderedTemplate: + (this.prerenderedHtml ?? this.prerenderedHTML) != null, + }; + }, + computed: { + hasGutterUtility(): boolean { + return hasSlot(this.$slots, 'gutter-utility'); + }, + rawFileDiff(): FileDiffMetadata { + return raw(this.fileDiff); + }, + rawLineAnnotations(): DiffLineAnnotation[] | undefined { + return this.lineAnnotations == null + ? undefined + : raw(this.lineAnnotations); + }, + rawMetrics(): VirtualFileMetrics | undefined { + return this.metrics == null ? undefined : raw(this.metrics); + }, + rawOptions(): FileDiffOptions | undefined { + return this.options == null ? undefined : raw(this.options); + }, + resolvedPrerenderedHTML(): string | undefined { + return this.prerenderedHtml ?? this.prerenderedHTML; + }, + rawSelectedLines(): SelectedLineRange | null | undefined { + return this.selectedLines == null + ? this.selectedLines + : toRaw(this.selectedLines); + }, + }, + mounted(): void { + this.mountOrHydrate(); + this.shouldRenderPrerenderedTemplate = false; + }, + updated(): void { + this.renderInstance(); + }, + beforeUnmount(): void { + this.instance?.cleanUp(); + this.instance = null; + }, + methods: { + createInstance(): FileDiffInstance { + if (this.instance != null) { + return this.instance as FileDiffInstance; + } + + const virtualizer = toRaw(this.virtualizer) as Virtualizer | undefined; + const workerPool = this.disableWorkerPool + ? undefined + : (toRaw(this.workerPool) as WorkerPoolManager | undefined); + this.instance = markRaw( + virtualizer == null + ? new FileDiffClass(this.getResolvedOptions(), workerPool, true) + : new VirtualizedFileDiff( + this.getResolvedOptions(), + virtualizer, + this.rawMetrics, + workerPool, + true + ) + ) as FileDiffInstance; + return this.instance as FileDiffInstance; + }, + getHoveredLine(): GetHoveredLineResult<'diff'> | undefined { + return this.instance?.getHoveredLine(); + }, + mountOrHydrate(): void { + const hostElement = this.hostElement; + if (hostElement == null) { + return; + } + + const instance = this.createInstance(); + if ( + this.resolvedPrerenderedHTML != null && + hasExistingPrerenderedContent(hostElement) + ) { + instance.hydrate({ + fileContainer: hostElement, + fileDiff: this.rawFileDiff, + lineAnnotations: this.rawLineAnnotations, + prerenderedHTML: this.resolvedPrerenderedHTML, + }); + } else { + instance.render({ + fileContainer: hostElement, + fileDiff: this.rawFileDiff, + lineAnnotations: this.rawLineAnnotations, + }); + } + + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + renderInstance(): void { + const instance = this.instance; + if (instance == null) { + return; + } + + const forceRender = !areOptionsEqual( + instance.options, + this.getResolvedOptions() + ); + instance.setOptions(this.getResolvedOptions()); + instance.render({ + fileDiff: this.rawFileDiff, + forceRender, + lineAnnotations: this.rawLineAnnotations, + }); + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + setHostElement(node: Element | null): void { + this.hostElement = node instanceof HTMLElement ? node : null; + }, + getResolvedOptions(): FileDiffOptions | undefined { + return mergeFileDiffOptions({ + hasCustomHeader: this.slotState.hasCustomHeader, + hasGutterUtility: this.hasGutterUtility, + options: this.rawOptions, + }); + }, + }, + render(): VNodeChild { + const children = this.shouldRenderPrerenderedTemplate + ? renderPrerenderedTemplate(this.resolvedPrerenderedHTML) + : []; + const renderedSlots = renderDiffSlots({ + fileDiff: this.rawFileDiff, + getHoveredLine: this.getHoveredLine, + lineAnnotations: this.rawLineAnnotations, + slots: this.$slots, + }); + this.slotState.hasCustomHeader = renderedSlots.hasCustomHeader; + children.push(...renderedSlots.children); + + return h( + DIFFS_TAG_NAME, + { + ...this.$attrs, + 'data-allow-mismatch': + this.resolvedPrerenderedHTML == null ? undefined : 'children', + ref: this.setHostElement as VNodeRef, + }, + children + ); + }, +}); diff --git a/packages/diffs/src/vue/MultiFileDiff.ts b/packages/diffs/src/vue/MultiFileDiff.ts new file mode 100644 index 000000000..b0385d901 --- /dev/null +++ b/packages/diffs/src/vue/MultiFileDiff.ts @@ -0,0 +1,93 @@ +import { defineComponent, h, type PropType, toRaw, type VNodeChild } from 'vue'; + +import type { FileDiffOptions } from '../components/FileDiff'; +import type { SelectedLineRange } from '../managers/InteractionManager'; +import type { + DiffLineAnnotation, + FileContents, + VirtualFileMetrics, +} from '../types'; +import { parseDiffFromFile } from '../utils/parseDiffFromFile'; +import { FileDiff } from './FileDiff'; + +export interface MultiFileDiffProps { + disableWorkerPool?: boolean; + lineAnnotations?: DiffLineAnnotation[]; + metrics?: VirtualFileMetrics; + newFile: FileContents; + oldFile: FileContents; + options?: FileDiffOptions; + prerenderedHtml?: string; + prerenderedHTML?: string; + selectedLines?: SelectedLineRange | null; +} + +export const MultiFileDiff = defineComponent({ + name: 'MultiFileDiff', + inheritAttrs: false, + props: { + disableWorkerPool: { + default: false, + type: Boolean, + }, + lineAnnotations: { + required: false, + type: Array as PropType[]>, + }, + metrics: { + required: false, + type: Object as PropType, + }, + newFile: { + required: true, + type: Object as PropType, + }, + oldFile: { + required: true, + type: Object as PropType, + }, + options: { + required: false, + type: Object as PropType>, + }, + prerenderedHTML: { + required: false, + type: String, + }, + prerenderedHtml: { + required: false, + type: String, + }, + selectedLines: { + default: undefined, + type: Object as PropType, + }, + }, + computed: { + fileDiff() { + const options = this.options == null ? undefined : toRaw(this.options); + return parseDiffFromFile( + toRaw(this.oldFile), + toRaw(this.newFile), + options?.parseDiffOptions + ); + }, + }, + render(): VNodeChild { + return h( + FileDiff, + { + ...this.$attrs, + disableWorkerPool: this.disableWorkerPool, + fileDiff: this.fileDiff, + lineAnnotations: this.lineAnnotations, + metrics: this.metrics, + options: this.options, + prerenderedHTML: this.prerenderedHTML, + prerenderedHtml: this.prerenderedHtml, + selectedLines: this.selectedLines, + }, + this.$slots + ); + }, +}); diff --git a/packages/diffs/src/vue/PatchDiff.ts b/packages/diffs/src/vue/PatchDiff.ts new file mode 100644 index 000000000..5509aeb9d --- /dev/null +++ b/packages/diffs/src/vue/PatchDiff.ts @@ -0,0 +1,79 @@ +import { defineComponent, h, type PropType, type VNodeChild } from 'vue'; + +import type { FileDiffOptions } from '../components/FileDiff'; +import type { SelectedLineRange } from '../managers/InteractionManager'; +import type { DiffLineAnnotation, VirtualFileMetrics } from '../types'; +import { getSingularPatch } from '../utils/getSingularPatch'; +import { FileDiff } from './FileDiff'; + +export interface PatchDiffProps { + disableWorkerPool?: boolean; + lineAnnotations?: DiffLineAnnotation[]; + metrics?: VirtualFileMetrics; + options?: FileDiffOptions; + patch: string; + prerenderedHtml?: string; + prerenderedHTML?: string; + selectedLines?: SelectedLineRange | null; +} + +export const PatchDiff = defineComponent({ + name: 'PatchDiff', + inheritAttrs: false, + props: { + disableWorkerPool: { + default: false, + type: Boolean, + }, + lineAnnotations: { + required: false, + type: Array as PropType[]>, + }, + metrics: { + required: false, + type: Object as PropType, + }, + options: { + required: false, + type: Object as PropType>, + }, + patch: { + required: true, + type: String, + }, + prerenderedHTML: { + required: false, + type: String, + }, + prerenderedHtml: { + required: false, + type: String, + }, + selectedLines: { + default: undefined, + type: Object as PropType, + }, + }, + computed: { + fileDiff() { + return getSingularPatch(this.patch); + }, + }, + render(): VNodeChild { + return h( + FileDiff, + { + ...this.$attrs, + disableWorkerPool: this.disableWorkerPool, + fileDiff: this.fileDiff, + lineAnnotations: this.lineAnnotations, + metrics: this.metrics, + options: this.options, + prerenderedHTML: this.prerenderedHTML, + prerenderedHtml: this.prerenderedHtml, + selectedLines: this.selectedLines, + }, + this.$slots + ); + }, +}); diff --git a/packages/diffs/src/vue/UnresolvedFile.ts b/packages/diffs/src/vue/UnresolvedFile.ts new file mode 100644 index 000000000..731e637ce --- /dev/null +++ b/packages/diffs/src/vue/UnresolvedFile.ts @@ -0,0 +1,355 @@ +import { + defineComponent, + h, + markRaw, + type PropType, + toRaw, + type VNodeChild, + type VNodeRef, +} from 'vue'; + +import { + UnresolvedFile as UnresolvedFileClass, + type UnresolvedFileOptions, +} from '../components/UnresolvedFile'; +import type { + GetHoveredLineResult, + SelectedLineRange, +} from '../managers/InteractionManager'; +import type { + DiffLineAnnotation, + FileContents, + FileDiffMetadata, + MergeConflictActionPayload, + MergeConflictMarkerRow, + MergeConflictResolution, +} from '../types'; +import { areOptionsEqual } from '../utils/areOptionsEqual'; +import { + type MergeConflictDiffAction, + parseMergeConflictDiffFromFile, +} from '../utils/parseMergeConflictDiffFromFile'; +import type { WorkerPoolManager } from '../worker'; +import { WorkerPoolInjectionKey } from './context'; +import { + DIFFS_TAG_NAME, + hasExistingPrerenderedContent, + hasSlot, + noopRender, + raw, + renderDiffSlots, + renderPrerenderedTemplate, +} from './utils'; + +export interface UnresolvedFileProps { + disableWorkerPool?: boolean; + file: FileContents; + lineAnnotations?: DiffLineAnnotation[]; + options?: VueUnresolvedFileOptions; + prerenderedHtml?: string; + prerenderedHTML?: string; + selectedLines?: SelectedLineRange | null; +} + +export interface VueUnresolvedFileOptions extends Omit< + UnresolvedFileOptions, + | 'mergeConflictActionsType' + | 'onMergeConflictAction' + | 'onMergeConflictResolve' +> { + mergeConflictActionsType?: 'default' | 'none'; +} + +export function mergeUnresolvedOptions({ + hasConflictUtility, + hasCustomHeader, + hasGutterUtility, + onMergeConflictAction, + options, +}: { + hasConflictUtility: boolean; + hasCustomHeader: boolean; + hasGutterUtility: boolean; + onMergeConflictAction: UnresolvedFileOptions['onMergeConflictAction']; + options: VueUnresolvedFileOptions | undefined; +}): UnresolvedFileOptions { + const normalizedOptions = (options ?? + {}) as VueUnresolvedFileOptions & { + onMergeConflictAction?: unknown; + onMergeConflictResolve?: unknown; + }; + const { + mergeConflictActionsType, + onMergeConflictAction: _onMergeConflictAction, + onMergeConflictResolve: _onMergeConflictResolve, + ...restOptions + } = normalizedOptions; + return { + ...restOptions, + mergeConflictActionsType: hasConflictUtility + ? noopRender + : mergeConflictActionsType === 'default' || + mergeConflictActionsType === 'none' + ? mergeConflictActionsType + : undefined, + onMergeConflictAction, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, + renderGutterUtility: hasGutterUtility ? noopRender : undefined, + }; +} + +interface UnresolvedState { + actions: (MergeConflictDiffAction | undefined)[]; + fileDiff: FileDiffMetadata; + markerRows: MergeConflictMarkerRow[]; +} + +export const UnresolvedFile = defineComponent({ + name: 'UnresolvedFile', + inheritAttrs: false, + inject: { + workerPool: { default: undefined, from: WorkerPoolInjectionKey }, + }, + props: { + disableWorkerPool: { + default: false, + type: Boolean, + }, + file: { + required: true, + type: Object as PropType, + }, + lineAnnotations: { + required: false, + type: Array as PropType[]>, + }, + options: { + required: false, + type: Object as PropType>, + }, + prerenderedHTML: { + required: false, + type: String, + }, + prerenderedHtml: { + required: false, + type: String, + }, + selectedLines: { + default: undefined, + type: Object as PropType, + }, + }, + data(): { + hostElement: HTMLElement | null; + instance: UnresolvedFileClass | null; + slotState: { hasCustomHeader: boolean }; + state: UnresolvedState; + shouldRenderPrerenderedTemplate: boolean; + } { + const state = parseMergeConflictDiffFromFile( + raw(this.file), + this.options?.maxContextLines + ); + return { + hostElement: null, + instance: null, + slotState: markRaw({ hasCustomHeader: false }), + state, + shouldRenderPrerenderedTemplate: + (this.prerenderedHtml ?? this.prerenderedHTML) != null, + }; + }, + computed: { + hasConflictUtility(): boolean { + return hasSlot(this.$slots, 'merge-conflict-utility'); + }, + hasGutterUtility(): boolean { + return hasSlot(this.$slots, 'gutter-utility'); + }, + rawFile(): FileContents { + return raw(this.file); + }, + rawLineAnnotations(): DiffLineAnnotation[] | undefined { + return this.lineAnnotations == null + ? undefined + : raw(this.lineAnnotations); + }, + rawOptions(): VueUnresolvedFileOptions | undefined { + return this.options == null ? undefined : raw(this.options); + }, + resolvedPrerenderedHTML(): string | undefined { + return this.prerenderedHtml ?? this.prerenderedHTML; + }, + rawSelectedLines(): SelectedLineRange | null | undefined { + return this.selectedLines == null + ? this.selectedLines + : toRaw(this.selectedLines); + }, + }, + mounted(): void { + this.mountOrHydrate(); + this.shouldRenderPrerenderedTemplate = false; + }, + updated(): void { + this.renderInstance(); + }, + beforeUnmount(): void { + this.instance?.cleanUp(); + this.instance = null; + }, + methods: { + createInstance(): UnresolvedFileClass { + if (this.instance != null) { + return this.instance as UnresolvedFileClass; + } + + const workerPool = this.disableWorkerPool + ? undefined + : (toRaw(this.workerPool) as WorkerPoolManager | undefined); + this.instance = markRaw( + new UnresolvedFileClass(this.getResolvedOptions(), workerPool, true) + ) as UnresolvedFileClass; + return this.instance as UnresolvedFileClass; + }, + getHoveredLine(): GetHoveredLineResult<'diff'> | undefined { + return this.instance?.getHoveredLine(); + }, + getInstance(): UnresolvedFileClass | undefined { + return (this.instance ?? undefined) as + | UnresolvedFileClass + | undefined; + }, + handleMergeConflictAction( + payload: MergeConflictActionPayload, + instance: UnresolvedFileClass + ): void { + this.resolveConflictAction( + payload.conflict.conflictIndex, + payload.resolution, + instance + ); + }, + mountOrHydrate(): void { + const hostElement = this.hostElement; + if (hostElement == null) { + return; + } + + const instance = this.createInstance(); + if ( + this.resolvedPrerenderedHTML != null && + hasExistingPrerenderedContent(hostElement) + ) { + instance.hydrate({ + actions: this.state.actions, + fileContainer: hostElement, + fileDiff: this.state.fileDiff, + lineAnnotations: this.rawLineAnnotations, + markerRows: this.state.markerRows, + prerenderedHTML: this.resolvedPrerenderedHTML, + }); + } else { + instance.render({ + actions: this.state.actions, + fileContainer: hostElement, + fileDiff: this.state.fileDiff, + lineAnnotations: this.rawLineAnnotations, + markerRows: this.state.markerRows, + }); + } + + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + renderInstance(): void { + const instance = this.instance; + if (instance == null) { + return; + } + + const forceRender = !areOptionsEqual( + instance.options, + this.getResolvedOptions() + ); + instance.setOptions(this.getResolvedOptions()); + instance.render({ + actions: this.state.actions, + fileDiff: this.state.fileDiff, + forceRender, + lineAnnotations: this.rawLineAnnotations, + markerRows: this.state.markerRows, + }); + if (this.rawSelectedLines !== undefined) { + instance.setSelectedLines(this.rawSelectedLines); + } + }, + resolveConflict( + action: MergeConflictDiffAction, + resolution: MergeConflictResolution + ): void { + this.resolveConflictAction(action.conflictIndex, resolution); + }, + resolveConflictAction( + conflictIndex: number, + resolution: MergeConflictResolution, + instance?: UnresolvedFileClass + ): void { + const targetInstance = instance ?? this.instance ?? undefined; + const result = targetInstance?.resolveConflict( + conflictIndex, + resolution, + this.state.fileDiff + ); + if (result == null) { + return; + } + + this.state = { + actions: result.actions, + fileDiff: result.fileDiff, + markerRows: result.markerRows, + }; + }, + setHostElement(node: Element | null): void { + this.hostElement = node instanceof HTMLElement ? node : null; + }, + getResolvedOptions(): UnresolvedFileOptions { + return mergeUnresolvedOptions({ + hasConflictUtility: this.hasConflictUtility, + hasCustomHeader: this.slotState.hasCustomHeader, + hasGutterUtility: this.hasGutterUtility, + onMergeConflictAction: this.handleMergeConflictAction, + options: this.rawOptions, + }); + }, + }, + render(): VNodeChild { + const children = this.shouldRenderPrerenderedTemplate + ? renderPrerenderedTemplate(this.resolvedPrerenderedHTML) + : []; + const renderedSlots = renderDiffSlots({ + actions: this.state.actions, + fileDiff: this.state.fileDiff, + getHoveredLine: this.getHoveredLine, + getInstance: this.getInstance, + lineAnnotations: this.rawLineAnnotations, + resolveConflict: this.resolveConflict, + slots: this.$slots, + }); + this.slotState.hasCustomHeader = renderedSlots.hasCustomHeader; + children.push(...renderedSlots.children); + + return h( + DIFFS_TAG_NAME, + { + ...this.$attrs, + 'data-allow-mismatch': + this.resolvedPrerenderedHTML == null ? undefined : 'children', + ref: this.setHostElement as VNodeRef, + }, + children + ); + }, +}); diff --git a/packages/diffs/src/vue/Virtualizer.ts b/packages/diffs/src/vue/Virtualizer.ts new file mode 100644 index 000000000..2cde565c2 --- /dev/null +++ b/packages/diffs/src/vue/Virtualizer.ts @@ -0,0 +1,99 @@ +import { + defineComponent, + h, + markRaw, + type PropType, + type VNodeChild, + type VNodeRef, +} from 'vue'; + +import { + Virtualizer as VirtualizerClass, + type VirtualizerConfig, +} from '../components/Virtualizer'; +import { VirtualizerInjectionKey } from './context'; + +export interface VirtualizerProps { + config?: Partial; + contentClass?: unknown; + contentStyle?: unknown; +} + +export const Virtualizer = defineComponent({ + name: 'Virtualizer', + inheritAttrs: false, + props: { + config: { + required: false, + type: Object as PropType>, + }, + contentClass: { + required: false, + type: [String, Object, Array] as PropType, + }, + contentStyle: { + required: false, + type: [String, Object, Array] as PropType, + }, + }, + data(): { + contentElement: HTMLElement | null; + rootElement: HTMLElement | null; + virtualizer: VirtualizerClass | undefined; + } { + return { + contentElement: null, + rootElement: null, + virtualizer: + typeof window === 'undefined' + ? undefined + : markRaw(new VirtualizerClass(this.config)), + }; + }, + provide(): Record { + return { + [VirtualizerInjectionKey as symbol]: this.virtualizer as + | VirtualizerClass + | undefined, + }; + }, + mounted(): void { + if (this.rootElement != null) { + this.virtualizer?.setup( + this.rootElement, + this.contentElement ?? undefined + ); + } + }, + beforeUnmount(): void { + this.virtualizer?.cleanUp(); + }, + methods: { + setContentElement(node: Element | null): void { + this.contentElement = node instanceof HTMLElement ? node : null; + }, + setRootElement(node: Element | null): void { + this.rootElement = node instanceof HTMLElement ? node : null; + }, + }, + render(): VNodeChild { + return h( + 'div', + { + ...this.$attrs, + ref: this.setRootElement as VNodeRef, + }, + [ + h( + 'div', + { + class: this.contentClass, + ref: this.setContentElement as VNodeRef, + style: this.contentStyle, + }, + this.$slots.default?.() + ), + ] + ); + }, +}); diff --git a/packages/diffs/src/vue/WorkerPoolProvider.ts b/packages/diffs/src/vue/WorkerPoolProvider.ts new file mode 100644 index 000000000..75eec1189 --- /dev/null +++ b/packages/diffs/src/vue/WorkerPoolProvider.ts @@ -0,0 +1,73 @@ +import { defineComponent, markRaw, type PropType, type VNodeChild } from 'vue'; + +import { + getOrCreateWorkerPoolSingleton, + type SetupWorkerPoolProps, + terminateWorkerPoolSingleton, + type WorkerInitializationRenderOptions, + type WorkerPoolManager, + type WorkerPoolOptions, +} from '../worker'; +import { WorkerPoolInjectionKey } from './context'; + +export type { WorkerInitializationRenderOptions, WorkerPoolOptions }; + +let workerProviderCount = 0; + +export type WorkerPoolProviderProps = SetupWorkerPoolProps; + +export const WorkerPoolProvider = defineComponent({ + name: 'WorkerPoolProvider', + props: { + highlighterOptions: { + required: true, + type: Object as PropType, + }, + poolOptions: { + required: true, + type: Object as PropType, + }, + }, + data(): { + workerPool: WorkerPoolManager | undefined; + } { + return { + workerPool: + typeof window === 'undefined' + ? undefined + : markRaw( + getOrCreateWorkerPoolSingleton({ + highlighterOptions: this.highlighterOptions, + poolOptions: this.poolOptions, + }) + ), + }; + }, + provide(): Record { + return { + [WorkerPoolInjectionKey as symbol]: this.workerPool as + | WorkerPoolManager + | undefined, + }; + }, + mounted(): void { + if (this.workerPool != null) { + workerProviderCount++; + } + }, + beforeUnmount(): void { + if (this.workerPool == null) { + return; + } + + workerProviderCount--; + if (workerProviderCount === 0) { + terminateWorkerPoolSingleton(); + } + }, + render(): VNodeChild { + return this.$slots.default?.(); + }, +}); + +export const WorkerPoolContextProvider = WorkerPoolProvider; diff --git a/packages/diffs/src/vue/context.ts b/packages/diffs/src/vue/context.ts new file mode 100644 index 000000000..d6f8bf83c --- /dev/null +++ b/packages/diffs/src/vue/context.ts @@ -0,0 +1,19 @@ +import { inject, type InjectionKey } from 'vue'; + +import type { Virtualizer } from '../components/Virtualizer'; +import type { WorkerPoolManager } from '../worker'; + +export const VirtualizerInjectionKey: InjectionKey = + Symbol('DiffsVirtualizer'); + +export const WorkerPoolInjectionKey: InjectionKey< + WorkerPoolManager | undefined +> = Symbol('DiffsWorkerPool'); + +export function useVirtualizer(): Virtualizer | undefined { + return inject(VirtualizerInjectionKey, undefined); +} + +export function useWorkerPool(): WorkerPoolManager | undefined { + return inject(WorkerPoolInjectionKey, undefined); +} diff --git a/packages/diffs/src/vue/index.ts b/packages/diffs/src/vue/index.ts new file mode 100644 index 000000000..151fcd86e --- /dev/null +++ b/packages/diffs/src/vue/index.ts @@ -0,0 +1,23 @@ +export { File, type FileProps } from './File'; +export { FileDiff, type DiffBaseProps, type FileDiffProps } from './FileDiff'; +export { MultiFileDiff, type MultiFileDiffProps } from './MultiFileDiff'; +export { PatchDiff, type PatchDiffProps } from './PatchDiff'; +export { + UnresolvedFile, + type UnresolvedFileProps, + type VueUnresolvedFileOptions, +} from './UnresolvedFile'; +export { Virtualizer, type VirtualizerProps } from './Virtualizer'; +export { + useVirtualizer, + useWorkerPool, + VirtualizerInjectionKey, + WorkerPoolInjectionKey, +} from './context'; +export { + WorkerPoolContextProvider, + WorkerPoolProvider, + type WorkerInitializationRenderOptions, + type WorkerPoolOptions, + type WorkerPoolProviderProps, +} from './WorkerPoolProvider'; diff --git a/packages/diffs/src/vue/utils.ts b/packages/diffs/src/vue/utils.ts new file mode 100644 index 000000000..c3b7d3c82 --- /dev/null +++ b/packages/diffs/src/vue/utils.ts @@ -0,0 +1,259 @@ +import type { Slots, VNodeChild } from 'vue'; +import { Comment, Fragment, h, isVNode, Text, toRaw } from 'vue'; + +import { + CUSTOM_HEADER_SLOT_ID, + DIFFS_TAG_NAME, + HEADER_METADATA_SLOT_ID, + HEADER_PREFIX_SLOT_ID, +} from '../constants'; +import type { GetHoveredLineResult } from '../managers/InteractionManager'; +import type { + DiffLineAnnotation, + FileContents, + FileDiffMetadata, + LineAnnotation, + MergeConflictResolution, +} from '../types'; +import { getLineAnnotationName } from '../utils/getLineAnnotationName'; +import { getMergeConflictActionSlotName } from '../utils/getMergeConflictActionSlotName'; +import { + getMergeConflictActionAnchor, + type MergeConflictDiffAction, +} from '../utils/parseMergeConflictDiffFromFile'; + +export { DIFFS_TAG_NAME }; + +export const GUTTER_UTILITY_SLOT_NAME = 'gutter-utility-slot'; + +export const GutterUtilitySlotStyle = { + bottom: 0, + position: 'absolute', + textAlign: 'center', + top: 0, +} as const; + +export const MergeConflictSlotStyle = { + display: 'contents', +} as const; + +export function noopRender(): null { + return null; +} + +export function raw(value: T): T { + return toRaw(value); +} + +export function hasExistingPrerenderedContent(host: HTMLElement): boolean { + if ((host.shadowRoot?.children.length ?? 0) > 0) { + return true; + } + + return ( + host.querySelector('template[shadowrootmode="open"]') instanceof + HTMLTemplateElement + ); +} + +export function renderPrerenderedTemplate( + prerenderedHTML: string | undefined +): VNodeChild[] { + if (prerenderedHTML == null) { + return []; + } + + return [ + h('template', { + innerHTML: prerenderedHTML, + shadowrootmode: 'open', + }), + ]; +} + +export function renderFileSlots({ + file, + getHoveredLine, + lineAnnotations, + slots, +}: { + file: FileContents; + getHoveredLine(): GetHoveredLineResult<'file'> | undefined; + lineAnnotations: LineAnnotation[] | undefined; + slots: Slots; +}): { children: VNodeChild[]; hasCustomHeader: boolean } { + const children: VNodeChild[] = []; + const customHeader = slots.header?.({ file }); + const hasCustomHeader = hasRenderedSlotContent(customHeader); + if (hasCustomHeader) { + children.push(h('div', { slot: CUSTOM_HEADER_SLOT_ID }, customHeader)); + } else { + const prefix = slots['header-prefix']?.({ file }); + const metadata = slots['header-metadata']?.({ file }); + if (prefix != null) { + children.push(h('div', { slot: HEADER_PREFIX_SLOT_ID }, prefix)); + } + if (metadata != null) { + children.push(h('div', { slot: HEADER_METADATA_SLOT_ID }, metadata)); + } + } + + const annotationSlot = slots.annotation; + if (annotationSlot != null) { + for (const annotation of lineAnnotations ?? []) { + children.push( + h( + 'div', + { slot: getLineAnnotationName(annotation) }, + annotationSlot({ annotation }) + ) + ); + } + } + + const gutterUtility = slots['gutter-utility']; + if (gutterUtility != null) { + children.push( + h( + 'div', + { slot: GUTTER_UTILITY_SLOT_NAME, style: GutterUtilitySlotStyle }, + gutterUtility({ getHoveredLine }) + ) + ); + } + + return { children, hasCustomHeader }; +} + +export function renderDiffSlots({ + actions, + fileDiff, + getHoveredLine, + getInstance, + lineAnnotations, + resolveConflict, + slots, +}: { + actions?: (MergeConflictDiffAction | undefined)[]; + fileDiff: FileDiffMetadata; + getHoveredLine(): GetHoveredLineResult<'diff'> | undefined; + getInstance?(): T | undefined; + lineAnnotations: DiffLineAnnotation[] | undefined; + resolveConflict?( + action: MergeConflictDiffAction, + resolution: MergeConflictResolution + ): void; + slots: Slots; +}): { children: VNodeChild[]; hasCustomHeader: boolean } { + const children: VNodeChild[] = []; + const customHeader = slots.header?.({ fileDiff }); + const hasCustomHeader = hasRenderedSlotContent(customHeader); + if (hasCustomHeader) { + children.push(h('div', { slot: CUSTOM_HEADER_SLOT_ID }, customHeader)); + } else { + const prefix = slots['header-prefix']?.({ fileDiff }); + const metadata = slots['header-metadata']?.({ fileDiff }); + if (prefix != null) { + children.push(h('div', { slot: HEADER_PREFIX_SLOT_ID }, prefix)); + } + if (metadata != null) { + children.push(h('div', { slot: HEADER_METADATA_SLOT_ID }, metadata)); + } + } + + const annotationSlot = slots.annotation; + if (annotationSlot != null) { + for (const annotation of lineAnnotations ?? []) { + children.push( + h( + 'div', + { slot: getLineAnnotationName(annotation) }, + annotationSlot({ annotation }) + ) + ); + } + } + + const mergeConflictUtility = slots['merge-conflict-utility']; + if ( + mergeConflictUtility != null && + actions != null && + getInstance != null && + resolveConflict != null + ) { + for (const action of actions) { + if (action == null) { + continue; + } + + const anchor = getMergeConflictActionAnchor(action, fileDiff); + if (anchor == null) { + continue; + } + + children.push( + h( + 'div', + { + slot: getMergeConflictActionSlotName({ + conflictIndex: action.conflictIndex, + hunkIndex: anchor.hunkIndex, + lineIndex: anchor.lineIndex, + }), + style: MergeConflictSlotStyle, + }, + mergeConflictUtility({ + action, + context: { + resolveConflict: (resolution: unknown) => { + resolveConflict(action, resolution as MergeConflictResolution); + }, + }, + getInstance, + }) + ) + ); + } + } + + const gutterUtility = slots['gutter-utility']; + if (gutterUtility != null) { + children.push( + h( + 'div', + { slot: GUTTER_UTILITY_SLOT_NAME, style: GutterUtilitySlotStyle }, + gutterUtility({ getHoveredLine }) + ) + ); + } + + return { children, hasCustomHeader }; +} + +export function hasSlot(slots: Slots, name: string): boolean { + return slots[name] != null; +} + +function hasRenderedSlotContent(content: VNodeChild): boolean { + if (content == null || typeof content === 'boolean' || content === '') { + return false; + } + + if (Array.isArray(content)) { + return content.some(hasRenderedSlotContent); + } + + if (isVNode(content)) { + if (content.type === Comment) { + return false; + } + if (content.type === Fragment) { + return hasRenderedSlotContent(content.children as VNodeChild); + } + if (content.type === Text) { + return hasRenderedSlotContent(content.children as VNodeChild); + } + } + + return true; +} diff --git a/packages/diffs/test/file-diff-vue.test.ts b/packages/diffs/test/file-diff-vue.test.ts new file mode 100644 index 000000000..28b8c1624 --- /dev/null +++ b/packages/diffs/test/file-diff-vue.test.ts @@ -0,0 +1,524 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + spyOn, + test, +} from 'bun:test'; +import { JSDOM } from 'jsdom'; +import type { App, Component, VNodeChild } from 'vue'; + +import type { VueUnresolvedFileOptions } from '../src/vue'; + +let createApp: typeof import('vue').createApp; +let createSSRApp: typeof import('vue').createSSRApp; +let defineComponent: typeof import('vue').defineComponent; +let h: typeof import('vue').h; +let nextTick: typeof import('vue').nextTick; +let renderToString: typeof import('@vue/server-renderer').renderToString; +let FileVue: typeof import('../src/vue').File; +let FileDiffVue: typeof import('../src/vue').FileDiff; +let MultiFileDiffVue: typeof import('../src/vue').MultiFileDiff; +let PatchDiffVue: typeof import('../src/vue').PatchDiff; +let UnresolvedFileVue: typeof import('../src/vue').UnresolvedFile; +let VirtualizerVue: typeof import('../src/vue').Virtualizer; +let FileClass: typeof import('../src/components/File').File; +let VirtualizerClass: typeof import('../src/components/Virtualizer').Virtualizer; +let parseDiffFromFile: typeof import('../src').parseDiffFromFile; +let preloadFile: typeof import('../src/ssr').preloadFile; + +const TAG = 'diffs-container'; +const originalGlobals = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Document: Reflect.get(globalThis, 'Document'), + Element: Reflect.get(globalThis, 'Element'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLPreElement: Reflect.get(globalThis, 'HTMLPreElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + IntersectionObserver: Reflect.get(globalThis, 'IntersectionObserver'), + MouseEvent: Reflect.get(globalThis, 'MouseEvent'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + requestAnimationFrame: Reflect.get(globalThis, 'requestAnimationFrame'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + window: Reflect.get(globalThis, 'window'), +}; + +const dom = new JSDOM('', { + pretendToBeVisual: true, + url: 'http://localhost', +}); + +class MockCSSStyleSheet { + replaceSync(_value: string): void {} +} + +class MockIntersectionObserver { + disconnect(): void {} + observe(_target: Element): void {} + unobserve(_target: Element): void {} +} + +class MockResizeObserver { + disconnect(): void {} + observe(_target: Element): void {} + unobserve(_target: Element): void {} +} + +beforeAll(async () => { + Object.assign(globalThis, { + CSSStyleSheet: MockCSSStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Document: dom.window.Document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + HTMLPreElement: dom.window.HTMLPreElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + IntersectionObserver: MockIntersectionObserver, + MouseEvent: dom.window.MouseEvent, + navigator: dom.window.navigator, + Node: dom.window.Node, + requestAnimationFrame: (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 0) as unknown as number; + }, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + window: dom.window, + }); + + ({ createApp, createSSRApp, defineComponent, h, nextTick } = + await import('vue')); + ({ renderToString } = await import('@vue/server-renderer')); + ({ + File: FileVue, + FileDiff: FileDiffVue, + MultiFileDiff: MultiFileDiffVue, + PatchDiff: PatchDiffVue, + UnresolvedFile: UnresolvedFileVue, + Virtualizer: VirtualizerVue, + } = await import('../src/vue')); + ({ File: FileClass } = await import('../src/components/File')); + ({ Virtualizer: VirtualizerClass } = + await import('../src/components/Virtualizer')); + ({ parseDiffFromFile } = await import('../src')); + ({ preloadFile } = await import('../src/ssr')); +}); + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +afterAll(() => { + for (const [key, value] of Object.entries(originalGlobals)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + + dom.window.close(); +}); + +async function flushDom(): Promise { + await nextTick(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function mountComponent( + component: Component, + container: HTMLElement +): Promise> { + const app = createApp(component); + app.mount(container); + await flushDom(); + return app; +} + +function getHosts(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(TAG)).filter( + (host): host is HTMLElement => host instanceof dom.window.HTMLElement + ); +} + +function dispatchClick(target: Element): void { + target.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); +} + +const file = { + contents: 'const value = 1;\n', + name: 'file.ts', +}; + +const oldFile = { + contents: 'const value = 1;\n', + name: 'file.ts', +}; + +const newFile = { + contents: 'const value = 2;\n', + name: 'file.ts', +}; + +const patch = `diff --git a/file.ts b/file.ts +index 0000000..1111111 100644 +--- a/file.ts ++++ b/file.ts +@@ -1 +1 @@ +-const value = 1; ++const value = 2; +`; + +const unresolvedFile = { + contents: `const value = 1; +<<<<<<< HEAD +const conflict = 'current'; +======= +const conflict = 'incoming'; +>>>>>>> branch +`, + name: 'file.ts', +}; + +describe('diffs Vue lane', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + test('renders the core Vue components', async () => { + const fileDiff = parseDiffFromFile(oldFile, newFile); + const component = defineComponent({ + render(): VNodeChild { + return h('div', [ + h(FileVue, { file }), + h(FileDiffVue, { fileDiff }), + h(MultiFileDiffVue, { newFile, oldFile }), + h(PatchDiffVue, { patch }), + h(UnresolvedFileVue, { file: unresolvedFile }), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + const hosts = getHosts(container); + expect(hosts).toHaveLength(5); + for (const host of hosts) { + expect(host.shadowRoot?.querySelector('pre')).not.toBeNull(); + } + } finally { + app.unmount(); + } + }); + + test('renders header, annotation, gutter, and merge-conflict slots', async () => { + const component = defineComponent({ + render(): VNodeChild { + return h('div', [ + h( + FileVue, + { + file, + lineAnnotations: [{ lineNumber: 1, metadata: 'note' }], + }, + { + annotation: ({ + annotation, + }: { + annotation: { metadata: string }; + }) => + h( + 'span', + { 'data-test-file-annotation': '' }, + annotation.metadata + ), + 'gutter-utility': ({ + getHoveredLine, + }: { + getHoveredLine(): unknown; + }) => + h( + 'button', + { 'data-test-file-gutter': '', type: 'button' }, + getHoveredLine() == null ? 'idle' : 'hovered' + ), + header: ({ file }: { file: { name: string } }) => + h('button', { 'data-test-file-header': '' }, file.name), + } + ), + h( + UnresolvedFileVue, + { file: unresolvedFile }, + { + 'merge-conflict-utility': ({ + action, + context, + }: { + action: { conflictIndex: number }; + context: { resolveConflict(resolution: 'current'): void }; + }) => + h( + 'button', + { + 'data-test-conflict-action': '', + onClick: () => context.resolveConflict('current'), + type: 'button', + }, + String(action.conflictIndex) + ), + } + ), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + expect( + container.querySelector('[data-test-file-header]')?.textContent + ).toBe('file.ts'); + expect( + container.querySelector('[data-test-file-annotation]')?.textContent + ).toBe('note'); + expect( + container.querySelector('[data-test-file-gutter]')?.textContent + ).toBe('idle'); + + const conflictAction = container.querySelector( + '[data-test-conflict-action]' + ); + if (!(conflictAction instanceof dom.window.HTMLElement)) { + throw new Error('expected merge conflict action slot'); + } + + dispatchClick(conflictAction); + await flushDom(); + expect(container.querySelector('[data-test-conflict-action]')).toBeNull(); + } finally { + app.unmount(); + } + }); + + test('does not expose core merge-conflict callbacks in Vue options', () => { + const options: VueUnresolvedFileOptions = { + mergeConflictActionsType: 'default', + }; + expect(options.mergeConflictActionsType).toBe('default'); + + const actionOptions: VueUnresolvedFileOptions = { + // @ts-expect-error Vue unresolved files own this callback internally. + onMergeConflictAction: () => {}, + }; + const resolveOptions: VueUnresolvedFileOptions = { + // @ts-expect-error Vue users resolve conflicts through slot context. + onMergeConflictResolve: () => {}, + }; + expect(actionOptions).toBeDefined(); + expect(resolveOptions).toBeDefined(); + }); + + test('falls back to prefix and metadata slots when header slots are empty', async () => { + const fileDiff = parseDiffFromFile(oldFile, newFile); + const component = defineComponent({ + render(): VNodeChild { + return h('div', [ + h( + FileVue, + { file }, + { + header: () => [], + 'header-prefix': () => + h('span', { 'data-test-file-prefix': '' }, 'file prefix'), + } + ), + h( + FileDiffVue, + { fileDiff }, + { + header: () => [], + 'header-metadata': () => + h('span', { 'data-test-diff-metadata': '' }, 'diff metadata'), + } + ), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + expect( + container.querySelector('[data-test-file-prefix]')?.textContent + ).toBe('file prefix'); + expect( + container.querySelector('[data-test-diff-metadata]')?.textContent + ).toBe('diff metadata'); + } finally { + app.unmount(); + } + }); + + test('updates props without cleaning up the renderer instance', async () => { + const cleanUpSpy = spyOn(FileClass.prototype, 'cleanUp'); + const component = defineComponent({ + data() { + return { + file, + }; + }, + methods: { + updateFile() { + this.file = { + contents: 'const value = 2;\n', + name: 'file.ts', + }; + }, + }, + render(): VNodeChild { + return h('div', [ + h( + 'button', + { + 'data-test-update': '', + onClick: this.updateFile, + type: 'button', + }, + 'Update' + ), + h(FileVue, { file: this.file }), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + const update = container.querySelector('[data-test-update]'); + if (!(update instanceof dom.window.HTMLElement)) { + throw new Error('expected update button'); + } + + dispatchClick(update); + await flushDom(); + expect(cleanUpSpy).toHaveBeenCalledTimes(0); + } finally { + app.unmount(); + expect(cleanUpSpy).toHaveBeenCalledTimes(1); + cleanUpSpy.mockRestore(); + } + }); + + test('Virtualizer provides virtualized renderer instances to children', async () => { + const connectSpy = spyOn(VirtualizerClass.prototype, 'connect'); + const component = defineComponent({ + render(): VNodeChild { + return h( + VirtualizerVue, + { style: { height: '200px', overflow: 'auto' } }, + { + default: () => h(FileVue, { file }), + } + ); + }, + }); + + const app = await mountComponent(component, container); + try { + expect(connectSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + } finally { + app.unmount(); + connectSpy.mockRestore(); + } + }); + + test('SSR output hydrates without Vue mismatch warnings and keeps slots live', async () => { + const preloaded = await preloadFile({ file }); + const component = defineComponent({ + data() { + return { + count: 0, + }; + }, + render(): VNodeChild { + return h( + FileVue, + { + file: preloaded.file, + options: preloaded.options, + prerenderedHTML: preloaded.prerenderedHTML, + }, + { + header: () => + h( + 'button', + { + 'data-test-ssr-header': '', + onClick: () => { + this.count += 1; + }, + type: 'button', + }, + `Count ${this.count}` + ), + } + ); + }, + }); + + const html = await renderToString(createSSRApp(component)); + expect(html).toContain(' {}); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + const app = createSSRApp(component); + app.mount(container, true); + await flushDom(); + + try { + const relevantWarnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes('Hydration') + ); + const relevantErrors = errorSpy.mock.calls.filter(([message]) => + String(message).includes('Hydration') + ); + expect(relevantWarnings).toHaveLength(0); + expect(relevantErrors).toHaveLength(0); + + const header = container.querySelector('[data-test-ssr-header]'); + if (!(header instanceof dom.window.HTMLElement)) { + throw new Error('expected hydrated header slot'); + } + + expect(header.textContent).toBe('Count 0'); + dispatchClick(header); + await flushDom(); + expect(header.textContent).toBe('Count 1'); + } finally { + app.unmount(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); +});