diff --git a/README.md b/README.md index cff330f..93ff124 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ import { DiffView, DiffModeEnum } from "@git-diff-view/cli"; - [`@git-diff-view/solid`](https://www.npmjs.com/package/@git-diff-view/solid) - Solid component - [`@git-diff-view/svelte`](https://www.npmjs.com/package/@git-diff-view/svelte) - Svelte component - [`@git-diff-view/lynx`](https://www.npmjs.com/package/@git-diff-view/lynx) - ReactLynx DiffView (wrap-first Split/Unified) +- [`@git-diff-view/octane`](https://www.npmjs.com/package/@git-diff-view/octane) - Native Octane DiffView with client and SSR entries - [`@git-diff-view/cli`](https://www.npmjs.com/package/@git-diff-view/cli) - Terminal diff viewer (`DiffView`, `CodeView`, scroll API) **Core Libraries** diff --git a/package.json b/package.json index 9deb6c2..331ee19 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@git-diff-view/core": "workspace:*", "@git-diff-view/file": "workspace:*", "@git-diff-view/lynx": "workspace:*", + "@git-diff-view/octane": "workspace:*", "@git-diff-view/react": "workspace:*", "@git-diff-view/lezer": "workspace:*", "@git-diff-view/shiki": "workspace:*", @@ -64,4 +65,4 @@ "tslib": "catalog:", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/octane/LICENSE b/packages/octane/LICENSE new file mode 100644 index 0000000..cd72cbc --- /dev/null +++ b/packages/octane/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 MrWangJustToDo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/octane/package.json b/packages/octane/package.json new file mode 100644 index 0000000..efaa95d --- /dev/null +++ b/packages/octane/package.json @@ -0,0 +1,78 @@ +{ + "name": "@git-diff-view/octane", + "description": "Octane components for rendering Git diffs", + "author": "MrWangJustToDo", + "license": "MIT", + "version": "0.1.7", + "type": "module", + "main": "./dist/server/index.js", + "module": "./dist/client/index.js", + "types": "./dist/index.d.ts", + "sideEffects": [ + "**/*.css" + ], + "files": [ + "dist", + "styles", + "LICENSE", + "readme.md" + ], + "scripts": { + "build": "vite build --mode npm-client && vite build --mode npm-server && pnpm run gen:css && pnpm run build:types && node ./scripts/check-built-package.mjs", + "build:types": "node ./scripts/generate-tsrx-types.mjs --out-dir dist/types && rollup -c ./scripts/rollup.dts.config.mjs", + "gen:css1": "postcss src/tailwind.css -o dist/css/diff-view.css", + "gen:css2": "postcss src/tailwind.css -o dist/css/diff-view-pure.css", + "gen:css": "pnpm run gen:css1 && pnpm run gen:css2", + "test": "pnpm run test:client && pnpm run test:ssr && pnpm run test:package", + "test:client": "vitest run --config vitest.config.ts", + "test:ssr": "vitest run --config vitest.ssr.config.ts", + "test:package": "node ./scripts/smoke-packed-package.mjs", + "typecheck": "tsrx-tsc --noEmit -p tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MrWangJustToDo/git-diff-view.git", + "directory": "packages/octane" + }, + "homepage": "https://mrwangjusttodo.github.io/git-diff-view", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/client/index.js", + "node": "./dist/server/index.js", + "import": "./dist/client/index.js", + "default": "./dist/client/index.js" + }, + "./styles/*": "./dist/css/*", + "./package.json": "./package.json" + }, + "keywords": [ + "diff component", + "octane diff component" + ], + "dependencies": { + "@git-diff-view/core": "^0.1.7", + "@types/hast": "^3.0.0", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0", + "reactivity-store": "^0.4.0" + }, + "peerDependencies": { + "octane": ">=0.1.36 <0.2.0" + }, + "devDependencies": { + "@octanejs/testing-library": "^0.1.18", + "@tsrx/typescript-plugin": "^0.3.118", + "autoprefixer": "^10.4.27", + "jsdom": "^24.1.3", + "octane": "^0.1.36", + "postcss": "^8.5.8", + "rollup": "^4.50.0", + "rollup-plugin-dts": "^6.2.3", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^8.0.16", + "vitest": "^3.2.4" + } +} diff --git a/packages/octane/postcss.config.cjs b/packages/octane/postcss.config.cjs new file mode 100644 index 0000000..2b86f4f --- /dev/null +++ b/packages/octane/postcss.config.cjs @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const { resolve } = require("path"); + +module.exports = { + plugins: { + "postcss-import": {}, + tailwindcss: { config: resolve(__dirname) + "/tailwind.config.cjs" }, + "postcss-prefix-selector": { + prefix: ".diff-tailwindcss-wrapper", + transform: function (prefix, selector, prefixedSelector, _filePath, rule) { + const filePath = rule.source?.input?.file; + if (filePath.includes("_base.css")) return prefixedSelector; + if ( + selector.includes("diff-line-extend-wrapper") || + selector.includes("diff-line-widget-wrapper") || + selector.includes("diff-multiselect-wrapper") || + selector.includes("diff-multi-selecting") + ) { + return selector; + } + if (selector.includes("[data-theme")) return prefix + selector; + if (filePath.includes("node_modules")) { + if (filePath.includes("dark.css")) { + return `${prefix}[data-theme="dark"] .diff-line-syntax-raw ${selector}`; + } + return `${prefix}[data-theme="light"] .diff-line-syntax-raw ${selector}`; + } + return prefixedSelector; + }, + }, + autoprefixer: {}, + }, +}; diff --git a/packages/octane/readme.md b/packages/octane/readme.md new file mode 100644 index 0000000..04027e3 --- /dev/null +++ b/packages/octane/readme.md @@ -0,0 +1,151 @@ +# @git-diff-view/octane + +> A high-performance Octane diff view component with GitHub-style UI + +[![npm version](https://img.shields.io/npm/v/@git-diff-view/octane)](https://www.npmjs.com/package/@git-diff-view/octane) + +## Features + +- ✅ Split & Unified views +- ✅ Syntax highlighting with full context +- ✅ Light & Dark themes +- ✅ Native Octane client and SSR builds +- ✅ Widget & extend data system +- ✅ High performance with Web Worker support + +## Installation + +```bash +npm install @git-diff-view/octane octane +# or +pnpm add @git-diff-view/octane octane +# or +yarn add @git-diff-view/octane octane +``` + +## Quick Start + +### Basic Usage + +```tsrx +import { DiffView, DiffModeEnum } from "@git-diff-view/octane"; +import "@git-diff-view/octane/styles/diff-view.css"; + +function MyDiffView() { + return ( + + ); +} +``` + +### Advanced Usage with DiffFile + +**File Comparison Mode** + +```tsrx +import { DiffView } from "@git-diff-view/octane"; +import { DiffFile, generateDiffFile } from "@git-diff-view/file"; +import "@git-diff-view/octane/styles/diff-view.css"; + +const file = generateDiffFile( + "old.ts", oldContent, + "new.ts", newContent, + "typescript", "typescript" +); +file.initTheme('dark'); +file.init(); +file.buildSplitDiffLines(); + + +``` + +**Git Diff Mode** + +```tsrx +import { DiffView } from "@git-diff-view/octane"; +import { DiffFile } from "@git-diff-view/core"; +import "@git-diff-view/octane/styles/diff-view.css"; + +const file = new DiffFile( + oldFileName, + oldContent, + newFileName, + newContent, + hunks, + oldFileLang, + newFileLang +); +file.initTheme('dark'); +file.init(); +file.buildSplitDiffLines(); + + +``` + +## Styling + +```tsrx +// Default styles with Tailwind (next release will be pure CSS) +import "@git-diff-view/octane/styles/diff-view.css"; + +// Pure CSS (no Tailwind conflicts) +import "@git-diff-view/octane/styles/diff-view-pure.css"; +``` + +## API Reference + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `data` | `DiffData` | Diff data with `oldFile`, `newFile`, and `hunks` | +| `diffFile` | `DiffFile` | Pre-processed diff file instance | +| `diffViewMode` | `Split \| Unified` | View mode (default: `Split`) | +| `diffViewTheme` | `light \| dark` | Theme (default: `light`) | +| `diffViewHighlight` | `boolean` | Enable syntax highlighting | +| `diffViewWrap` | `boolean` | Enable line wrapping | +| `diffViewFontSize` | `number` | Font size in pixels | +| `diffViewAddWidget` | `boolean` | Enable widget button | +| `renderWidgetLine` | `(props) => OctaneNode` | Custom widget renderer | +| `renderExtendLine` | `(props) => OctaneNode` | Custom extend data renderer | +| `extendData` | `ExtendData` | Additional data per line | +| `onAddWidgetClick` | `(lineNumber, side) => void` | Widget button click handler | + +### DiffData Type + +```typescript +type DiffData = { + oldFile?: { + fileName?: string | null; + fileLang?: string | null; + content?: string | null; + }; + newFile?: { + fileName?: string | null; + fileLang?: string | null; + content?: string | null; + }; + hunks: string[]; +}; +``` + +## Compatibility + +The public `DiffView` and `DiffViewWithMultiSelect` surface mirrors `@git-diff-view/react` 0.1.7. Components use Octane refs-as-props, native browser events, and precompiled browser/server exports; no React runtime is required. + +## Live Demo + +Try it online: [https://mrwangjusttodo.github.io/git-diff-view](https://mrwangjusttodo.github.io/git-diff-view) + +## License + +MIT © [MrWangJustToDo](https://github.com/MrWangJustToDo) diff --git a/packages/octane/scripts/check-built-package.mjs b/packages/octane/scripts/check-built-package.mjs new file mode 100644 index 0000000..d39e982 --- /dev/null +++ b/packages/octane/scripts/check-built-package.mjs @@ -0,0 +1,89 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const distRoot = join(packageRoot, "dist"); +const expected = [ + "dist/client/index.js", + "dist/server/index.js", + "dist/index.d.ts", + "dist/css/diff-view.css", + "dist/css/diff-view-pure.css", +]; + +function filesUnder(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = join(directory, entry.name); + return entry.isDirectory() ? filesUnder(file) : [file]; + }); +} + +const missing = expected.filter((file) => !existsSync(join(packageRoot, file))); +if (missing.length > 0) { + throw new Error(`@git-diff-view/octane build omitted:\n${missing.map((file) => ` ${file}`).join("\n")}`); +} + +for (const file of filesUnder(distRoot)) { + const packagePath = relative(packageRoot, file); + if (/\.(?:ts|tsx|tsrx)$/.test(file) && !file.endsWith(".d.ts")) { + throw new Error(`Raw TypeScript leaked into the package: ${packagePath}`); + } + if (!file.endsWith(".js")) continue; + + const source = readFileSync(file, "utf8"); + if (/(?:from\s*|import\s*\()["'](?:react|react-dom|use-sync-external-store)(?:\/[^"']*)?["']/.test(source)) { + throw new Error(`React runtime import leaked into ${packagePath}`); + } + if (!source.startsWith("// octane-no-slot:")) { + throw new Error(`Precompiled marker is missing from ${packagePath}`); + } + if (packagePath.startsWith("dist/server/") && /from\s*["']octane["']/.test(source)) { + throw new Error(`Server output retained a client Octane runtime import: ${packagePath}`); + } +} + +for (const environment of ["client", "server"]) { + const entry = readFileSync(join(distRoot, environment, "index.js"), "utf8"); + if (!/\bDiffView\b/.test(entry) || !/\bexport\s*\{/.test(entry)) { + throw new Error(`${environment} entry does not expose DiffView.`); + } +} + +const declarationEntry = readFileSync(join(distRoot, "index.d.ts"), "utf8"); +if (/from\s+["']@git-diff-view\/utils(?:\/[^"']*)?["']/.test(declarationEntry)) { + throw new Error("Published declarations retained the private @git-diff-view/utils workspace import."); +} + +const clientEntry = readFileSync(join(distRoot, "client/index.js"), "utf8"); +const compiledHookRegions = [ + "src/hooks/useCallbackRef.ts", + "src/hooks/useDomWidth.ts", + "src/hooks/useUnmount.ts", + "src/hooks/useUpdateEffect.ts", +]; + +for (const sourcePath of compiledHookRegions) { + const start = clientEntry.indexOf(`//#region ${sourcePath}`); + const end = clientEntry.indexOf("//#endregion", start); + const region = start >= 0 && end > start ? clientEntry.slice(start, end) : ""; + if (!region.includes("hookSlots(") || !region.includes("_h$")) { + throw new Error(`${sourcePath} lost compiler-assigned internal hook slots.`); + } +} + +for (const hook of [ + "useCallbackRef", + "useDiffViewContext", + "useDiffWidgetContext", + "useDomWidth", + "useUnmount", + "useUpdateEffect", +]) { + if (!new RegExp(`withSlot\\([^\\n;]*\\b${hook}\\b`).test(clientEntry)) { + throw new Error(`${hook} lost its compiler-assigned call-site scope.`); + } +} + +console.log( + "@git-diff-view/octane dist contains scoped hooks, client/server entries, declarations, styles, and no React runtime." +); diff --git a/packages/octane/scripts/generate-tsrx-types.mjs b/packages/octane/scripts/generate-tsrx-types.mjs new file mode 100644 index 0000000..d031970 --- /dev/null +++ b/packages/octane/scripts/generate-tsrx-types.mjs @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import ts from "typescript"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const sourceRoot = join(packageRoot, "src"); +const outputFlag = process.argv.indexOf("--out-dir"); +const outputRoot = outputFlag === -1 ? null : resolve(packageRoot, process.argv[outputFlag + 1] ?? ""); + +if (!outputRoot) { + throw new Error("generate-tsrx-types requires --out-dir ."); +} + +function filesUnder(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = join(directory, entry.name); + return entry.isDirectory() ? filesUnder(file) : [file]; + }); +} + +function rewriteRelativeSpecifiers(source, transform) { + return source.replace( + /(\b(?:from\s*|import\s*\(\s*|require\s*\(\s*)["'])(\.{1,2}\/[^"']+)(["'])/g, + (_match, before, specifier, after) => `${before}${transform(specifier)}${after}` + ); +} + +const sources = filesUnder(sourceRoot).filter( + (file) => (file.endsWith(".ts") || file.endsWith(".tsrx")) && !file.endsWith(".d.ts") +); +const temporaryRoot = mkdtempSync(join(tmpdir(), "git-diff-view-octane-types-")); +const temporarySource = join(temporaryRoot, "src"); +const temporaryOutput = join(temporaryRoot, "out"); + +try { + symlinkSync(join(packageRoot, "node_modules"), join(temporaryRoot, "node_modules"), "dir"); + + for (const file of sources) { + const relativePath = relative(sourceRoot, file).replace(/\.tsrx$/, ".tsx"); + const target = join(temporarySource, relativePath); + const source = rewriteRelativeSpecifiers(readFileSync(file, "utf8"), (specifier) => + specifier.endsWith(".tsrx") ? specifier.replace(/\.tsrx$/, ".tsx") : specifier + ); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); + } + + const roots = sources.map((file) => join(temporarySource, relative(sourceRoot, file).replace(/\.tsrx$/, ".tsx"))); + const program = ts.createProgram(roots, { + declaration: true, + emitDeclarationOnly: true, + esModuleInterop: true, + jsx: ts.JsxEmit.ReactJSX, + jsxImportSource: "octane", + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noCheck: true, + outDir: temporaryOutput, + preserveSymlinks: true, + rootDir: temporarySource, + skipLibCheck: true, + strictNullChecks: true, + target: ts.ScriptTarget.ESNext, + }); + const result = program.emit(); + if (result.emitSkipped) { + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics] + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")) + .join("\n"); + throw new Error(`TypeScript skipped @git-diff-view/octane declaration generation.\n${diagnostics}`); + } + + for (const source of sources) { + const sourcePath = relative(sourceRoot, source).replace(/\.tsrx$/, ".tsx"); + const emitted = join(temporaryOutput, sourcePath.replace(/\.(?:ts|tsx)$/, ".d.ts")); + const destination = join(outputRoot, sourcePath.replace(/\.(?:ts|tsx)$/, ".d.ts")); + const declaration = rewriteRelativeSpecifiers(readFileSync(emitted, "utf8"), (specifier) => + specifier.replace(/\.(?:ts|tsx)$/, ".js") + ).replace(/[ \t]+$/gm, ""); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, declaration); + } + + console.log(`Generated declarations for ${sources.length} Octane modules.`); +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} diff --git a/packages/octane/scripts/rollup.dts.config.mjs b/packages/octane/scripts/rollup.dts.config.mjs new file mode 100644 index 0000000..f2fdfba --- /dev/null +++ b/packages/octane/scripts/rollup.dts.config.mjs @@ -0,0 +1,14 @@ +import path from "node:path"; +import dts from "rollup-plugin-dts"; + +export default { + input: path.resolve("dist/types/index.d.ts"), + plugins: [dts({ includeExternal: ["@git-diff-view/utils"] })], + // Reason: shared utils are not a published runtime dependency of the adapter, + // so their declarations must be bundled just like the existing React adapter. + external: [/^octane(?:\/.*)?$/, /^reactivity-store(?:\/.*)?$/, /^@git-diff-view\/core(?:\/.*)?$/], + output: { + file: "dist/index.d.ts", + format: "es", + }, +}; diff --git a/packages/octane/scripts/smoke-packed-package.mjs b/packages/octane/scripts/smoke-packed-package.mjs new file mode 100644 index 0000000..562e194 --- /dev/null +++ b/packages/octane/scripts/smoke-packed-package.mjs @@ -0,0 +1,126 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const temporaryRoot = mkdtempSync(join(tmpdir(), "git-diff-view-octane-pack-")); +const reactivityStoreTarball = process.env.REACTIVITY_STORE_TARBALL; + +if (reactivityStoreTarball && !existsSync(reactivityStoreTarball)) { + throw new Error(`REACTIVITY_STORE_TARBALL does not exist: ${reactivityStoreTarball}`); +} + +try { + const output = execFileSync("pnpm", ["pack", "--pack-destination", temporaryRoot], { + cwd: packageRoot, + encoding: "utf8", + }); + const tarball = output.trim().split("\n").at(-1); + if (!tarball) throw new Error("pnpm pack did not return a tarball path."); + const listing = execFileSync("tar", ["-tf", tarball], { encoding: "utf8" }); + + // Reason: registry 0.4.0 predates the Octane export. Pack the dependency + // selected by the development override so the smoke test still exercises + // the publishable package without leaking that override into its manifest. + const resolvedReactivityStoreTarball = reactivityStoreTarball + ? resolve(reactivityStoreTarball) + : execFileSync("pnpm", ["pack", "--pack-destination", temporaryRoot], { + cwd: resolve(fileURLToPath(import.meta.resolve("reactivity-store/package.json")), ".."), + encoding: "utf8", + }) + .trim() + .split("\n") + .at(-1); + if (!resolvedReactivityStoreTarball) throw new Error("Unable to pack reactivity-store."); + const localReactivityStore = `file:${resolvedReactivityStoreTarball}`; + + for (const expected of [ + "package/dist/client/index.js", + "package/dist/server/index.js", + "package/dist/index.d.ts", + "package/dist/css/diff-view.css", + ]) { + if (!listing.includes(expected)) throw new Error(`Packed package omitted ${expected}.`); + } + + writeFileSync( + join(temporaryRoot, "package.json"), + JSON.stringify({ + name: "octane-pack-smoke", + private: true, + type: "module", + dependencies: { + "@git-diff-view/octane": `file:${tarball}`, + octane: "^0.1.36", + "reactivity-store": localReactivityStore, + }, + }) + ); + writeFileSync( + join(temporaryRoot, "pnpm-workspace.yaml"), + `overrides:\n reactivity-store: ${JSON.stringify(localReactivityStore)}\n` + ); + execFileSync("pnpm", ["install", "--ignore-scripts", "--config.auto-install-peers=false"], { + cwd: temporaryRoot, + stdio: "inherit", + }); + const productionPackages = readdirSync(join(temporaryRoot, "node_modules/.pnpm")); + for (const forbiddenRuntime of ["react@", "react-dom@", "use-sync-external-store@"]) { + if (productionPackages.some((entry) => entry.startsWith(forbiddenRuntime))) { + throw new Error(`Packed dependency graph contains ${forbiddenRuntime.slice(0, -1)}.`); + } + } + const packageJson = JSON.parse( + readFileSync(join(temporaryRoot, "node_modules/@git-diff-view/octane/package.json"), "utf8") + ); + if (!packageJson.exports?.["."]?.browser || !packageJson.exports?.["."]?.node) { + throw new Error("Packed package lost browser/node conditional exports."); + } + if (packageJson.dependencies?.["reactivity-store"] !== "^0.4.0") { + throw new Error("Packed package leaked the development reactivity-store override."); + } + const declarationEntry = readFileSync( + join(temporaryRoot, "node_modules/@git-diff-view/octane/dist/index.d.ts"), + "utf8" + ); + if (/from\s+["']@git-diff-view\/utils(?:\/[^"']*)?["']/.test(declarationEntry)) { + throw new Error("Packed declarations retained the private @git-diff-view/utils workspace import."); + } + + execFileSync( + "node", + [ + "--conditions=node", + "--input-type=module", + "--eval", + [ + 'import { DiffModeEnum, DiffView } from "@git-diff-view/octane";', + 'import { renderToString } from "octane/server";', + "const props = {", + ' data: { oldFile: { fileName: "a.ts", content: "a" }, newFile: { fileName: "a.ts", content: "b" }, hunks: ["@@ -1 +1 @@\\n-a\\n+b"] },', + " diffViewHighlight: false,", + " diffViewMode: DiffModeEnum.Unified,", + " diffViewWrap: true,", + "};", + "const result = renderToString(DiffView, props);", + 'if (!result.html.includes("unified-diff-view")) throw new Error("Packed SSR render failed.");', + ].join("\n"), + ], + { cwd: temporaryRoot, stdio: "inherit" } + ); + execFileSync( + "node", + [ + "--conditions=browser", + "--input-type=module", + "--eval", + 'import("@git-diff-view/octane").then((m) => { if (!m.DiffView) process.exit(1); })', + ], + { cwd: temporaryRoot, stdio: "inherit" } + ); + console.log("Packed @git-diff-view/octane installs and renders through browser/SSR entries."); +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} diff --git a/packages/octane/src/_base.css b/packages/octane/src/_base.css new file mode 100644 index 0000000..7c05d4d --- /dev/null +++ b/packages/octane/src/_base.css @@ -0,0 +1,2 @@ +@tailwind components; +@tailwind utilities; diff --git a/packages/octane/src/_com.css b/packages/octane/src/_com.css new file mode 100644 index 0000000..069549b --- /dev/null +++ b/packages/octane/src/_com.css @@ -0,0 +1,275 @@ +* { + box-sizing: border-box; +} + +[data-theme="light"] .diff-style-root { + --diff-border--: #dedede; + --diff-add-content--: #dafbe1; + --diff-del-content--: #ffebe9; + --diff-add-lineNumber--: #aceebb; + --diff-del-lineNumber--: #ffcecb; + --diff-plain-content--: #ffffff; + --diff-expand-content--: #fafafa; + --diff-plain-lineNumber--: #fafafa; + --diff-expand-lineNumber--: #fafafa; + --diff-plain-lineNumber-color--: #555555; + --diff-expand-lineNumber-color--: #555555; + --diff-hunk-content--: #ddf4ff; + --diff-hunk-lineNumber--: #b6e3ff; + --diff-hunk-lineNumber-hover--: #0969da; + --diff-add-content-highlight--: #aceebb; + --diff-del-content-highlight--: #ffcecb; + --diff-add-widget--: #0969d2; + --diff-add-widget-color--: #ffffff; + --diff-empty-content--: #fafafa; + --diff-hunk-content-color--: #777777; +} + +.diff-style-root .diff-line-syntax-raw *, +[data-theme="light"] .diff-line-syntax-raw * { + color: var(--diff-view-light, inherit); + font-weight: var(--diff-view-light-font-weight, inherit); +} + +[data-theme="dark"] .diff-style-root { + --diff-border--: #3d444d; + --diff-add-content--: #18271f; + --diff-del-content--: #23191c; + --diff-add-lineNumber--: #284228; + --diff-del-lineNumber--: #4f2828; + --diff-plain-content--: #0d1117; + --diff-expand-content--: #161b22; + --diff-plain-lineNumber--: #161b22; + --diff-expand-lineNumber--: #161b22; + --diff-plain-lineNumber-color--: #a0aaab; + --diff-expand-lineNumber-color--: #a0aaab; + --diff-hunk-content--: #131d2e; + --diff-hunk-lineNumber--: #0c2d6b; + --diff-hunk-lineNumber-hover--: #1f6feb; + --diff-add-content-highlight--: #2f5732; + --diff-del-content-highlight--: #713431; + --diff-add-widget--: #0969d2; + --diff-add-widget-color--: #ffffff; + --diff-empty-content--: #161b22; + --diff-hunk-content-color--: #9298a0; +} + +[data-theme="dark"] [data-state="diff"], +[data-theme="dark"] [data-state="plain"], +[data-theme="dark"] [data-state="hunk"] { + color: white; +} + +[data-theme="light"] [data-state="diff"], +[data-theme="light"] [data-state="plain"], +[data-theme="light"] [data-state="hunk"] { + color: black; +} + +[data-theme="dark"] .diff-line-syntax-raw * { + color: var(--diff-view-dark, inherit); + font-weight: var(--diff-view-dark-font-weight, inherit); +} + +table, +tr, +td { + border-color: transparent; + border-width: 0px; + text-align: left; +} + +td { + padding: 0; +} + +.diff-line-old-num, +.diff-line-new-num, +.diff-line-num { + text-align: right; +} + +.diff-style-root tr { + content-visibility: auto; +} + +.diff-add-widget-wrapper { + transform-origin: center; + transform: translateX(-50%) !important; +} + +.diff-line-old-content .diff-add-widget-wrapper, +.diff-line-new-content .diff-add-widget-wrapper { + transform: translateX(50%) !important; +} + +.diff-add-widget-wrapper:hover { + transform: translateX(-50%) scale(1.1) !important; +} + +.diff-line-old-content .diff-add-widget-wrapper:hover, +.diff-line-new-content .diff-add-widget-wrapper:hover { + transform: translateX(50%) scale(1.1) !important; +} + +.diff-widget-tooltip { + position: relative; +} + +.diff-add-widget, +.diff-widget-tooltip { + font-family: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + font-size: 100%; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; + color: inherit; + margin: 0; + text-transform: none; + border-width: 0px; + background-color: transparent; + background-image: none; +} + +.diff-widget-tooltip::after { + display: none; + box-sizing: border-box; + background-color: #555555; + position: absolute; + content: attr(data-title); + font-size: 11px; + padding: 1px 2px; + border-radius: 4px; + overflow: hidden; + top: 50%; + white-space: nowrap; + transform: translateY(-50%); + left: calc(100% + 8px); + color: #ffffff; +} + +.diff-widget-tooltip::before { + display: none; + box-sizing: border-box; + content: ""; + position: absolute; + top: 50%; + left: calc(100% - 2px); + transform: translateY(-50%); + border: 6px solid transparent; + border-right-color: #555555; +} + +.diff-widget-tooltip:hover { + background-color: var(--diff-hunk-lineNumber-hover--); + color: white; +} + +.diff-widget-tooltip:hover::before { + display: block; +} + +.diff-widget-tooltip:hover::after { + display: block; +} + +.diff-line-extend-wrapper, +.diff-line-widget-wrapper { + display: flow-root; +} + +/* Multi-select styles for line range selection */ +.diff-multi-select-active.diff-line-new-num, +.diff-multi-select-active.diff-line-old-num, +.diff-multi-select-active.diff-line-num { + z-index: 2; +} + +.diff-multi-select-active.diff-line-content, +.diff-multi-select-active.diff-line-new-content, +.diff-multi-select-active.diff-line-old-content { + position: relative; +} + +.diff-multi-select-active.diff-line-new-num::after, +.diff-multi-select-active.diff-line-old-num::after, +.diff-multi-select-active.diff-line-num::after { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + opacity: 0.15; + background-color: var(--diff-multi-select-bg, #f0c000); + pointer-events: none; +} + +.diff-multi-select-active.diff-line-new-num::before, +.diff-multi-select-active.diff-line-old-num::before, +.diff-multi-select-active.diff-line-num::before { + content: ""; + z-index: 2; + position: absolute; + top: 0; + bottom: 0; + right: -2px; + width: 4px; + background-color: var(--diff-multi-select-border, #2588fa); +} + +.diff-multi-select-active.diff-line-new-content::after, +.diff-multi-select-active.diff-line-old-content::after, +.diff-multi-select-active.diff-line-content::after { + content: ""; + position: absolute; + z-index: 1; + inset: 0; + opacity: 0.15; + background-color: var(--diff-multi-select-bg, #f0c000); + pointer-events: none; +} + +/* Multi-select: ensure proper positioning for line number cells */ +.diff-multi-selecting .diff-line-old-num, +.diff-multi-selecting .diff-line-new-num, +.diff-multi-selecting .diff-line-num { + user-select: none; +} + +.diff-multi-selecting .diff-line-old-content, +.diff-multi-selecting .diff-line-new-content, +.diff-multi-selecting .diff-line-content { + user-select: none; +} + +/* Prevent text selection during multi-line selection */ +.diff-multi-selecting { + user-select: none; +} + +.diff-multi-selecting * { + user-select: none; +} + +/* Hide addWidget button during active dragging selection */ +.diff-multi-selecting .diff-add-widget-wrapper { + display: none; +} + +/* Ensure addWidget button is above selection overlay */ +.diff-multiselect-wrapper .diff-add-widget-wrapper { + z-index: 10 !important; +} + +/* Line number span should not interfere with click events */ +.diff-line-new-num span[data-line-num], +.diff-line-old-num span[data-line-num] { + pointer-events: none; +} + +.diff-multiselect-wrapper .diff-line-old-num, +.diff-multiselect-wrapper .diff-line-new-num, +.diff-multiselect-wrapper .diff-line-num { + cursor: pointer; +} diff --git a/packages/octane/src/_theme.css b/packages/octane/src/_theme.css new file mode 100644 index 0000000..3302cba --- /dev/null +++ b/packages/octane/src/_theme.css @@ -0,0 +1,2 @@ +@import url(highlight.js/styles/github.css); +@import url(highlight.js/styles/github-dark.css); diff --git a/packages/octane/src/components/DiffAddWidget.tsrx b/packages/octane/src/components/DiffAddWidget.tsrx new file mode 100644 index 0000000..6bef253 --- /dev/null +++ b/packages/octane/src/components/DiffAddWidget.tsrx @@ -0,0 +1,92 @@ +import { addWidgetBGName, addWidgetColorName, diffFontSizeName } from "@git-diff-view/utils"; + +import { SplitSide } from "./DiffView.tsrx"; + +import type { DiffFile } from "@git-diff-view/core"; + +export const DiffSplitAddWidget = ({ + side, + className, + lineNumber, + onWidgetClick, + onOpenAddWidget, +}: { + index: number; + lineNumber: number; + diffFile: DiffFile; + side: SplitSide; + className?: string; + onWidgetClick?: (lineNumber: number, side: SplitSide) => void; + onOpenAddWidget: (lineNumber: number, side: SplitSide) => void; +}) => { + return ( +
+ +
+ ); +}; + +export const DiffUnifiedAddWidget = ({ + lineNumber, + side, + onWidgetClick, + onOpenAddWidget, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; + side: SplitSide; + onWidgetClick?: (lineNumber: number, side: SplitSide) => void; + onOpenAddWidget: (lineNumber: number, side: SplitSide) => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/packages/octane/src/components/DiffContent.tsrx b/packages/octane/src/components/DiffContent.tsrx new file mode 100644 index 0000000..c9be828 --- /dev/null +++ b/packages/octane/src/components/DiffContent.tsrx @@ -0,0 +1,238 @@ +import { + DiffLineType, + getSyntaxDiffTemplate, + getSyntaxLineTemplate, + getPlainDiffTemplate, + getPlainLineTemplate, +} from "@git-diff-view/core"; +import { memoFunc, diffFontSizeName, NewLineSymbol } from "@git-diff-view/utils"; + +import { DiffNoNewLine } from "./DiffNoNewLine.tsrx"; + +import type { DiffFile, DiffLine, File } from "@git-diff-view/core"; + +const temp = {}; + +const formatStringToCamelCase = (str: string) => { + if (str.startsWith("--")) return str; + const splitted = str.split("-"); + if (splitted.length === 1) return splitted[0]; + return ( + splitted[0] + + splitted + .slice(1) + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join("") + ); +}; + +export const getStyleObjectFromString = memoFunc((str: string) => { + if (!str) return temp; + const style: Record = {}; + str.split(";").forEach((el) => { + const [property, value] = el.split(":"); + if (!property) return; + + const formattedProperty = formatStringToCamelCase(property.trim()); + style[formattedProperty] = value.trim(); + }); + return style; +}); + +const DiffString = ({ + rawLine, + diffLine, + operator, + plainLine, + enableWrap, +}: { + rawLine: string; + diffLine?: DiffLine; + operator?: "add" | "del"; + plainLine?: File["plainFile"][number]; + enableWrap?: boolean; +}) => { + const changes = diffLine?.changes; + + if (changes?.hasLineChange) { + const isNewLineSymbolChanged = changes.newLineSymbol; + + if (!diffLine?.plainTemplate && typeof getPlainDiffTemplate === "function") { + getPlainDiffTemplate({ diffLine: diffLine!, rawLine, operator: operator! }); + } + + if (diffLine?.plainTemplate) { + return ( + + + {isNewLineSymbolChanged === NewLineSymbol.NEWLINE && ( + + + + )} + + ); + } + } + + if (plainLine && !plainLine?.template) { + // eslint-disable-next-line react-hooks/immutability + plainLine.template = getPlainLineTemplate(plainLine.value); + } + + if (plainLine?.template) { + return ( + + + + ); + } + + return {rawLine}; +}; + +const DiffSyntax = ({ + rawLine, + diffFile, + diffLine, + operator, + syntaxLine, + enableWrap, +}: { + rawLine: string; + diffFile: DiffFile; + diffLine?: DiffLine; + syntaxLine?: File["syntaxFile"][number]; + operator?: "add" | "del"; + enableWrap?: boolean; +}) => { + if (!syntaxLine) { + return ; + } + + const changes = diffLine?.changes; + + if (changes?.hasLineChange) { + const isNewLineSymbolChanged = changes.newLineSymbol; + + if (!diffLine?.syntaxTemplate && typeof getSyntaxDiffTemplate === "function") { + getSyntaxDiffTemplate({ diffFile, diffLine: diffLine!, syntaxLine, operator: operator! }); + } + + if (diffLine?.syntaxTemplate) { + return ( + + + {isNewLineSymbolChanged === NewLineSymbol.NEWLINE && ( + + + + )} + + ); + } + } + + if (!syntaxLine.template) { + // eslint-disable-next-line react-hooks/immutability + syntaxLine.template = getSyntaxLineTemplate(syntaxLine); + } + + if (syntaxLine?.template) { + return ( + + + + ); + } + + return ( + + {syntaxLine?.nodeList?.map(({ node, wrapper }, index) => ( + + {node.value} + + ))} + + ); +}; + +export const DiffContent = ({ + diffFile, + diffLine, + rawLine, + plainLine, + syntaxLine, + enableWrap, + enableHighlight, +}: { + rawLine: string; + plainLine?: File["plainFile"][number]; + syntaxLine?: File["syntaxFile"][number]; + diffLine?: DiffLine; + diffFile: DiffFile; + enableWrap: boolean; + enableHighlight: boolean; +}) => { + const isAdded = diffLine?.type === DiffLineType.Add; + + const isDelete = diffLine?.type === DiffLineType.Delete; + + const isMaxLineLengthToIgnoreSyntax = syntaxLine && syntaxLine?.nodeList?.length > 150; + + return ( +
+ + {isAdded ? "+" : isDelete ? "-" : " "} + + {enableHighlight && syntaxLine && !isMaxLineLengthToIgnoreSyntax ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/octane/src/components/DiffExpand.tsrx b/packages/octane/src/components/DiffExpand.tsrx new file mode 100644 index 0000000..58af9c6 --- /dev/null +++ b/packages/octane/src/components/DiffExpand.tsrx @@ -0,0 +1,23 @@ +export const ExpandDown = ({ className }: { className?: string }) => { + return ( + + ); +}; + +export const ExpandUp = ({ className }: { className?: string }) => { + return ( + + ); +}; + +export const ExpandAll = ({ className }: { className?: string }) => { + return ( + + ); +}; diff --git a/packages/octane/src/components/DiffNoNewLine.tsrx b/packages/octane/src/components/DiffNoNewLine.tsrx new file mode 100644 index 0000000..cbc29b9 --- /dev/null +++ b/packages/octane/src/components/DiffNoNewLine.tsrx @@ -0,0 +1,8 @@ +export const DiffNoNewLine = () => { + return ( + + + + + ); +}; diff --git a/packages/octane/src/components/DiffSplitContentLineNormal.tsrx b/packages/octane/src/components/DiffSplitContentLineNormal.tsrx new file mode 100644 index 0000000..b7d6ea5 --- /dev/null +++ b/packages/octane/src/components/DiffSplitContentLineNormal.tsrx @@ -0,0 +1,162 @@ +import { DiffLineType, type DiffFile, checkDiffLineIncludeChange } from "@git-diff-view/core"; +import { + getContentBG, + getLineNumberBG, + plainLineNumberColorName, + diffAsideWidthName, + emptyBGName, + expandLineNumberColorName, +} from "@git-diff-view/utils"; + +import { SplitSide } from ".."; + +import { DiffSplitAddWidget } from "./DiffAddWidget.tsrx"; +import { DiffContent } from "./DiffContent.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; +import { useDiffWidgetContext } from "./DiffWidgetContext"; + +const InternalDiffSplitLine = ({ + index, + diffFile, + lineNumber, + side, + enableAddWidget, + enableHighlight, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; + enableHighlight: boolean; + enableAddWidget: boolean; +}) => { + const getCurrentSyntaxLine = side === SplitSide.old ? diffFile.getOldSyntaxLine : diffFile.getNewSyntaxLine; + + const getCurrentPlainLine = side === SplitSide.old ? diffFile.getOldPlainLine : diffFile.getNewPlainLine; + + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + const currentLine = side === SplitSide.old ? oldLine : newLine; + + const hasDiff = !!currentLine?.diff; + + const hasContent = !!currentLine?.lineNumber; + + const hasChange = checkDiffLineIncludeChange(currentLine?.diff); + + const isAdded = currentLine?.diff?.type === DiffLineType.Add; + + const isDelete = currentLine?.diff?.type === DiffLineType.Delete; + + const { useDiffContext } = useDiffViewContext(); + + const onAddWidgetClick = useDiffContext.getReadonlyState().onAddWidgetClick; + + const { useWidget } = useDiffWidgetContext(); + + const setWidget = useWidget.getReadonlyState().setWidget; + + const contentBG = getContentBG(isAdded, isDelete, hasDiff); + + const lineNumberBG = getLineNumberBG(isAdded, isDelete, hasDiff); + + const syntaxLine = getCurrentSyntaxLine(currentLine.lineNumber ?? -1); + + const plainLine = getCurrentPlainLine(currentLine.lineNumber ?? -1); + + return ( + + {hasContent ? ( + <> + + {hasDiff && enableAddWidget && ( + onAddWidgetClick.current?.(...props)} + className="absolute left-[100%] z-[1] translate-x-[-50%]" + onOpenAddWidget={(lineNumber, side) => setWidget({ lineNumber: lineNumber, side: side })} + /> + )} + + {currentLine.lineNumber} + + + + + + + ) : ( + + + + )} + + ); +}; + +export const DiffSplitContentLine = ({ + index, + diffFile, + lineNumber, + side, + enableAddWidget, + enableHighlight, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; + enableHighlight: boolean; + enableAddWidget: boolean; +}) => { + const getCurrentLine = side === SplitSide.old ? diffFile.getSplitLeftLine : diffFile.getSplitRightLine; + + const currentLine = getCurrentLine(index); + + if (currentLine?.isHidden) return null; + + return ( + + ); +}; diff --git a/packages/octane/src/components/DiffSplitContentLineWrap.tsrx b/packages/octane/src/components/DiffSplitContentLineWrap.tsrx new file mode 100644 index 0000000..1da83af --- /dev/null +++ b/packages/octane/src/components/DiffSplitContentLineWrap.tsrx @@ -0,0 +1,235 @@ +import { DiffLineType, type DiffFile, checkDiffLineIncludeChange } from "@git-diff-view/core"; +import { + getContentBG, + getLineNumberBG, + plainLineNumberColorName, + emptyBGName, + borderColorName, + expandLineNumberColorName, +} from "@git-diff-view/utils"; + +import { SplitSide } from ".."; + +import { DiffSplitAddWidget } from "./DiffAddWidget.tsrx"; +import { DiffContent } from "./DiffContent.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; +import { useDiffWidgetContext } from "./DiffWidgetContext"; + +const InternalDiffSplitLine = ({ + index, + diffFile, + lineNumber, + enableAddWidget, + enableHighlight, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; + enableHighlight: boolean; + enableAddWidget: boolean; +}) => { + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + const oldSyntaxLine = diffFile.getOldSyntaxLine(oldLine?.lineNumber ?? -1); + + const oldPlainLine = diffFile.getOldPlainLine(oldLine.lineNumber ?? -1); + + const newSyntaxLine = diffFile.getNewSyntaxLine(newLine?.lineNumber ?? -1); + + const newPlainLine = diffFile.getNewPlainLine(newLine.lineNumber ?? -1); + + const hasDiff = !!oldLine?.diff || !!newLine?.diff; + + const hasChange = checkDiffLineIncludeChange(oldLine?.diff) || checkDiffLineIncludeChange(newLine?.diff); + + const oldLineIsDelete = oldLine?.diff?.type === DiffLineType.Delete; + + const newLineIsAdded = newLine?.diff?.type === DiffLineType.Add; + + const { useDiffContext } = useDiffViewContext(); + + const onAddWidgetClick = useDiffContext.getReadonlyState().onAddWidgetClick; + + const { useWidget } = useDiffWidgetContext(); + + const setWidget = useWidget.getReadonlyState().setWidget; + + const hasOldLine = !!oldLine.lineNumber; + + const hasNewLine = !!newLine.lineNumber; + + const oldLineContentBG = getContentBG(false, oldLineIsDelete, hasDiff); + + const oldLineNumberBG = getLineNumberBG(false, oldLineIsDelete, hasDiff); + + const newLineContentBG = getContentBG(newLineIsAdded, false, hasDiff); + + const newLineNumberBG = getLineNumberBG(newLineIsAdded, false, hasDiff); + + return ( + + {hasOldLine ? ( + <> + + {hasDiff && enableAddWidget && ( + onAddWidgetClick.current?.(...props)} + className="absolute left-[100%] z-[1] translate-x-[-50%]" + onOpenAddWidget={(lineNumber, side) => setWidget({ lineNumber: lineNumber, side: side })} + /> + )} + + {oldLine.lineNumber} + + + + {hasDiff && enableAddWidget && ( + onAddWidgetClick.current?.(...props)} + className="absolute right-[100%] z-[1] translate-x-[50%]" + onOpenAddWidget={(lineNumber, side) => setWidget({ lineNumber: lineNumber, side: side })} + /> + )} + + + + ) : ( + + + + )} + {hasNewLine ? ( + <> + + {hasDiff && enableAddWidget && ( + onAddWidgetClick.current?.(...props)} + className="absolute left-[100%] z-[1] translate-x-[-50%]" + onOpenAddWidget={(lineNumber, side) => setWidget({ lineNumber: lineNumber, side: side })} + /> + )} + + {newLine.lineNumber} + + + + {hasDiff && enableAddWidget && ( + onAddWidgetClick.current?.(...props)} + className="absolute right-[100%] z-[1] translate-x-[50%]" + onOpenAddWidget={(lineNumber, side) => setWidget({ lineNumber: lineNumber, side: side })} + /> + )} + + + + ) : ( + + + + )} + + ); +}; + +export const DiffSplitContentLine = ({ + index, + diffFile, + lineNumber, + enableAddWidget, + enableHighlight, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; + enableHighlight: boolean; + enableAddWidget: boolean; +}) => { + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + if (oldLine?.isHidden && newLine?.isHidden) return null; + + return ( + + ); +}; diff --git a/packages/octane/src/components/DiffSplitExtendLineNormal.tsrx b/packages/octane/src/components/DiffSplitExtendLineNormal.tsrx new file mode 100644 index 0000000..5432eec --- /dev/null +++ b/packages/octane/src/components/DiffSplitExtendLineNormal.tsrx @@ -0,0 +1,150 @@ +import { emptyBGName } from "@git-diff-view/utils"; +import * as Octane from "octane"; + +import { SplitSide } from ".."; +import { useDomWidth } from "../hooks/useDomWidth"; +import { useSyncHeight } from "../hooks/useSyncHeight"; + +import { useDiffViewContext } from "./DiffViewContext"; + +import type { DiffFile } from "@git-diff-view/core"; + +const InternalDiffSplitExtendLine = ({ + index, + diffFile, + oldLineExtend, + newLineExtend, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + oldLineExtend: { data: any }; + newLineExtend: { data: any }; + diffFile: DiffFile; + lineNumber: number; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + const renderExtendLine = useDiffContext.useShallowStableSelector((s) => s.renderExtendLine); + + const currentExtend = side === SplitSide.old ? oldLineExtend : newLineExtend; + + const otherSide = side === SplitSide.old ? SplitSide.new : SplitSide.old; + + const currentLineNumber = side === SplitSide.old ? oldLine.lineNumber : newLine.lineNumber; + + const currentSideHasExtend = currentExtend?.data !== undefined && currentExtend?.data !== null; + + const hasExtend = + (oldLineExtend?.data !== undefined && oldLineExtend?.data !== null) || + (newLineExtend?.data !== undefined && newLineExtend?.data !== null); + + const currentExtendRendered = + hasExtend && + renderExtendLine?.({ + diffFile, + side, + lineNumber: currentLineNumber ?? -1, + data: currentExtend?.data, + onUpdate: diffFile.notifyAll, + }); + + useSyncHeight({ + selector: `div[data-line="${lineNumber}-extend-content"]`, + wrapper: `tr[data-line="${lineNumber}-extend"]`, + side: SplitSide[currentSideHasExtend ? side : otherSide], + enable: hasExtend && typeof renderExtendLine === "function", + }); + + const width = useDomWidth({ + selector: side === SplitSide.old ? ".old-diff-table-wrapper" : ".new-diff-table-wrapper", + enable: currentSideHasExtend && typeof renderExtendLine === "function", + }); + + if (!renderExtendLine) return null; + + return ( + + {currentSideHasExtend ? ( + +
+ {width > 0 && currentExtendRendered} +
+ + ) : ( + +
+ + )} + + ); +}; + +export const DiffSplitExtendLine = ({ + index, + diffFile, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + const { oldLineExtend, newLineExtend } = useDiffContext( + Octane.useCallback( + (s) => ({ + oldLineExtend: s.extendData?.oldFile?.[oldLine?.lineNumber ?? -1], + newLineExtend: s.extendData?.newFile?.[newLine?.lineNumber ?? -1], + }), + [oldLine?.lineNumber, newLine?.lineNumber] + ) + ); + + const hasExtend = oldLineExtend?.data || newLineExtend?.data; + + // if the expand action not enabled, the `isHidden` property will never change + const enableExpand = diffFile.getExpandEnabled(); + + const currentLine = side === SplitSide.old ? oldLine : newLine; + + const currentIsShow = hasExtend && (!currentLine.isHidden || !enableExpand); + + if (!currentIsShow) return null; + + return ( + + ); +}; diff --git a/packages/octane/src/components/DiffSplitExtendLineWrap.tsrx b/packages/octane/src/components/DiffSplitExtendLineWrap.tsrx new file mode 100644 index 0000000..f0b3ecb --- /dev/null +++ b/packages/octane/src/components/DiffSplitExtendLineWrap.tsrx @@ -0,0 +1,133 @@ +import { emptyBGName, borderColorName } from "@git-diff-view/utils"; +import * as Octane from "octane"; + +import { SplitSide } from ".."; + +import { useDiffViewContext } from "./DiffViewContext"; + +import type { DiffFile } from "@git-diff-view/core"; + +const InternalDiffSplitExtendLine = ({ + index, + diffFile, + lineNumber, + oldLineExtend, + newLineExtend, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; + oldLineExtend: { data: any }; + newLineExtend: { data: any }; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + // 需要显示的时候才进行方法订阅,可以大幅度提高性能 + const renderExtendLine = useDiffContext.useShallowStableSelector((s) => s.renderExtendLine); + + if (!renderExtendLine) return null; + + const oldExtendRendered = + oldLineExtend?.data && + renderExtendLine?.({ + diffFile, + side: SplitSide.old, + lineNumber: oldLine.lineNumber ?? -1, + data: oldLineExtend.data, + onUpdate: diffFile.notifyAll, + }); + + const newExtendRendered = + newLineExtend?.data && + renderExtendLine?.({ + diffFile, + side: SplitSide.new, + lineNumber: newLine.lineNumber ?? -1, + data: newLineExtend.data, + onUpdate: diffFile.notifyAll, + }); + + return ( + + {oldExtendRendered ? ( + +
{oldExtendRendered}
+ + ) : ( + + )} + {newExtendRendered ? ( + +
{newExtendRendered}
+ + ) : ( + + )} + + ); +}; + +export const DiffSplitExtendLine = ({ + index, + diffFile, + lineNumber, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const oldLine = diffFile.getSplitLeftLine(index); + + const newLine = diffFile.getSplitRightLine(index); + + const { oldLineExtend, newLineExtend } = useDiffContext( + Octane.useCallback( + (s) => ({ + oldLineExtend: s.extendData?.oldFile?.[oldLine?.lineNumber ?? -1], + newLineExtend: s.extendData?.newFile?.[newLine?.lineNumber ?? -1], + }), + [oldLine?.lineNumber, newLine?.lineNumber] + ) + ); + + const hasExtend = oldLineExtend?.data || newLineExtend?.data; + + // if the expand action not enabled, the `isHidden` property will never change + const enableExpand = diffFile.getExpandEnabled(); + + const currentIsShow = hasExtend && ((!oldLine?.isHidden && !newLine?.isHidden) || !enableExpand); + + if (!currentIsShow) return null; + + return ( + + ); +}; diff --git a/packages/octane/src/components/DiffSplitHunkLineNormal.tsrx b/packages/octane/src/components/DiffSplitHunkLineNormal.tsrx new file mode 100644 index 0000000..329560a --- /dev/null +++ b/packages/octane/src/components/DiffSplitHunkLineNormal.tsrx @@ -0,0 +1,315 @@ +import { composeLen, type DiffFile } from "@git-diff-view/core"; +import { + hunkLineNumberBGName, + plainLineNumberColorName, + diffAsideWidthName, + hunkContentBGName, + hunkContentColorName, +} from "@git-diff-view/utils"; + +import { DiffModeEnum, SplitSide } from ".."; +import { useSyncHeight } from "../hooks/useSyncHeight"; + +import { ExpandAll, ExpandDown, ExpandUp } from "./DiffExpand.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; + +const InternalDiffSplitHunkLineGitHub = ({ + index, + diffFile, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const expandEnabled = diffFile.getExpandEnabled(); + + useSyncHeight({ + selector: `tr[data-line="${lineNumber}-hunk"]`, + side: SplitSide[SplitSide.old], + enable: side === SplitSide.new, + }); + + const enableHunkAction = side === SplitSide.old; + + const couldExpand = expandEnabled && currentHunk && currentHunk.splitInfo; + + const isExpandAll = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.endHiddenIndex - currentHunk.splitInfo.startHiddenIndex < composeLen; + + const isFirstLine = currentHunk && currentHunk.isFirst; + + const isLastLine = currentHunk && currentHunk.isLast; + + return ( + + {enableHunkAction ? ( + <> + + {couldExpand ? ( + isFirstLine ? ( + + ) : isLastLine ? ( + + ) : isExpandAll ? ( + + ) : ( + <> + + + + ) + ) : ( +
+ )} + + +
+ {currentHunk.splitInfo?.plainText || currentHunk.text} +
+ + + ) : ( + +
+ + )} + + ); +}; + +const InternalDiffSplitHunkLineGitLab = ({ + index, + diffFile, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const expandEnabled = diffFile.getExpandEnabled(); + + useSyncHeight({ + selector: `tr[data-line="${lineNumber}-hunk"]`, + side: SplitSide[SplitSide.old], + enable: side === SplitSide.new, + }); + + const couldExpand = expandEnabled && currentHunk && currentHunk.splitInfo; + + const isExpandAll = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.endHiddenIndex - currentHunk.splitInfo.startHiddenIndex < composeLen; + + const isFirstLine = currentHunk && currentHunk.isFirst; + + const isLastLine = currentHunk && currentHunk.isLast; + + return ( + + + {couldExpand ? ( + isFirstLine ? ( + + ) : isLastLine ? ( + + ) : isExpandAll ? ( + + ) : ( + <> + + + + ) + ) : ( +
+ )} + + +
+ {currentHunk.splitInfo?.plainText || currentHunk.text} +
+ + + ); +}; + +const InternalDiffSplitHunkLine = ({ + index, + diffFile, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const diffViewMode = useDiffContext.useShallowStableSelector((s) => s.mode); + + if ( + diffViewMode === DiffModeEnum.SplitGitHub || + diffViewMode === DiffModeEnum.Split || + diffViewMode === DiffModeEnum.Unified + ) { + return ; + } else { + return ; + } +}; + +export const DiffSplitHunkLine = ({ + index, + diffFile, + side, + lineNumber, +}: { + index: number; + side: SplitSide; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const currentIsShow = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.startHiddenIndex < currentHunk.splitInfo.endHiddenIndex; + + const currentIsPureHunk = currentHunk && diffFile._getIsPureDiffRender() && !currentHunk.splitInfo; + + if (!currentIsShow && !currentIsPureHunk) return null; + + return ; +}; diff --git a/packages/octane/src/components/DiffSplitHunkLineWrap.tsrx b/packages/octane/src/components/DiffSplitHunkLineWrap.tsrx new file mode 100644 index 0000000..6b0cb76 --- /dev/null +++ b/packages/octane/src/components/DiffSplitHunkLineWrap.tsrx @@ -0,0 +1,339 @@ +import { composeLen, type DiffFile } from "@git-diff-view/core"; +import { + hunkLineNumberBGName, + plainLineNumberColorName, + hunkContentBGName, + hunkContentColorName, + borderColorName, +} from "@git-diff-view/utils"; + +import { DiffModeEnum } from ".."; + +import { ExpandAll, ExpandDown, ExpandUp } from "./DiffExpand.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; + +const DiffSplitHunkLineGitHub = ({ + index, + diffFile, + lineNumber, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const expandEnabled = diffFile.getExpandEnabled(); + + const couldExpand = expandEnabled && currentHunk && currentHunk.splitInfo; + + const isExpandAll = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.endHiddenIndex - currentHunk.splitInfo.startHiddenIndex < composeLen; + + const isFirstLine = currentHunk && currentHunk.isFirst; + + const isLastLine = currentHunk && currentHunk.isLast; + + return ( + + + {couldExpand ? ( + isFirstLine ? ( + + ) : isLastLine ? ( + + ) : isExpandAll ? ( + + ) : ( + <> + + + + ) + ) : ( +
+ )} + + +
+ {currentHunk.splitInfo?.plainText || currentHunk.text} +
+ + + ); +}; + +const DiffSplitHunkLineGitLab = ({ + index, + diffFile, + lineNumber, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const expandEnabled = diffFile.getExpandEnabled(); + + const couldExpand = expandEnabled && currentHunk && currentHunk.splitInfo; + + const isExpandAll = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.endHiddenIndex - currentHunk.splitInfo.startHiddenIndex < composeLen; + + const isFirstLine = currentHunk && currentHunk.isFirst; + + const isLastLine = currentHunk && currentHunk.isLast; + + return ( + + + {couldExpand ? ( + isFirstLine ? ( + + ) : isLastLine ? ( + + ) : isExpandAll ? ( + + ) : ( + <> + + + + ) + ) : ( +
+ )} + + +
+ {currentHunk.splitInfo?.plainText || currentHunk.text} +
+ + + {couldExpand ? ( + isFirstLine ? ( + + ) : isLastLine ? ( + + ) : isExpandAll ? ( + + ) : ( + <> + + + + ) + ) : ( +
+ )} + + +
+ {currentHunk.splitInfo?.plainText || currentHunk.text} +
+ + + ); +}; + +const InternalDiffSplitHunkLine = ({ + index, + diffFile, + lineNumber, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; +}) => { + const { useDiffContext } = useDiffViewContext(); + + const diffViewMode = useDiffContext.useShallowStableSelector((s) => s.mode); + + if ( + diffViewMode === DiffModeEnum.SplitGitHub || + diffViewMode === DiffModeEnum.Split || + diffViewMode === DiffModeEnum.Unified + ) { + return ; + } else { + return ; + } +}; + +export const DiffSplitHunkLine = ({ + index, + diffFile, + lineNumber, +}: { + index: number; + diffFile: DiffFile; + lineNumber: number; +}) => { + const currentHunk = diffFile.getSplitHunkLine(index); + + const currentIsShow = + currentHunk && + currentHunk.splitInfo && + currentHunk.splitInfo.startHiddenIndex < currentHunk.splitInfo.endHiddenIndex; + + const currentIsPureHunk = currentHunk && diffFile._getIsPureDiffRender() && !currentHunk.splitInfo; + + if (!currentIsShow && !currentIsPureHunk) return null; + + return ; +}; diff --git a/packages/octane/src/components/DiffSplitView.tsrx b/packages/octane/src/components/DiffSplitView.tsrx new file mode 100644 index 0000000..9d560a9 --- /dev/null +++ b/packages/octane/src/components/DiffSplitView.tsrx @@ -0,0 +1,43 @@ +import { memo, useEffect, useMemo, useRef } from "octane"; + +import { DiffSplitViewNormal } from "./DiffSplitViewNormal.tsrx"; +import { DiffSplitViewWrap } from "./DiffSplitViewWrap.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; +import { DiffWidgetContext } from "./DiffWidgetContext"; +import { createDiffWidgetStore } from "./tools"; + +import type { DiffFile } from "@git-diff-view/core"; + +export const DiffSplitView = memo(({ diffFile }: { diffFile: DiffFile }) => { + const { useDiffContext } = useDiffViewContext(); + + const useDiffContextRef = useRef(useDiffContext); + + useDiffContextRef.current = useDiffContext; + + const { enableWrap, onCreateUseWidgetHook } = useDiffContext.useShallowStableSelector((s) => ({ + enableWrap: s.enableWrap, + onCreateUseWidgetHook: s.onCreateUseWidgetHook, + })); + + // performance optimization + const useWidget = useMemo(() => createDiffWidgetStore(useDiffContextRef), []); + + const contextValue = useMemo(() => ({ useWidget }), [useWidget]); + + useEffect(() => { + const { setWidget } = useWidget.getReadonlyState(); + + setWidget({}); + }, [diffFile, useWidget]); + + useEffect(() => { + onCreateUseWidgetHook?.(useWidget); + }, [useWidget, onCreateUseWidgetHook]); + + return ( + + {enableWrap ? : } + + ); +}); diff --git a/packages/octane/src/components/DiffSplitViewNormal.tsrx b/packages/octane/src/components/DiffSplitViewNormal.tsrx new file mode 100644 index 0000000..84d8334 --- /dev/null +++ b/packages/octane/src/components/DiffSplitViewNormal.tsrx @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { getSplitContentLines, type DiffFile } from "@git-diff-view/core"; +import { + removeAllSelection, + syncScroll, + diffFontSizeName, + borderColorName, + diffAsideWidthName, + getDiffIdFromElement, +} from "@git-diff-view/utils"; +import { Fragment, memo, useEffect, useRef } from "octane"; +import * as Octane from "octane"; +// @ts-ignore +import { useSyncExternalStore } from "octane"; + +import { useTextWidth } from "../hooks/useTextWidth"; + +import { DiffSplitContentLine } from "./DiffSplitContentLineNormal.tsrx"; +import { DiffSplitExtendLine } from "./DiffSplitExtendLineNormal.tsrx"; +import { DiffSplitHunkLine } from "./DiffSplitHunkLineNormal.tsrx"; +import { DiffSplitWidgetLine } from "./DiffSplitWidgetLineNormal.tsrx"; +import { SplitSide } from "./DiffView.tsrx"; +import { useDiffViewContext } from "./DiffViewContext"; + +import type { NativeMouseEventHandler, OctaneStyle } from "../octane-types"; + +const DiffSplitViewTable = ({ + side, + diffFile, + enableAddWidget, + enableHighlight, + onMouseDown, +}: { + side: SplitSide; + diffFile: DiffFile; + enableHighlight: boolean; + enableAddWidget: boolean; + onMouseDown?: NativeMouseEventHandler; +}) => { + const className = side === SplitSide.new ? "new-diff-table" : "old-diff-table"; + + const lines = getSplitContentLines(diffFile); + + return ( + + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + ))} + + +
{SplitSide[side]} line number{SplitSide[side]} line content
+ ); +}; + +export const DiffSplitViewNormal = memo(({ diffFile }: { diffFile: DiffFile }) => { + const ref1 = useRef(null); + + const ref2 = useRef(null); + + const ref = useRef(null); + + const tempRef = useRef(undefined); + + const splitLineLength = Math.max(diffFile.splitLineLength, diffFile.fileLineLength); + + const { useDiffContext } = useDiffViewContext(); + + const { fontSize, enableAddWidget, enableHighlight } = useDiffContext.useShallowStableSelector((s) => ({ + fontSize: s.fontSize, + enableAddWidget: s.enableAddWidget, + enableHighlight: s.enableHighlight, + })); + + useSyncExternalStore(diffFile.subscribe, diffFile.getUpdateCount, diffFile.getUpdateCount); + + useEffect(() => { + const left = ref1.current; + const right = ref2.current; + if (!left || !right) return; + return syncScroll(left, right); + }, []); + + const font = Octane.useMemo( + () => ({ fontSize: fontSize + "px", fontFamily: "Menlo, Consolas, monospace" }), + [fontSize] + ); + + const _width = useTextWidth({ + text: splitLineLength.toString(), + font, + }); + + const width = Math.max(40, _width + 25); + + const setStyle = (side?: SplitSide) => { + if (!ref.current) return; + if (!side) { + ref.current.textContent = ""; + } else { + const id = `diff-root${diffFile.getId()}`; + ref.current.textContent = `#${id} [data-state="extend"] {user-select: none} \n#${id} [data-state="hunk"] {user-select: none} \n#${id} [data-state="widget"] {user-select: none}`; + } + }; + + const onMouseDown: NativeMouseEventHandler = (e) => { + let ele: Element | null = e.target as Element; + + // need remove all the selection + if (ele && ele instanceof HTMLElement && ele.nodeName === "BUTTON") { + removeAllSelection(); + return; + } + + const id = getDiffIdFromElement(ele as HTMLElement); + + if (id && id !== `diff-root${diffFile.getId()}`) { + return; + } + + while (ele && ele instanceof HTMLElement) { + const state = ele.getAttribute("data-state"); + const side = ele.getAttribute("data-side") as unknown as SplitSide; + if (side) { + if (tempRef.current !== (SplitSide[side] as unknown as SplitSide)) { + tempRef.current = SplitSide[side] as unknown as SplitSide; + setStyle(SplitSide[side] as unknown as SplitSide); + removeAllSelection(); + } + } + if (state) { + if (state === "extend" || state === "hunk" || state === "widget") { + if (tempRef.current !== undefined) { + tempRef.current = undefined; + setStyle(undefined); + removeAllSelection(); + } + return; + } else { + return; + } + } + + ele = ele.parentElement; + } + }; + + return ( +
+