From 5f065c2685cc111536e007a5a395c2b3a1d5010a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 19:56:49 +0700 Subject: [PATCH] Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview - Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps) - Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component - Import shared highlight colors in git-diff-pane.tsx instead of local definitions - Delete duplicated syntax-highlighter.ts from both app and server - Fix TSX dialect to "ts jsx" (correct per Lezer docs) --- package-lock.json | 496 +++++++- package.json | 6 +- packages/app/package.json | 21 +- packages/app/src/components/file-pane.tsx | 245 ++-- packages/app/src/components/git-diff-pane.tsx | 1019 ++++++++--------- packages/app/src/utils/diff-highlighter.ts | 246 ++-- packages/app/src/utils/syntax-highlighter.ts | 219 ---- packages/highlight/package.json | 36 + .../highlight/src/__tests__/colors.test.ts | 65 ++ .../src/__tests__/highlighter.test.ts | 125 ++ .../highlight/src/__tests__/parsers.test.ts | 74 ++ packages/highlight/src/colors.ts | 47 + packages/highlight/src/highlighter.ts | 111 ++ packages/highlight/src/index.ts | 4 + packages/highlight/src/parsers.ts | 78 ++ packages/highlight/src/types.ts | 26 + packages/highlight/tsconfig.json | 20 + packages/server/package.json | 17 +- .../src/server/utils/diff-highlighter.ts | 280 +++-- .../src/server/utils/syntax-highlighter.ts | 219 ---- 20 files changed, 1978 insertions(+), 1376 deletions(-) delete mode 100644 packages/app/src/utils/syntax-highlighter.ts create mode 100644 packages/highlight/package.json create mode 100644 packages/highlight/src/__tests__/colors.test.ts create mode 100644 packages/highlight/src/__tests__/highlighter.test.ts create mode 100644 packages/highlight/src/__tests__/parsers.test.ts create mode 100644 packages/highlight/src/colors.ts create mode 100644 packages/highlight/src/highlighter.ts create mode 100644 packages/highlight/src/index.ts create mode 100644 packages/highlight/src/parsers.ts create mode 100644 packages/highlight/src/types.ts create mode 100644 packages/highlight/tsconfig.json delete mode 100644 packages/server/src/server/utils/syntax-highlighter.ts diff --git a/package-lock.json b/package-lock.json index e60c9e56a..59b019386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "AGPL-3.0-or-later", "workspaces": [ "packages/expo-two-way-audio", + "packages/highlight", "packages/server", "packages/app", "packages/relay", @@ -19,7 +20,8 @@ "packages/cli" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.11" + "@anthropic-ai/claude-agent-sdk": "^0.2.11", + "@modelcontextprotocol/sdk": "^1.27.1" }, "devDependencies": { "get-port-cli": "^3.0.0", @@ -6596,6 +6598,10 @@ "resolved": "packages/expo-two-way-audio", "link": true }, + "node_modules/@getpaseo/highlight": { + "resolved": "packages/highlight", + "link": true + }, "node_modules/@getpaseo/relay": { "resolved": "packages/relay", "link": true @@ -6708,6 +6714,18 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -7901,6 +7919,365 @@ "node": ">=10" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -21828,6 +22205,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -22250,7 +22636,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -22682,6 +23067,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -23639,6 +24030,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -23908,6 +24308,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", @@ -29935,6 +30341,32 @@ "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -34276,24 +34708,10 @@ "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", "@getpaseo/expo-two-way-audio": "0.1.32", + "@getpaseo/highlight": "*", "@getpaseo/server": "0.1.32", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", - "@lezer/common": "^1.5.0", - "@lezer/cpp": "^1.1.5", - "@lezer/css": "^1.3.0", - "@lezer/go": "^1.0.1", - "@lezer/highlight": "^1.2.3", - "@lezer/html": "^1.3.13", - "@lezer/java": "^1.1.3", - "@lezer/javascript": "^1.5.4", - "@lezer/json": "^1.0.3", - "@lezer/markdown": "^1.6.2", - "@lezer/php": "^1.0.5", - "@lezer/python": "^1.1.18", - "@lezer/rust": "^1.0.2", - "@lezer/xml": "^1.0.6", - "@lezer/yaml": "^1.0.4", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", @@ -34332,7 +34750,6 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", - "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.4", @@ -34511,6 +34928,32 @@ "invariant": "^2.2.4" } }, + "packages/highlight": { + "name": "@getpaseo/highlight", + "version": "0.1.32", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/cpp": "^1.1.5", + "@lezer/css": "^1.3.0", + "@lezer/go": "^1.0.1", + "@lezer/highlight": "^1.2.3", + "@lezer/html": "^1.3.13", + "@lezer/java": "^1.1.3", + "@lezer/javascript": "^1.5.4", + "@lezer/json": "^1.0.3", + "@lezer/markdown": "^1.6.2", + "@lezer/php": "^1.0.5", + "@lezer/python": "^1.1.18", + "@lezer/rust": "^1.0.2", + "@lezer/xml": "^1.0.6", + "@lezer/yaml": "^1.0.4", + "lezer-elixir": "^1.1.2" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } + }, "packages/relay": { "name": "@getpaseo/relay", "version": "0.1.32", @@ -34534,22 +34977,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", + "@getpaseo/highlight": "*", "@getpaseo/relay": "0.1.32", - "@lezer/common": "^1.5.0", - "@lezer/cpp": "^1.1.5", - "@lezer/css": "^1.3.0", - "@lezer/go": "^1.0.1", - "@lezer/highlight": "^1.2.3", - "@lezer/html": "^1.3.13", - "@lezer/java": "^1.1.3", - "@lezer/javascript": "^1.5.4", - "@lezer/json": "^1.0.3", - "@lezer/markdown": "^1.6.2", - "@lezer/php": "^1.0.5", - "@lezer/python": "^1.1.18", - "@lezer/rust": "^1.0.2", - "@lezer/xml": "^1.0.6", - "@lezer/yaml": "^1.0.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", "@sctg/sentencepiece-js": "^1.1.0", @@ -34560,7 +34989,6 @@ "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-uri": "^3.1.0", - "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", diff --git a/package.json b/package.json index 43e3e5229..e1e37ae6d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "workspaces": [ "packages/expo-two-way-audio", + "packages/highlight", "packages/server", "packages/app", "packages/relay", @@ -56,10 +57,10 @@ "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" }, "devDependencies": { - "prettier": "^3.5.3", "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", + "prettier": "^3.5.3", "react": "19.1.4", "react-dom": "19.1.4", "typescript": "^5.9.3" @@ -81,6 +82,7 @@ "react-dom": "19.1.4" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.11" + "@anthropic-ai/claude-agent-sdk": "^0.2.11", + "@modelcontextprotocol/sdk": "^1.27.1" } } diff --git a/packages/app/package.json b/packages/app/package.json index 79ae4cf17..e00807fb8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "build:workspace-deps": "npm run build --prefix ../expo-two-way-audio", + "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio", "eas-build-post-install": "npm run build:workspace-deps", "android": "npm run android:development", "android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug", @@ -22,7 +22,7 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", - "build:web": "expo export --platform web", + "build:web": "npm run build:workspace-deps && expo export --platform web", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main" }, "dependencies": { @@ -32,24 +32,10 @@ "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", "@getpaseo/expo-two-way-audio": "0.1.32", + "@getpaseo/highlight": "*", "@getpaseo/server": "0.1.32", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", - "@lezer/common": "^1.5.0", - "@lezer/cpp": "^1.1.5", - "@lezer/css": "^1.3.0", - "@lezer/go": "^1.0.1", - "@lezer/highlight": "^1.2.3", - "@lezer/html": "^1.3.13", - "@lezer/java": "^1.1.3", - "@lezer/javascript": "^1.5.4", - "@lezer/json": "^1.0.3", - "@lezer/markdown": "^1.6.2", - "@lezer/php": "^1.0.5", - "@lezer/python": "^1.1.18", - "@lezer/rust": "^1.0.2", - "@lezer/xml": "^1.0.6", - "@lezer/yaml": "^1.0.4", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", @@ -88,7 +74,6 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", - "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.4", diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e42514849..99466f833 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; +import React, { useCallback, useMemo, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' import { ActivityIndicator, Image as RNImage, @@ -10,65 +10,152 @@ import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, -} from "react-native"; -import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; -import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; +} from 'react-native' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +import { Fonts } from '@/constants/theme' +import { useSessionStore, type ExplorerFile } from '@/stores/session-store' import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, -} from "@/components/web-desktop-scrollbar"; +} from '@/components/web-desktop-scrollbar' +import { + highlightCode, + darkHighlightColors, + lightHighlightColors, + type HighlightToken, + type HighlightStyle, +} from '@getpaseo/highlight' + +interface CodeLineProps { + tokens: HighlightToken[] + lineNumber: number + gutterWidth: number + colorMap: Record + baseColor: string +} + +interface FilePreviewBodyProps { + preview: ExplorerFile | null + isLoading: boolean + showDesktopWebScrollbar: boolean + isMobile: boolean + filePath: string +} function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; + if (typeof value !== 'string') { + return null } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null } function formatFileSize({ size }: { size: number }): string { if (size < 1024) { - return `${size} B`; + return `${size} B` } if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024).toFixed(1)} KB` } - return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB` } +const CodeLine = React.memo(function CodeLine({ + tokens, + lineNumber, + gutterWidth, + colorMap, + baseColor, +}: CodeLineProps) { + return ( + + + {String(lineNumber)} + + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + + ) +}) + +const codeLineStyles = StyleSheet.create((theme) => ({ + line: { + flexDirection: 'row', + }, + gutter: { + alignItems: 'flex-end', + paddingRight: theme.spacing[3], + flexShrink: 0, + }, + gutterText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.45, + opacity: 0.4, + }, + lineText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.45, + flex: 1, + }, +})) + function FilePreviewBody({ preview, isLoading, showDesktopWebScrollbar, isMobile, -}: { - preview: ExplorerFile | null; - isLoading: boolean; - showDesktopWebScrollbar: boolean; - isMobile: boolean; -}) { - const enablePreviewDesktopScrollbar = showDesktopWebScrollbar; - const previewScrollRef = useRef(null); - const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); + filePath, +}: FilePreviewBodyProps) { + const { theme } = useUnistyles() + const isDark = theme.colors.surface0 === '#18181c' + const colorMap = isDark ? darkHighlightColors : lightHighlightColors + const baseColor = isDark ? '#c9d1d9' : '#24292f' + + const enablePreviewDesktopScrollbar = showDesktopWebScrollbar + const previewScrollRef = useRef(null) + const previewScrollbarMetrics = useWebDesktopScrollbarMetrics() + + const highlightedLines = useMemo(() => { + if (!preview || preview.kind !== 'text') { + return null + } + + return highlightCode(preview.content ?? '', filePath) + }, [preview?.kind, preview?.content, filePath]) + + const gutterWidth = useMemo(() => { + if (!highlightedLines) return 0 + const digits = String(highlightedLines.length).length + return Math.max(digits * 8 + 12, 28) + }, [highlightedLines]) const handlePreviewScroll = useCallback( (event: NativeSyntheticEvent) => { if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onScroll(event); + previewScrollbarMetrics.onScroll(event) } }, [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ); + ) const handlePreviewLayout = useCallback( (event: LayoutChangeEvent) => { if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onLayout(event); + previewScrollbarMetrics.onLayout(event) } }, [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ); + ) if (isLoading && !preview) { return ( @@ -76,7 +163,7 @@ function FilePreviewBody({ Loading file… - ); + ) } if (!preview) { @@ -84,10 +171,26 @@ function FilePreviewBody({ No preview available - ); + ) } - if (preview.kind === "text") { + if (preview.kind === 'text') { + const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]] + const codeLines = ( + + {lines.map((tokens, index) => ( + + ))} + + ) + return ( {isMobile ? ( - - {preview.content} - + {codeLines} ) : ( - {preview.content} + {codeLines} )} @@ -122,14 +221,14 @@ function FilePreviewBody({ enabled={enablePreviewDesktopScrollbar} metrics={previewScrollbarMetrics} onScrollToOffset={(nextOffset) => { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) }} /> - ); + ) } - if (preview.kind === "image" && preview.content) { + if (preview.kind === 'image' && preview.content) { return ( { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) }} /> - ); + ) } return ( @@ -170,7 +267,7 @@ function FilePreviewBody({ Binary preview unavailable {formatFileSize({ size: preview.size })} - ); + ) } export function FilePane({ @@ -178,34 +275,33 @@ export function FilePane({ workspaceRoot, filePath, }: { - serverId: string; - workspaceRoot: string; - filePath: string; + serverId: string + workspaceRoot: string + filePath: string }) { - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); - const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]) + const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]) const query = useQuery({ - queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], + queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath], enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath), queryFn: async () => { if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) { - return { file: null as ExplorerFile | null, error: "Host is not connected" }; + return { file: null as ExplorerFile | null, error: 'Host is not connected' } } const payload = await client.exploreFileSystem( normalizedWorkspaceRoot, normalizedFilePath, - "file" - ); - return { file: payload.file ?? null, error: payload.error ?? null }; + 'file' + ) + return { file: payload.file ?? null, error: payload.error ?? null } }, staleTime: 5_000, - }); + }) return ( @@ -220,9 +316,10 @@ export function FilePane({ isLoading={query.isFetching} showDesktopWebScrollbar={showDesktopWebScrollbar} isMobile={isMobile} + filePath={filePath} /> - ); + ) } const styles = StyleSheet.create((theme) => ({ @@ -233,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({ }, centerState: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', padding: theme.spacing[4], }, loadingText: { @@ -245,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({ errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, - textAlign: "center", + textAlign: 'center', }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - textAlign: "center", + textAlign: 'center', }, binaryMetaText: { marginTop: theme.spacing[2], @@ -268,20 +365,14 @@ const styles = StyleSheet.create((theme) => ({ previewCodeScrollContent: { padding: theme.spacing[4], }, - codeText: { - fontFamily: Fonts.mono, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.45, - color: theme.colors.foreground, - }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, previewImage: { - width: "100%", + width: '100%', height: 420, }, -})); +})) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 26642b1eb..c1ccab82e 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,5 +1,14 @@ -import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react"; -import { useRouter } from "expo-router"; +import { + useState, + useCallback, + useEffect, + useId, + useMemo, + useRef, + memo, + type ReactElement, +} from 'react' +import { useRouter } from 'expo-router' import { View, Text, @@ -10,14 +19,13 @@ import { type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, -} from "react-native"; -import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +} from 'react-native' +import { ScrollView, type ScrollView as ScrollViewType } from 'react-native-gesture-handler' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +import AsyncStorage from '@react-native-async-storage/async-storage' import { Archive, ChevronDown, - GitBranch, GitCommitHorizontal, GitMerge, @@ -26,114 +34,69 @@ import { RefreshCcw, Upload, WrapText, -} from "lucide-react-native"; -import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; +} from 'lucide-react-native' +import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { useCheckoutDiffQuery, type ParsedDiffFile, type DiffLine, type HighlightToken, -} from "@/hooks/use-checkout-diff-query"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; -import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; -import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; -import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { Fonts } from "@/constants/theme"; -import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; +} from '@/hooks/use-checkout-diff-query' +import { useCheckoutStatusQuery } from '@/hooks/use-checkout-status-query' +import { useCheckoutPrStatusQuery } from '@/hooks/use-checkout-pr-status-query' +import { useHorizontalScrollOptional } from '@/contexts/horizontal-scroll-context' +import { useExplorerSidebarAnimation } from '@/contexts/explorer-sidebar-animation-context' +import { + darkHighlightColors, + lightHighlightColors, + type HighlightStyle as HighlightStyleKey, +} from '@getpaseo/highlight' +import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from '@/constants/layout' +import { Fonts } from '@/constants/theme' +import { shouldAnchorHeaderBeforeCollapse } from '@/utils/git-diff-scroll' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { GitHubIcon } from "@/components/icons/github-icon"; -import { - buildGitActions, - type GitActions, -} from "@/components/git-actions-policy"; +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { GitHubIcon } from '@/components/icons/github-icon' +import { buildGitActions, type GitActions } from '@/components/git-actions-policy' import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, -} from "@/components/web-desktop-scrollbar"; -import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; -import { openExternalUrl } from "@/utils/open-external-url"; -import { GitActionsSplitButton } from "@/components/git-actions-split-button"; +} from '@/components/web-desktop-scrollbar' +import { buildNewAgentRoute, resolveNewAgentWorkingDir } from '@/utils/new-agent-routing' +import { openExternalUrl } from '@/utils/open-external-url' +import { GitActionsSplitButton } from '@/components/git-actions-split-button' -export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; +export type { GitActionId, GitAction, GitActions } from '@/components/git-actions-policy' function openURLInNewTab(url: string): void { - void openExternalUrl(url); + void openExternalUrl(url) } -type HighlightStyle = NonNullable; +type HighlightStyle = NonNullable interface HighlightedTextProps { - tokens: HighlightToken[]; - baseStyle: HighlightStyle | null; - lineType: "add" | "remove" | "context" | "header"; + tokens: HighlightToken[] + baseStyle: HighlightStyle | null + lineType: 'add' | 'remove' | 'context' | 'header' } -// GitHub syntax highlight colors for dark/light modes -const darkHighlightColors: Record = { - keyword: "#ff7b72", - comment: "#8b949e", - string: "#a5d6ff", - number: "#79c0ff", - literal: "#79c0ff", - function: "#d2a8ff", - definition: "#d2a8ff", - class: "#ffa657", - type: "#ff7b72", - tag: "#7ee787", - attribute: "#79c0ff", - property: "#79c0ff", - variable: "#c9d1d9", - operator: "#79c0ff", - punctuation: "#c9d1d9", - regexp: "#a5d6ff", - escape: "#79c0ff", - meta: "#8b949e", - heading: "#79c0ff", - link: "#a5d6ff", -}; - -const lightHighlightColors: Record = { - keyword: "#cf222e", - comment: "#6e7781", - string: "#0a3069", - number: "#0550ae", - literal: "#0550ae", - function: "#8250df", - definition: "#8250df", - class: "#953800", - type: "#cf222e", - tag: "#116329", - attribute: "#0550ae", - property: "#0550ae", - variable: "#24292f", - operator: "#0550ae", - punctuation: "#24292f", - regexp: "#0a3069", - escape: "#0550ae", - meta: "#6e7781", - heading: "#0550ae", - link: "#0a3069", -}; - function HighlightedText({ tokens, lineType }: HighlightedTextProps) { - const { theme } = useUnistyles(); - const isDark = theme.colors.surface0 === "#18181c"; + const { theme } = useUnistyles() + const isDark = theme.colors.surface0 === '#18181c' // Get color for a highlight style const getTokenColor = (style: HighlightStyle | null): string => { - const baseColor = isDark ? "#c9d1d9" : "#24292f"; - if (!style) return baseColor; - const colors = isDark ? darkHighlightColors : lightHighlightColors; - return colors[style] ?? baseColor; - }; + const baseColor = isDark ? '#c9d1d9' : '#24292f' + if (!style) return baseColor + const colors = isDark ? darkHighlightColors : lightHighlightColors + return colors[style as HighlightStyleKey] ?? baseColor + } return ( @@ -143,59 +106,64 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) { ))} - ); + ) } - interface DiffFileSectionProps { - file: ParsedDiffFile; - isExpanded: boolean; - onToggle: (path: string) => void; - onHeaderHeightChange?: (path: string, height: number) => void; - testID?: string; + file: ParsedDiffFile + isExpanded: boolean + onToggle: (path: string) => void + onHeaderHeightChange?: (path: string, height: number) => void + testID?: string } -function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) { +function DiffLineView({ + line, + lineNumber, + gutterWidth, +}: { + line: DiffLine + lineNumber: number | null + gutterWidth: number +}) { return ( - - {lineNumber != null ? String(lineNumber) : ""} + + {lineNumber != null ? String(lineNumber) : ''} - {line.tokens && line.type !== "header" ? ( - + {line.tokens && line.type !== 'header' ? ( + ) : ( - {line.content || " "} + {line.content || ' '} )} - ); + ) } const DiffFileHeader = memo(function DiffFileHeader({ @@ -205,69 +173,61 @@ const DiffFileHeader = memo(function DiffFileHeader({ onHeaderHeightChange, testID, }: DiffFileSectionProps) { - const layoutYRef = useRef(null); - const pressHandledRef = useRef(false); - const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); + const layoutYRef = useRef(null) + const pressHandledRef = useRef(false) + const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null) const toggleExpanded = useCallback(() => { - pressHandledRef.current = true; - onToggle(file.path); - }, [file.path, onToggle]); + pressHandledRef.current = true + onToggle(file.path) + }, [file.path, onToggle]) return ( { - layoutYRef.current = event.nativeEvent.layout.y; - onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); + layoutYRef.current = event.nativeEvent.layout.y + onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height) }} testID={testID} > [ - styles.fileHeader, - pressed && styles.fileHeaderPressed, - ]} + style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]} // Android: prevent parent pan/scroll gestures from canceling the tap release. cancelable={false} onPressIn={(event) => { - pressHandledRef.current = false; + pressHandledRef.current = false pressInRef.current = { ts: Date.now(), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY, - }; + } }} onPressOut={(event) => { if ( - Platform.OS !== "web" && + Platform.OS !== 'web' && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current ) { - const durationMs = Date.now() - pressInRef.current.ts; - const dx = event.nativeEvent.pageX - pressInRef.current.pageX; - const dy = event.nativeEvent.pageY - pressInRef.current.pageY; - const distance = Math.hypot(dx, dy); + const durationMs = Date.now() - pressInRef.current.ts + const dx = event.nativeEvent.pageX - pressInRef.current.pageX + const dy = event.nativeEvent.pageY - pressInRef.current.pageY + const distance = Math.hypot(dx, dy) // Sticky headers on Android can emit pressIn/pressOut without onPress. // Treat short, low-movement interactions as taps. if (durationMs <= 500 && distance <= 12) { - toggleExpanded(); + toggleExpanded() } } }} onPress={toggleExpanded} > - {file.path.split("/").pop()} + {file.path.split('/').pop()} - {file.path.includes("/") - ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` - : ""} + {file.path.includes('/') ? ` ${file.path.slice(0, file.path.lastIndexOf('/'))}` : ''} {file.isNew && ( @@ -286,8 +246,8 @@ const DiffFileHeader = memo(function DiffFileHeader({ - ); -}); + ) +}) function DiffFileBody({ file, @@ -295,89 +255,93 @@ function DiffFileBody({ onBodyHeightChange, testID, }: { - file: ParsedDiffFile; - wrapLines: boolean; - onBodyHeightChange?: (path: string, height: number) => void; - testID?: string; + file: ParsedDiffFile + wrapLines: boolean + onBodyHeightChange?: (path: string, height: number) => void + testID?: string }) { - const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const horizontalScroll = useHorizontalScrollOptional(); - const scrollId = useId(); - const scrollViewRef = useRef(null); + const [scrollViewWidth, setScrollViewWidth] = useState(0) + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true) + const horizontalScroll = useHorizontalScrollOptional() + const scrollId = useId() + const scrollViewRef = useRef(null) // Get the close gesture ref from animation context (may not be available outside sidebar) - let closeGestureRef: React.MutableRefObject | undefined; + let closeGestureRef: React.MutableRefObject | undefined try { - const animation = useExplorerSidebarAnimation(); - closeGestureRef = animation.closeGestureRef; + const animation = useExplorerSidebarAnimation() + closeGestureRef = animation.closeGestureRef } catch { // Not inside ExplorerSidebarAnimationProvider, which is fine } // Register/unregister scroll offset tracking useEffect(() => { - if (!horizontalScroll) return; + if (!horizontalScroll) return // Start at 0 (not scrolled) - horizontalScroll.registerScrollOffset(scrollId, 0); + horizontalScroll.registerScrollOffset(scrollId, 0) return () => { - horizontalScroll.unregisterScrollOffset(scrollId); - }; - }, [horizontalScroll, scrollId]); + horizontalScroll.unregisterScrollOffset(scrollId) + } + }, [horizontalScroll, scrollId]) const handleScroll = useCallback( (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x; + const offsetX = event.nativeEvent.contentOffset.x // Track if we're at the left edge (with small threshold for float precision) - setIsAtLeftEdge(offsetX <= 1); + setIsAtLeftEdge(offsetX <= 1) if (horizontalScroll) { - horizontalScroll.registerScrollOffset(scrollId, offsetX); + horizontalScroll.registerScrollOffset(scrollId, offsetX) } }, [horizontalScroll, scrollId] - ); + ) return ( { - onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height) }} testID={testID} > {(() => { - if (file.status === "too_large" || file.status === "binary") { + if (file.status === 'too_large' || file.status === 'binary') { return ( - {file.status === "binary" ? "Binary file" : "Diff too large to display"} + {file.status === 'binary' ? 'Binary file' : 'Diff too large to display'} - ); + ) } const linesContent = (() => { - let maxLineNo = 0; + let maxLineNo = 0 for (const hunk of file.hunks) { - maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); + maxLineNo = Math.max( + maxLineNo, + hunk.oldStart + hunk.oldCount, + hunk.newStart + hunk.newCount + ) } - const digitCount = Math.max(1, String(maxLineNo).length); - const gutterWidth = digitCount * 8 + 12; + const digitCount = Math.max(1, String(maxLineNo).length) + const gutterWidth = digitCount * 8 + 12 return file.hunks.map((hunk, hunkIndex) => { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; + let oldLineNo = hunk.oldStart + let newLineNo = hunk.newStart return hunk.lines.map((line, lineIndex) => { - let lineNumber: number | null = null; - if (line.type === "remove") { - lineNumber = oldLineNo; - oldLineNo++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; + let lineNumber: number | null = null + if (line.type === 'remove') { + lineNumber = oldLineNo + oldLineNo++ + } else if (line.type === 'add') { + lineNumber = newLineNo + newLineNo++ + } else if (line.type === 'context') { + lineNumber = newLineNo + oldLineNo++ + newLineNo++ } return ( - ); - }); - }); - })(); + ) + }) + }) + })() if (wrapLines) { return ( - - {linesContent} - + {linesContent} - ); + ) } return ( @@ -419,67 +381,74 @@ function DiffFileBody({ // activates and closes the sidebar. waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > - 0 && { minWidth: scrollViewWidth }]}> + 0 && { minWidth: scrollViewWidth }]} + > {linesContent} - ); + ) })()} - ); + ) } interface GitDiffPaneProps { - serverId: string; - workspaceId?: string | null; - cwd: string; - hideHeaderRow?: boolean; + serverId: string + workspaceId?: string | null + cwd: string + hideHeaderRow?: boolean } type DiffFlatItem = - | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } - | { type: "body"; file: ParsedDiffFile; fileIndex: number }; + | { type: 'header'; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } + | { type: 'body'; file: ParsedDiffFile; fileIndex: number } export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { - const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const router = useRouter(); - const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); - const [actionError, setActionError] = useState(null); - const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); - const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); - const [wrapLines, setWrapLines] = useState(false); + const { theme } = useUnistyles() + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile + const router = useRouter() + const [diffModeOverride, setDiffModeOverride] = useState<'uncommitted' | 'base' | null>(null) + const [actionError, setActionError] = useState(null) + const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false) + const [shipDefault, setShipDefault] = useState<'merge' | 'pr'>('merge') + const [wrapLines, setWrapLines] = useState(false) useEffect(() => { - AsyncStorage.getItem("diff-wrap-lines").then((value) => { - if (value === "true") setWrapLines(true); - }); - }, []); + AsyncStorage.getItem('diff-wrap-lines').then((value) => { + if (value === 'true') setWrapLines(true) + }) + }, []) const handleToggleWrapLines = useCallback(() => { setWrapLines((prev) => { - const next = !prev; - AsyncStorage.setItem("diff-wrap-lines", String(next)); - return next; - }); - }, []); + const next = !prev + AsyncStorage.setItem('diff-wrap-lines', String(next)) + return next + }) + }, []) - const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = - useCheckoutStatusQuery({ serverId, cwd }); - const gitStatus = status && status.isGit ? status : null; - const isGit = Boolean(gitStatus); - const notGit = status !== null && !status.isGit && !status.error; + const { + status, + isLoading: isStatusLoading, + isFetching: isStatusFetching, + isError: isStatusError, + error: statusError, + refresh: refreshStatus, + } = useCheckoutStatusQuery({ serverId, cwd }) + const gitStatus = status && status.isGit ? status : null + const isGit = Boolean(gitStatus) + const notGit = status !== null && !status.isGit && !status.error const statusErrorMessage = status?.error?.message ?? - (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = gitStatus?.baseRef ?? undefined; + (isStatusError && statusError instanceof Error ? statusError.message : null) + const baseRef = gitStatus?.baseRef ?? undefined // Auto-select diff mode based on state: uncommitted when dirty, base when clean - const hasUncommittedChanges = Boolean(gitStatus?.isDirty); - const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base"; - const diffMode = diffModeOverride ?? autoDiffMode; + const hasUncommittedChanges = Boolean(gitStatus?.isDirty) + const autoDiffMode = hasUncommittedChanges ? 'uncommitted' : 'base' + const diffMode = diffModeOverride ?? autoDiffMode const { files, @@ -495,7 +464,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi mode: diffMode, baseRef, enabled: isGit, - }); + }) const { status: prStatus, githubFeaturesEnabled, @@ -505,142 +474,142 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi serverId, cwd, enabled: isGit, - }); + }) // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches - const [isManualRefresh, setIsManualRefresh] = useState(false); - const [expandedByPath, setExpandedByPath] = useState>({}); - const diffListRef = useRef>(null); - const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); - const diffListScrollOffsetRef = useRef(0); - const diffListViewportHeightRef = useRef(0); - const headerHeightByPathRef = useRef>({}); - const bodyHeightByPathRef = useRef>({}); - const defaultHeaderHeightRef = useRef(44); + const [isManualRefresh, setIsManualRefresh] = useState(false) + const [expandedByPath, setExpandedByPath] = useState>({}) + const diffListRef = useRef>(null) + const diffScrollbarMetrics = useWebDesktopScrollbarMetrics() + const diffListScrollOffsetRef = useRef(0) + const diffListViewportHeightRef = useRef(0) + const headerHeightByPathRef = useRef>({}) + const bodyHeightByPathRef = useRef>({}) + const defaultHeaderHeightRef = useRef(44) const handleRefresh = useCallback(() => { - setIsManualRefresh(true); - void refreshDiff(); - void refreshStatus(); - void refreshPrStatus(); - }, [refreshDiff, refreshStatus, refreshPrStatus]); + setIsManualRefresh(true) + void refreshDiff() + void refreshStatus() + void refreshPrStatus() + }, [refreshDiff, refreshStatus, refreshPrStatus]) const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { - return null; + return null } - return `@paseo:changes-ship-default:${gitStatus.repoRoot}`; - }, [gitStatus?.repoRoot]); + return `@paseo:changes-ship-default:${gitStatus.repoRoot}` + }, [gitStatus?.repoRoot]) useEffect(() => { if (!shipDefaultStorageKey) { - return; + return } - let isActive = true; + let isActive = true AsyncStorage.getItem(shipDefaultStorageKey) .then((value) => { - if (!isActive) return; - if (value === "pr" || value === "merge") { - setShipDefault(value); + if (!isActive) return + if (value === 'pr' || value === 'merge') { + setShipDefault(value) } }) - .catch(() => undefined); + .catch(() => undefined) return () => { - isActive = false; - }; - }, [shipDefaultStorageKey]); + isActive = false + } + }, [shipDefaultStorageKey]) const persistShipDefault = useCallback( - async (next: "merge" | "pr") => { - setShipDefault(next); - if (!shipDefaultStorageKey) return; + async (next: 'merge' | 'pr') => { + setShipDefault(next) + if (!shipDefaultStorageKey) return try { - await AsyncStorage.setItem(shipDefaultStorageKey, next); + await AsyncStorage.setItem(shipDefaultStorageKey, next) } catch { // Ignore persistence failures; default will reset to "merge". } }, [shipDefaultStorageKey] - ); + ) const { flatItems, stickyHeaderIndices } = useMemo(() => { - const items: DiffFlatItem[] = []; - const stickyIndices: number[] = []; + const items: DiffFlatItem[] = [] + const stickyIndices: number[] = [] for (let i = 0; i < files.length; i++) { - const file = files[i]; - const isExpanded = expandedByPath[file.path] ?? false; - items.push({ type: "header", file, fileIndex: i, isExpanded }); + const file = files[i] + const isExpanded = expandedByPath[file.path] ?? false + items.push({ type: 'header', file, fileIndex: i, isExpanded }) if (isExpanded) { - stickyIndices.push(items.length - 1); + stickyIndices.push(items.length - 1) } if (isExpanded) { - items.push({ type: "body", file, fileIndex: i }); + items.push({ type: 'body', file, fileIndex: i }) } } - return { flatItems: items, stickyHeaderIndices: stickyIndices }; - }, [files, expandedByPath]); + return { flatItems: items, stickyHeaderIndices: stickyIndices } + }, [files, expandedByPath]) const handleHeaderHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { - return; + return } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - }, []); + headerHeightByPathRef.current[path] = height + defaultHeaderHeightRef.current = height + }, []) const handleBodyHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height < 0) { - return; + return } - bodyHeightByPathRef.current[path] = height; - }, []); + bodyHeightByPathRef.current[path] = height + }, []) const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onScroll(event); + diffScrollbarMetrics.onScroll(event) } }, [diffScrollbarMetrics, showDesktopWebScrollbar] - ); + ) const handleDiffListLayout = useCallback( (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; + const height = event.nativeEvent.layout.height if (!Number.isFinite(height) || height <= 0) { - return; + return } - diffListViewportHeightRef.current = height; + diffListViewportHeightRef.current = height if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onLayout(event); + diffScrollbarMetrics.onLayout(event) } }, [diffScrollbarMetrics, showDesktopWebScrollbar] - ); + ) const computeHeaderOffset = useCallback( (path: string): number => { - const defaultHeaderHeight = defaultHeaderHeightRef.current; - let offset = 0; + const defaultHeaderHeight = defaultHeaderHeightRef.current + let offset = 0 for (const file of files) { if (file.path === path) { - break; + break } - offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; + offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight if (expandedByPath[file.path]) { - offset += bodyHeightByPathRef.current[file.path] ?? 0; + offset += bodyHeightByPathRef.current[file.path] ?? 0 } } - return Math.max(0, offset); + return Math.max(0, offset) }, [expandedByPath, files] - ); + ) const handleToggleExpanded = useCallback( (path: string) => { - const isCurrentlyExpanded = expandedByPath[path] ?? false; - const nextExpanded = !isCurrentlyExpanded; - const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; - const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; + const isCurrentlyExpanded = expandedByPath[path] ?? false + const nextExpanded = !isCurrentlyExpanded + const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null + const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current const shouldAnchor = isCurrentlyExpanded && targetOffset !== null && @@ -649,14 +618,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi headerHeight, viewportOffset: diffListScrollOffsetRef.current, viewportHeight: diffListViewportHeightRef.current, - }); + }) // Anchor to the clicked header before collapsing so visual context is preserved. if (shouldAnchor && targetOffset !== null) { diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false, - }); + }) } setExpandedByPath((prev) => ({ @@ -664,141 +633,141 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi // Use a deterministic target value (instead of toggling from prev) so duplicate // onPress events from sticky headers on Android can't flip back immediately. [path]: nextExpanded, - })); + })) }, [computeHeaderOffset, expandedByPath] - ); + ) const allExpanded = useMemo(() => { - if (files.length === 0) return false; - return files.every((file) => expandedByPath[file.path]); - }, [files, expandedByPath]); + if (files.length === 0) return false + return files.every((file) => expandedByPath[file.path]) + }, [files, expandedByPath]) const handleToggleExpandAll = useCallback(() => { if (allExpanded) { - setExpandedByPath({}); + setExpandedByPath({}) } else { - const newExpanded: Record = {}; + const newExpanded: Record = {} for (const file of files) { - newExpanded[file.path] = true; + newExpanded[file.path] = true } - setExpandedByPath(newExpanded); + setExpandedByPath(newExpanded) } - }, [allExpanded, files]); + }, [allExpanded, files]) // Reset manual refresh flag when fetch completes useEffect(() => { if (!(isDiffFetching || isStatusFetching) && isManualRefresh) { - setIsManualRefresh(false); + setIsManualRefresh(false) } - }, [isDiffFetching, isStatusFetching, isManualRefresh]); + }, [isDiffFetching, isStatusFetching, isManualRefresh]) // Clear diff mode override when auto mode changes (e.g., after commit) useEffect(() => { - setDiffModeOverride(null); - }, [autoDiffMode]); + setDiffModeOverride(null) + }, [autoDiffMode]) const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "commit" }) - ); + state.getStatus({ serverId, cwd, actionId: 'commit' }) + ) const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "push" }) - ); + state.getStatus({ serverId, cwd, actionId: 'push' }) + ) const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "create-pr" }) - ); + state.getStatus({ serverId, cwd, actionId: 'create-pr' }) + ) const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-branch" }) - ); + state.getStatus({ serverId, cwd, actionId: 'merge-branch' }) + ) const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-from-base" }) - ); + state.getStatus({ serverId, cwd, actionId: 'merge-from-base' }) + ) const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "archive-worktree" }) - ); + state.getStatus({ serverId, cwd, actionId: 'archive-worktree' }) + ) - const runCommit = useCheckoutGitActionsStore((state) => state.commit); - const runPush = useCheckoutGitActionsStore((state) => state.push); - const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); - const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); - const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const runCommit = useCheckoutGitActionsStore((state) => state.commit) + const runPush = useCheckoutGitActionsStore((state) => state.push) + const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr) + const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch) + const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase) + const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const handleCommit = useCallback(() => { - setActionError(null); + setActionError(null) void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to commit"; - setActionError(message); - }); - }, [runCommit, serverId, cwd]); + const message = err instanceof Error ? err.message : 'Failed to commit' + setActionError(message) + }) + }, [runCommit, serverId, cwd]) const handlePush = useCallback(() => { - setActionError(null); + setActionError(null) void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to push"; - setActionError(message); - }); - }, [runPush, serverId, cwd]); + const message = err instanceof Error ? err.message : 'Failed to push' + setActionError(message) + }) + }, [runPush, serverId, cwd]) const handleCreatePr = useCallback(() => { - void persistShipDefault("pr"); - setActionError(null); + void persistShipDefault('pr') + setActionError(null) void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create PR"; - setActionError(message); - }); - }, [persistShipDefault, runCreatePr, serverId, cwd]); + const message = err instanceof Error ? err.message : 'Failed to create PR' + setActionError(message) + }) + }, [persistShipDefault, runCreatePr, serverId, cwd]) const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); - return; + setActionError('Base ref unavailable') + return } - void persistShipDefault("merge"); - setActionError(null); + void persistShipDefault('merge') + setActionError(null) void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { - setPostShipArchiveSuggested(true); + setPostShipArchiveSuggested(true) }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); - }); - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); + const message = err instanceof Error ? err.message : 'Failed to merge' + setActionError(message) + }) + }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]) const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); - return; + setActionError('Base ref unavailable') + return } - setActionError(null); + setActionError(null) void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge from base"; - setActionError(message); - }); - }, [baseRef, runMergeFromBase, serverId, cwd]); + const message = err instanceof Error ? err.message : 'Failed to merge from base' + setActionError(message) + }) + }, [baseRef, runMergeFromBase, serverId, cwd]) const handleArchiveWorktree = useCallback(() => { - const worktreePath = status?.cwd; + const worktreePath = status?.cwd if (!worktreePath) { - setActionError("Worktree path unavailable"); - return; + setActionError('Worktree path unavailable') + return } - setActionError(null); - const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); + setActionError(null) + const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null) void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { - router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); + router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any) }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to archive worktree"; - setActionError(message); - }); - }, [runArchiveWorktree, router, serverId, cwd, status]); + const message = err instanceof Error ? err.message : 'Failed to archive worktree' + setActionError(message) + }) + }, [runArchiveWorktree, router, serverId, cwd, status]) const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { - if (item.type === "header") { + if (item.type === 'header') { return ( - ); + ) } return ( - ); + ) }, [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines] - ); + ) - const flatKeyExtractor = useCallback( - (item: DiffFlatItem) => `${item.type}-${item.file.path}`, - [] - ); + const flatKeyExtractor = useCallback((item: DiffFlatItem) => `${item.type}-${item.file.path}`, []) - const hasChanges = files.length > 0; + const hasChanges = files.length > 0 const diffErrorMessage = diffPayloadError?.message ?? - (isDiffError && diffError instanceof Error ? diffError.message : null); - const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null; + (isDiffError && diffError instanceof Error ? diffError.message : null) + const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null const branchLabel = - gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" + gitStatus?.currentBranch && gitStatus.currentBranch !== 'HEAD' ? gitStatus.currentBranch : notGit - ? "Not a git repository" - : "Unknown"; - const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; - const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; - const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; + ? 'Not a git repository' + : 'Unknown' + const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading + const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0 + const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0 + const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0 const baseRefLabel = useMemo(() => { - if (!baseRef) return "base"; - const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); - return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; - }, [baseRef]); + if (!baseRef) return 'base' + const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, '').trim() + return trimmed.startsWith('origin/') ? trimmed.slice('origin/'.length) : trimmed + }, [baseRef]) const committedDiffDescription = useMemo(() => { if (!branchLabel || !baseRefLabel) { - return undefined; + return undefined } - return branchLabel === baseRefLabel - ? undefined - : `${branchLabel} -> ${baseRefLabel}`; - }, [baseRefLabel, branchLabel]); - const hasPullRequest = Boolean(prStatus?.url); - const hasRemote = gitStatus?.hasRemote ?? false; - const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; - const isMergedPullRequest = Boolean(prStatus?.isMerged); - const currentBranch = gitStatus?.currentBranch; - const isOnBaseBranch = currentBranch === baseRefLabel; + return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}` + }, [baseRefLabel, branchLabel]) + const hasPullRequest = Boolean(prStatus?.url) + const hasRemote = gitStatus?.hasRemote ?? false + const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false + const isMergedPullRequest = Boolean(prStatus?.isMerged) + const currentBranch = gitStatus?.currentBranch + const isOnBaseBranch = currentBranch === baseRefLabel const shouldPromoteArchive = isPaseoOwnedWorktree && !hasUncommittedChanges && - (postShipArchiveSuggested || isMergedPullRequest); + (postShipArchiveSuggested || isMergedPullRequest) - const commitDisabled = actionsDisabled || commitStatus === "pending"; - const prDisabled = actionsDisabled || prCreateStatus === "pending"; - const mergeDisabled = - actionsDisabled || mergeStatus === "pending"; - const mergeFromBaseDisabled = - actionsDisabled || mergeFromBaseStatus === "pending"; - const pushDisabled = - actionsDisabled || pushStatus === "pending"; - const archiveDisabled = - actionsDisabled || archiveStatus === "pending"; + const commitDisabled = actionsDisabled || commitStatus === 'pending' + const prDisabled = actionsDisabled || prCreateStatus === 'pending' + const mergeDisabled = actionsDisabled || mergeStatus === 'pending' + const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === 'pending' + const pushDisabled = actionsDisabled || pushStatus === 'pending' + const archiveDisabled = actionsDisabled || archiveStatus === 'pending' - let bodyContent: ReactElement; + let bodyContent: ReactElement if (isStatusLoading) { bodyContent = ( @@ -884,39 +844,39 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi Checking repository... - ); + ) } else if (statusErrorMessage) { bodyContent = ( {statusErrorMessage} - ); + ) } else if (notGit) { bodyContent = ( Not a git repository - ); + ) } else if (isDiffLoading) { bodyContent = ( - ); + ) } else if (diffErrorMessage) { bodyContent = ( {diffErrorMessage} - ); + ) } else if (!hasChanges) { bodyContent = ( - {diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`} + {diffMode === 'uncommitted' ? 'No uncommitted changes' : `No changes vs ${baseRefLabel}`} - ); + ) } else { bodyContent = ( - ); + ) } useEffect(() => { - setPostShipArchiveSuggested(false); - }, [cwd]); + setPostShipArchiveSuggested(false) + }, [cwd]) // ========================================================================== // Git Actions (Data-Oriented) @@ -995,44 +953,71 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }, pr: { disabled: prDisabled, - status: hasPullRequest ? "idle" : prCreateStatus, + status: hasPullRequest ? 'idle' : prCreateStatus, icon: , handler: () => { if (prStatus?.url) { - openURLInNewTab(prStatus.url); - return; + openURLInNewTab(prStatus.url) + return } - handleCreatePr(); + handleCreatePr() }, }, - "merge-branch": { + 'merge-branch': { disabled: mergeDisabled, status: mergeStatus, icon: , handler: handleMergeBranch, }, - "merge-from-base": { + 'merge-from-base': { disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, icon: , handler: handleMergeFromBase, }, - "archive-worktree": { + 'archive-worktree': { disabled: archiveDisabled, status: archiveStatus, icon: , handler: handleArchiveWorktree, }, }, - }); + }) }, [ - isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, - hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, - commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, - commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, - handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, + isGit, + hasRemote, + hasPullRequest, + prStatus?.url, + aheadCount, + isPaseoOwnedWorktree, + isOnBaseBranch, + githubFeaturesEnabled, + hasUncommittedChanges, + aheadOfOrigin, + behindOfOrigin, + shipDefault, + baseRefLabel, + shouldPromoteArchive, + commitDisabled, + pushDisabled, + prDisabled, + mergeDisabled, + mergeFromBaseDisabled, + archiveDisabled, + commitStatus, + pushStatus, + prCreateStatus, + mergeStatus, + mergeFromBaseStatus, + archiveStatus, + handleCommit, + handlePush, + handleCreatePr, + handleMergeBranch, + handleMergeFromBase, + handleArchiveWorktree, theme.colors.foregroundMuted, - ]); + ]) // Helper to get display label based on status @@ -1046,9 +1031,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi {branchLabel} - {isGit ? ( - - ) : null} + {isGit ? : null} ) : null} @@ -1067,28 +1050,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi accessibilityLabel="Diff mode" > - {diffMode === "uncommitted" ? "Uncommitted" : "Committed"} + {diffMode === 'uncommitted' ? 'Uncommitted' : 'Committed'} - + setDiffModeOverride("uncommitted")} + selected={diffMode === 'uncommitted'} + onSelect={() => setDiffModeOverride('uncommitted')} > Uncommitted setDiffModeOverride("base")} + onSelect={() => setDiffModeOverride('base')} > Committed @@ -1110,7 +1089,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi - {wrapLines ? "Scroll long lines" : "Wrap long lines"} + {wrapLines ? 'Scroll long lines' : 'Wrap long lines'} @@ -1124,15 +1103,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi onPress={handleToggleExpandAll} > {allExpanded ? ( - + ) : ( - + )} - {allExpanded ? "Collapse all files" : "Expand all files"} + {allExpanded ? 'Collapse all files' : 'Expand all files'} @@ -1143,9 +1128,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ) : null} {actionError ? {actionError} : null} - {prErrorMessage ? ( - {prErrorMessage} - ) : null} + {prErrorMessage ? {prErrorMessage} : null} {bodyContent} @@ -1156,12 +1139,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi diffListRef.current?.scrollToOffset({ offset: nextOffset, animated: false, - }); + }) }} /> - ); + ) } const styles = StyleSheet.create((theme) => ({ @@ -1170,9 +1153,9 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -1180,8 +1163,8 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, headerLeft: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], flex: 1, minWidth: 0, @@ -1199,14 +1182,14 @@ const styles = StyleSheet.create((theme) => ({ }, diffStatusInner: { flex: 1, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingRight: theme.spacing[3], }, diffModeTrigger: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[1], // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) marginLeft: theme.spacing[3] - theme.spacing[1], @@ -1232,8 +1215,8 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0, }, diffStatusButtons: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: { xs: theme.spacing[1], sm: theme.spacing[1], @@ -1241,8 +1224,8 @@ const styles = StyleSheet.create((theme) => ({ }, }, expandAllButton: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[1], marginVertical: theme.spacing[2], paddingHorizontal: { @@ -1266,7 +1249,7 @@ const styles = StyleSheet.create((theme) => ({ diffContainer: { flex: 1, minHeight: 0, - position: "relative", + position: 'relative', }, scrollView: { flex: 1, @@ -1276,8 +1259,8 @@ const styles = StyleSheet.create((theme) => ({ }, loadingContainer: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', paddingTop: theme.spacing[16], gap: theme.spacing[4], }, @@ -1287,20 +1270,20 @@ const styles = StyleSheet.create((theme) => ({ }, errorContainer: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', paddingTop: theme.spacing[16], paddingHorizontal: theme.spacing[6], }, errorText: { fontSize: theme.fontSize.base, color: theme.colors.destructive, - textAlign: "center", + textAlign: 'center', }, emptyContainer: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', paddingTop: theme.spacing[16], }, emptyText: { @@ -1308,19 +1291,19 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, fileSection: { - overflow: "hidden", + overflow: 'hidden', backgroundColor: theme.colors.surface2, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, fileSectionHeaderContainer: { - overflow: "hidden", + overflow: 'hidden', }, fileSectionHeaderExpanded: { backgroundColor: theme.colors.surface1, }, fileSectionBodyContainer: { - overflow: "hidden", + overflow: 'hidden', backgroundColor: theme.colors.surface2, }, fileSectionBorder: { @@ -1328,9 +1311,9 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, fileHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingLeft: theme.spacing[3], paddingRight: theme.spacing[2], paddingVertical: theme.spacing[2], @@ -1342,15 +1325,15 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.7, }, fileHeaderLeft: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[1], flex: 1, minWidth: 0, }, fileHeaderRight: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[1], flexShrink: 0, }, @@ -1367,7 +1350,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, newBadge: { - backgroundColor: "rgba(46, 160, 67, 0.2)", + backgroundColor: 'rgba(46, 160, 67, 0.2)', paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, @@ -1379,7 +1362,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.green[400], }, deletedBadge: { - backgroundColor: "rgba(248, 81, 73, 0.2)", + backgroundColor: 'rgba(248, 81, 73, 0.2)', paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, @@ -1406,24 +1389,24 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, }, diffContentInner: { - flexDirection: "column", + flexDirection: 'column', }, linesContainer: { backgroundColor: theme.colors.surface1, }, diffLineContainer: { - flexDirection: "row", - alignItems: "stretch", + flexDirection: 'row', + alignItems: 'stretch', }, lineNumberGutter: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, marginRight: theme.spacing[2], - alignSelf: "stretch", - justifyContent: "center", + alignSelf: 'stretch', + justifyContent: 'center', }, lineNumberText: { - textAlign: "right", + textAlign: 'right', paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, @@ -1445,13 +1428,13 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, addLineContainer: { - backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green + backgroundColor: 'rgba(46, 160, 67, 0.15)', // GitHub green }, addLineText: { color: theme.colors.foreground, }, removeLineContainer: { - backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red + backgroundColor: 'rgba(248, 81, 73, 0.1)', // GitHub red }, removeLineText: { color: theme.colors.foreground, @@ -1478,10 +1461,10 @@ const styles = StyleSheet.create((theme) => ({ statusMessageText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, - fontStyle: "italic", + fontStyle: 'italic', }, tooltipText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, }, -})); +})) diff --git a/packages/app/src/utils/diff-highlighter.ts b/packages/app/src/utils/diff-highlighter.ts index 03b75b417..d01a1f71a 100644 --- a/packages/app/src/utils/diff-highlighter.ts +++ b/packages/app/src/utils/diff-highlighter.ts @@ -1,31 +1,27 @@ -import { - highlightCode, - isLanguageSupported, - type HighlightToken, -} from "./syntax-highlighter"; +import { highlightCode, isLanguageSupported, type HighlightToken } from '@getpaseo/highlight' export interface DiffLine { - type: "add" | "remove" | "context" | "header"; - content: string; - lineNumber?: number; // Line number in the original/new file - tokens?: HighlightToken[]; + type: 'add' | 'remove' | 'context' | 'header' + content: string + lineNumber?: number // Line number in the original/new file + tokens?: HighlightToken[] } export interface DiffHunk { - oldStart: number; - oldCount: number; - newStart: number; - newCount: number; - lines: DiffLine[]; + oldStart: number + oldCount: number + newStart: number + newCount: number + lines: DiffLine[] } export interface ParsedDiffFile { - path: string; - isNew: boolean; - isDeleted: boolean; - additions: number; - deletions: number; - hunks: DiffHunk[]; + path: string + isNew: boolean + isDeleted: boolean + additions: number + deletions: number + hunks: DiffHunk[] } /** @@ -33,93 +29,87 @@ export interface ParsedDiffFile { */ export function parseDiff(diffText: string): ParsedDiffFile[] { if (!diffText || diffText.trim().length === 0) { - return []; + return [] } - const files: ParsedDiffFile[] = []; - const fileSections = diffText.split(/^diff --git /m).filter(Boolean); + const files: ParsedDiffFile[] = [] + const fileSections = diffText.split(/^diff --git /m).filter(Boolean) for (const section of fileSections) { - const lines = section.split("\n"); - const firstLine = lines[0]; + const lines = section.split('\n') + const firstLine = lines[0] // Detect new/deleted file - const isNew = - section.includes("new file mode") || - section.includes("--- /dev/null"); - const isDeleted = - section.includes("deleted file mode") || - section.includes("+++ /dev/null"); + const isNew = section.includes('new file mode') || section.includes('--- /dev/null') + const isDeleted = section.includes('deleted file mode') || section.includes('+++ /dev/null') // Extract path - let path = "unknown"; - const pathMatch = firstLine.match(/a\/(.*?) b\//); + let path = 'unknown' + const pathMatch = firstLine.match(/a\/(.*?) b\//) if (pathMatch) { - path = pathMatch[1]; + path = pathMatch[1] } else { - const newFileMatch = firstLine.match(/b\/(.+)$/); + const newFileMatch = firstLine.match(/b\/(.+)$/) if (newFileMatch) { - path = newFileMatch[1]; + path = newFileMatch[1] } } - const hunks: DiffHunk[] = []; - let currentHunk: DiffHunk | null = null; - let additions = 0; - let deletions = 0; + const hunks: DiffHunk[] = [] + let currentHunk: DiffHunk | null = null + let additions = 0 + let deletions = 0 for (let i = 1; i < lines.length; i++) { - const line = lines[i]; + const line = lines[i] // Skip metadata lines - if (line.startsWith("index ")) continue; - if (line.startsWith("--- ")) continue; - if (line.startsWith("+++ ")) continue; - if (line.startsWith("new file mode")) continue; - if (line.startsWith("deleted file mode")) continue; + if (line.startsWith('index ')) continue + if (line.startsWith('--- ')) continue + if (line.startsWith('+++ ')) continue + if (line.startsWith('new file mode')) continue + if (line.startsWith('deleted file mode')) continue // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ - const hunkMatch = line.match( - /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ - ); + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/) if (hunkMatch) { if (currentHunk) { - hunks.push(currentHunk); + hunks.push(currentHunk) } currentHunk = { oldStart: parseInt(hunkMatch[1], 10), - oldCount: parseInt(hunkMatch[2] ?? "1", 10), + oldCount: parseInt(hunkMatch[2] ?? '1', 10), newStart: parseInt(hunkMatch[3], 10), - newCount: parseInt(hunkMatch[4] ?? "1", 10), - lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], - }; - continue; + newCount: parseInt(hunkMatch[4] ?? '1', 10), + lines: [{ type: 'header', content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], + } + continue } - if (!currentHunk) continue; + if (!currentHunk) continue - if (line.startsWith("+")) { - currentHunk.lines.push({ type: "add", content: line.slice(1) }); - additions++; - } else if (line.startsWith("-")) { - currentHunk.lines.push({ type: "remove", content: line.slice(1) }); - deletions++; - } else if (line.startsWith(" ")) { - currentHunk.lines.push({ type: "context", content: line.slice(1) }); - } else if (line.length > 0 && !line.startsWith("\\")) { + if (line.startsWith('+')) { + currentHunk.lines.push({ type: 'add', content: line.slice(1) }) + additions++ + } else if (line.startsWith('-')) { + currentHunk.lines.push({ type: 'remove', content: line.slice(1) }) + deletions++ + } else if (line.startsWith(' ')) { + currentHunk.lines.push({ type: 'context', content: line.slice(1) }) + } else if (line.length > 0 && !line.startsWith('\\')) { // Non-empty line that's not a "\ No newline" marker - currentHunk.lines.push({ type: "context", content: line }); + currentHunk.lines.push({ type: 'context', content: line }) } } if (currentHunk) { - hunks.push(currentHunk); + hunks.push(currentHunk) } - files.push({ path, isNew, isDeleted, additions, deletions, hunks }); + files.push({ path, isNew, isDeleted, additions, deletions, hunks }) } - return files; + return files } /** @@ -127,23 +117,23 @@ export function parseDiff(diffText: string): ParsedDiffFile[] { * Returns a map of new line numbers to their content. */ export function reconstructNewFile(hunks: DiffHunk[]): Map { - const lines = new Map(); + const lines = new Map() for (const hunk of hunks) { - let newLineNum = hunk.newStart; + let newLineNum = hunk.newStart for (const line of hunk.lines) { - if (line.type === "header") continue; + if (line.type === 'header') continue - if (line.type === "add" || line.type === "context") { - lines.set(newLineNum, line.content); - newLineNum++; + if (line.type === 'add' || line.type === 'context') { + lines.set(newLineNum, line.content) + newLineNum++ } // Remove lines don't appear in the new file } } - return lines; + return lines } /** @@ -151,23 +141,23 @@ export function reconstructNewFile(hunks: DiffHunk[]): Map { * Returns a map of old line numbers to their content. */ export function reconstructOldFile(hunks: DiffHunk[]): Map { - const lines = new Map(); + const lines = new Map() for (const hunk of hunks) { - let oldLineNum = hunk.oldStart; + let oldLineNum = hunk.oldStart for (const line of hunk.lines) { - if (line.type === "header") continue; + if (line.type === 'header') continue - if (line.type === "remove" || line.type === "context") { - lines.set(oldLineNum, line.content); - oldLineNum++; + if (line.type === 'remove' || line.type === 'context') { + lines.set(oldLineNum, line.content) + oldLineNum++ } // Add lines don't appear in the old file } } - return lines; + return lines } /** @@ -180,100 +170,100 @@ export function reconstructOldFile(hunks: DiffHunk[]): Map { */ export function highlightDiffFile(file: ParsedDiffFile): ParsedDiffFile { if (!isLanguageSupported(file.path)) { - return file; + return file } // Reconstruct both versions - const newFileLines = reconstructNewFile(file.hunks); - const oldFileLines = reconstructOldFile(file.hunks); + const newFileLines = reconstructNewFile(file.hunks) + const oldFileLines = reconstructOldFile(file.hunks) // Build complete file content strings for highlighting - const newFileContent = buildFileContent(newFileLines); - const oldFileContent = buildFileContent(oldFileLines); + const newFileContent = buildFileContent(newFileLines) + const oldFileContent = buildFileContent(oldFileLines) // Highlight both versions - const newHighlighted = highlightCode(newFileContent, file.path); - const oldHighlighted = highlightCode(oldFileContent, file.path); + const newHighlighted = highlightCode(newFileContent, file.path) + const oldHighlighted = highlightCode(oldFileContent, file.path) // Build lookup maps: line number -> tokens - const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted); - const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted); + const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted) + const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted) // Apply tokens to hunks const highlightedHunks = file.hunks.map((hunk) => { - let oldLineNum = hunk.oldStart; - let newLineNum = hunk.newStart; + let oldLineNum = hunk.oldStart + let newLineNum = hunk.newStart const highlightedLines = hunk.lines.map((line): DiffLine => { - if (line.type === "header") { - return line; + if (line.type === 'header') { + return line } - let tokens: HighlightToken[] | undefined; + let tokens: HighlightToken[] | undefined - if (line.type === "add") { - tokens = newTokensByLine.get(newLineNum); - newLineNum++; - } else if (line.type === "remove") { - tokens = oldTokensByLine.get(oldLineNum); - oldLineNum++; - } else if (line.type === "context") { + if (line.type === 'add') { + tokens = newTokensByLine.get(newLineNum) + newLineNum++ + } else if (line.type === 'remove') { + tokens = oldTokensByLine.get(oldLineNum) + oldLineNum++ + } else if (line.type === 'context') { // Context lines exist in both - use new file version - tokens = newTokensByLine.get(newLineNum); - oldLineNum++; - newLineNum++; + tokens = newTokensByLine.get(newLineNum) + oldLineNum++ + newLineNum++ } - return tokens ? { ...line, tokens } : line; - }); + return tokens ? { ...line, tokens } : line + }) - return { ...hunk, lines: highlightedLines }; - }); + return { ...hunk, lines: highlightedLines } + }) - return { ...file, hunks: highlightedHunks }; + return { ...file, hunks: highlightedHunks } } function buildFileContent(lineMap: Map): string { - if (lineMap.size === 0) return ""; + if (lineMap.size === 0) return '' - const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b); - const minLine = lineNumbers[0]; - const maxLine = lineNumbers[lineNumbers.length - 1]; + const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b) + const minLine = lineNumbers[0] + const maxLine = lineNumbers[lineNumbers.length - 1] - const lines: string[] = []; + const lines: string[] = [] for (let i = minLine; i <= maxLine; i++) { - lines.push(lineMap.get(i) ?? ""); + lines.push(lineMap.get(i) ?? '') } - return lines.join("\n"); + return lines.join('\n') } function buildTokenLookup( lineMap: Map, highlighted: HighlightToken[][] ): Map { - const lookup = new Map(); + const lookup = new Map() - if (lineMap.size === 0) return lookup; + if (lineMap.size === 0) return lookup - const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b); - const minLine = lineNumbers[0]; + const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b) + const minLine = lineNumbers[0] // highlighted array is 0-indexed, line numbers are 1-indexed for (let i = 0; i < highlighted.length; i++) { - const lineNum = minLine + i; + const lineNum = minLine + i if (lineMap.has(lineNum)) { - lookup.set(lineNum, highlighted[i]); + lookup.set(lineNum, highlighted[i]) } } - return lookup; + return lookup } /** * Parse and highlight a complete diff */ export function parseAndHighlightDiff(diffText: string): ParsedDiffFile[] { - const files = parseDiff(diffText); - return files.map(highlightDiffFile); + const files = parseDiff(diffText) + return files.map(highlightDiffFile) } diff --git a/packages/app/src/utils/syntax-highlighter.ts b/packages/app/src/utils/syntax-highlighter.ts deleted file mode 100644 index 89cae3952..000000000 --- a/packages/app/src/utils/syntax-highlighter.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { highlightTree, tagHighlighter, tags } from "@lezer/highlight"; -import { parser as jsParser } from "@lezer/javascript"; -import { parser as jsonParser } from "@lezer/json"; -import { parser as cssParser } from "@lezer/css"; -import { parser as cppParser } from "@lezer/cpp"; -import { parser as goParser } from "@lezer/go"; -import { parser as htmlParser } from "@lezer/html"; -import { parser as javaParser } from "@lezer/java"; -import { parser as pythonParser } from "@lezer/python"; -import { parser as markdownParser } from "@lezer/markdown"; -import { parser as phpParser } from "@lezer/php"; -import { parser as rustParser } from "@lezer/rust"; -import { parser as xmlParser } from "@lezer/xml"; -import { parser as yamlParser } from "@lezer/yaml"; -import { parser as elixirParser } from "lezer-elixir"; -import type { Parser } from "@lezer/common"; - -// Map file extensions to parsers -const parsersByExtension: Record = { - // JavaScript/TypeScript - js: jsParser, - jsx: jsParser.configure({ dialect: "jsx" }), - ts: jsParser.configure({ dialect: "ts" }), - tsx: jsParser.configure({ dialect: "tsx jsx" }), - mjs: jsParser, - cjs: jsParser, - // C / C++ / Objective-C - c: cppParser, - h: cppParser, - cc: cppParser, - cpp: cppParser, - cxx: cppParser, - hpp: cppParser, - hxx: cppParser, - m: cppParser, - mm: cppParser, - // JSON - json: jsonParser, - // CSS - css: cssParser, - scss: cssParser, - // HTML - html: htmlParser, - htm: htmlParser, - // XML - xml: xmlParser, - // Java - java: javaParser, - // Python - py: pythonParser, - // Go - go: goParser, - // PHP - php: phpParser, - // YAML - yaml: yamlParser, - yml: yamlParser, - // Rust - rs: rustParser, - // Elixir - ex: elixirParser, - exs: elixirParser, - // Markdown - md: markdownParser, - mdx: markdownParser, -}; - -export type HighlightStyle = - | "keyword" - | "comment" - | "string" - | "number" - | "literal" - | "function" - | "definition" - | "class" - | "type" - | "tag" - | "attribute" - | "property" - | "variable" - | "operator" - | "punctuation" - | "regexp" - | "escape" - | "meta" - | "heading" - | "link"; - -export interface HighlightToken { - text: string; - style: HighlightStyle | null; -} - -// Create highlighter using tagHighlighter -const highlighter = tagHighlighter([ - { tag: tags.keyword, class: "keyword" }, - { tag: tags.controlKeyword, class: "keyword" }, - { tag: tags.operatorKeyword, class: "keyword" }, - { tag: tags.definitionKeyword, class: "keyword" }, - { tag: tags.moduleKeyword, class: "keyword" }, - { tag: tags.comment, class: "comment" }, - { tag: tags.lineComment, class: "comment" }, - { tag: tags.blockComment, class: "comment" }, - { tag: tags.docComment, class: "comment" }, - { tag: tags.string, class: "string" }, - { tag: tags.special(tags.string), class: "string" }, - { tag: tags.number, class: "number" }, - { tag: tags.integer, class: "number" }, - { tag: tags.float, class: "number" }, - { tag: tags.bool, class: "literal" }, - { tag: tags.null, class: "literal" }, - { tag: tags.function(tags.variableName), class: "function" }, - { tag: tags.function(tags.propertyName), class: "function" }, - { tag: tags.definition(tags.variableName), class: "definition" }, - { tag: tags.definition(tags.propertyName), class: "definition" }, - { tag: tags.definition(tags.function(tags.variableName)), class: "definition" }, - { tag: tags.className, class: "class" }, - { tag: tags.definition(tags.className), class: "class" }, - { tag: tags.typeName, class: "type" }, - { tag: tags.tagName, class: "tag" }, - { tag: tags.attributeName, class: "attribute" }, - { tag: tags.attributeValue, class: "string" }, - { tag: tags.propertyName, class: "property" }, - { tag: tags.variableName, class: "variable" }, - { tag: tags.local(tags.variableName), class: "variable" }, - { tag: tags.special(tags.variableName), class: "variable" }, - { tag: tags.operator, class: "operator" }, - { tag: tags.punctuation, class: "punctuation" }, - { tag: tags.bracket, class: "punctuation" }, - { tag: tags.separator, class: "punctuation" }, - { tag: tags.regexp, class: "regexp" }, - { tag: tags.escape, class: "escape" }, - { tag: tags.meta, class: "meta" }, - { tag: tags.heading, class: "heading" }, - { tag: tags.link, class: "link" }, - { tag: tags.url, class: "link" }, -]); - -function getParserForFile(filename: string): Parser | null { - const ext = filename.split(".").pop()?.toLowerCase(); - if (!ext) return null; - return parsersByExtension[ext] ?? null; -} - -export function highlightCode(code: string, filename: string): HighlightToken[][] { - const parser = getParserForFile(filename); - - if (!parser) { - // No parser available, return unhighlighted lines - return code.split("\n").map((line) => [{ text: line, style: null }]); - } - - const tree = parser.parse(code); - const lines = code.split("\n"); - const result: HighlightToken[][] = []; - - // Initialize with unhighlighted content - for (let i = 0; i < lines.length; i++) { - result.push([]); - } - - // Build a map of character positions to styles - const styleMap: Array = new Array(code.length).fill(null); - - // Use highlightTree to populate the style map - highlightTree(tree, highlighter, (from, to, classes) => { - for (let i = from; i < to && i < styleMap.length; i++) { - styleMap[i] = classes as HighlightStyle; - } - }); - - // Convert style map to tokens per line - let pos = 0; - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const line = lines[lineIndex]; - - if (line.length === 0) { - result[lineIndex].push({ text: "", style: null }); - pos++; // skip newline - continue; - } - - let currentToken: HighlightToken = { text: "", style: styleMap[pos] }; - - for (let i = 0; i < line.length; i++) { - const charStyle = styleMap[pos + i]; - if (charStyle === currentToken.style) { - currentToken.text += line[i]; - } else { - if (currentToken.text) { - result[lineIndex].push(currentToken); - } - currentToken = { text: line[i], style: charStyle }; - } - } - - if (currentToken.text) { - result[lineIndex].push(currentToken); - } - - pos += line.length + 1; // +1 for newline - } - - return result; -} - -export function highlightLine(line: string, filename: string): HighlightToken[] { - const result = highlightCode(line, filename); - return result[0] ?? [{ text: line, style: null }]; -} - -export function getSupportedExtensions(): string[] { - return Object.keys(parsersByExtension); -} - -export function isLanguageSupported(filename: string): boolean { - return getParserForFile(filename) !== null; -} diff --git a/packages/highlight/package.json b/packages/highlight/package.json new file mode 100644 index 000000000..4d084ca70 --- /dev/null +++ b/packages/highlight/package.json @@ -0,0 +1,36 @@ +{ + "name": "@getpaseo/highlight", + "version": "0.1.32", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./src/index.ts", + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false", + "prepack": "npm run build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/cpp": "^1.1.5", + "@lezer/css": "^1.3.0", + "@lezer/go": "^1.0.1", + "@lezer/highlight": "^1.2.3", + "@lezer/html": "^1.3.13", + "@lezer/java": "^1.1.3", + "@lezer/javascript": "^1.5.4", + "@lezer/json": "^1.0.3", + "@lezer/markdown": "^1.6.2", + "@lezer/php": "^1.0.5", + "@lezer/python": "^1.1.18", + "@lezer/rust": "^1.0.2", + "@lezer/xml": "^1.0.6", + "@lezer/yaml": "^1.0.4", + "lezer-elixir": "^1.1.2" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/highlight/src/__tests__/colors.test.ts b/packages/highlight/src/__tests__/colors.test.ts new file mode 100644 index 000000000..1b064ba82 --- /dev/null +++ b/packages/highlight/src/__tests__/colors.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { darkHighlightColors, lightHighlightColors } from '../colors.js' +import type { HighlightStyle } from '../types.js' + +const allStyles: HighlightStyle[] = [ + 'keyword', + 'comment', + 'string', + 'number', + 'literal', + 'function', + 'definition', + 'class', + 'type', + 'tag', + 'attribute', + 'property', + 'variable', + 'operator', + 'punctuation', + 'regexp', + 'escape', + 'meta', + 'heading', + 'link', +] + +describe('darkHighlightColors', () => { + it('covers all HighlightStyle values', () => { + for (const style of allStyles) { + expect(darkHighlightColors[style]).toBeDefined() + expect(typeof darkHighlightColors[style]).toBe('string') + } + }) + + it('has valid hex color values', () => { + for (const style of allStyles) { + expect(darkHighlightColors[style]).toMatch(/^#[0-9a-fA-F]{6}$/) + } + }) +}) + +describe('lightHighlightColors', () => { + it('covers all HighlightStyle values', () => { + for (const style of allStyles) { + expect(lightHighlightColors[style]).toBeDefined() + expect(typeof lightHighlightColors[style]).toBe('string') + } + }) + + it('has valid hex color values', () => { + for (const style of allStyles) { + expect(lightHighlightColors[style]).toMatch(/^#[0-9a-fA-F]{6}$/) + } + }) +}) + +describe('color map completeness', () => { + it('dark and light maps have the same keys', () => { + const darkKeys = Object.keys(darkHighlightColors).sort() + const lightKeys = Object.keys(lightHighlightColors).sort() + + expect(darkKeys).toEqual(lightKeys) + }) +}) diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts new file mode 100644 index 000000000..5cedb295e --- /dev/null +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest' +import { highlightCode, highlightLine } from '../highlighter.js' + +describe('highlightCode', () => { + it('highlights JavaScript code with correct token styles', () => { + const code = 'const x = 42;' + const result = highlightCode(code, 'test.js') + + expect(result).toHaveLength(1) + const tokens = result[0] + expect(tokens.length).toBeGreaterThan(1) + + const keywordToken = tokens.find((t) => t.text === 'const') + expect(keywordToken?.style).toBe('keyword') + + const numberToken = tokens.find((t) => t.text === '42') + expect(numberToken?.style).toBe('number') + }) + + it('highlights Python code', () => { + const code = 'def hello():\n print("world")' + const result = highlightCode(code, 'test.py') + + expect(result).toHaveLength(2) + + const defToken = result[0].find((t) => t.text === 'def') + expect(defToken?.style).toBe('keyword') + + const stringToken = result[1].find((t) => t.text.includes('world')) + expect(stringToken?.style).toBe('string') + }) + + it('highlights TSX code with correct dialect', () => { + const code = 'const el =
hello
;' + const result = highlightCode(code, 'test.tsx') + + expect(result).toHaveLength(1) + const tokens = result[0] + + const constToken = tokens.find((t) => t.text === 'const') + expect(constToken?.style).toBe('keyword') + + const tagToken = tokens.find((t) => t.text === 'div') + expect(tagToken).toBeDefined() + }) + + it('returns unhighlighted tokens for unsupported extensions', () => { + const code = 'hello world\nsecond line' + const result = highlightCode(code, 'test.xyz') + + expect(result).toHaveLength(2) + expect(result[0]).toEqual([{ text: 'hello world', style: null }]) + expect(result[1]).toEqual([{ text: 'second line', style: null }]) + }) + + it('splits multi-line code into separate line arrays', () => { + const code = 'line1\nline2\nline3' + const result = highlightCode(code, 'test.txt') + + expect(result).toHaveLength(3) + }) + + it('handles empty lines', () => { + const code = 'const a = 1;\n\nconst b = 2;' + const result = highlightCode(code, 'test.js') + + expect(result).toHaveLength(3) + expect(result[1]).toEqual([{ text: '', style: null }]) + }) + + it('returns a single empty line for empty input', () => { + expect(highlightCode('', 'test.ts')).toEqual([[{ text: '', style: null }]]) + }) + + it('returns valid HighlightStyle values for all tokens', () => { + const validStyles = new Set([ + 'keyword', + 'comment', + 'string', + 'number', + 'literal', + 'function', + 'definition', + 'class', + 'type', + 'tag', + 'attribute', + 'property', + 'variable', + 'operator', + 'punctuation', + 'regexp', + 'escape', + 'meta', + 'heading', + 'link', + null, + ]) + + const code = '// comment\nconst x: number = 42;\nfunction foo() { return "bar"; }' + const result = highlightCode(code, 'test.ts') + + for (const line of result) { + for (const token of line) { + expect(validStyles.has(token.style)).toBe(true) + } + } + }) +}) + +describe('highlightLine', () => { + it('highlights a single line', () => { + const tokens = highlightLine('const x = 1;', 'test.js') + + expect(tokens.length).toBeGreaterThan(1) + const keywordToken = tokens.find((t) => t.text === 'const') + expect(keywordToken?.style).toBe('keyword') + }) + + it('returns unhighlighted token for unsupported files', () => { + const tokens = highlightLine('hello', 'test.xyz') + + expect(tokens).toEqual([{ text: 'hello', style: null }]) + }) +}) diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts new file mode 100644 index 000000000..18889de51 --- /dev/null +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { isLanguageSupported, getSupportedExtensions, getParserForFile } from '../parsers.js' + +describe('isLanguageSupported', () => { + it('returns true for supported file extensions', () => { + expect(isLanguageSupported('test.js')).toBe(true) + expect(isLanguageSupported('test.ts')).toBe(true) + expect(isLanguageSupported('test.tsx')).toBe(true) + expect(isLanguageSupported('test.py')).toBe(true) + expect(isLanguageSupported('test.go')).toBe(true) + expect(isLanguageSupported('test.rs')).toBe(true) + expect(isLanguageSupported('test.json')).toBe(true) + expect(isLanguageSupported('test.css')).toBe(true) + expect(isLanguageSupported('test.html')).toBe(true) + expect(isLanguageSupported('test.java')).toBe(true) + expect(isLanguageSupported('test.ex')).toBe(true) + }) + + it('returns false for unsupported file extensions', () => { + expect(isLanguageSupported('test.xyz')).toBe(false) + expect(isLanguageSupported('test.txt')).toBe(false) + expect(isLanguageSupported('test.csv')).toBe(false) + }) + + it('returns false for files without extensions', () => { + expect(isLanguageSupported('Makefile')).toBe(false) + }) + + it('handles nested paths', () => { + expect(isLanguageSupported('src/utils/test.ts')).toBe(true) + expect(isLanguageSupported('deep/nested/path/file.py')).toBe(true) + }) +}) + +describe('getSupportedExtensions', () => { + it('returns an array of extension strings', () => { + const extensions = getSupportedExtensions() + + expect(Array.isArray(extensions)).toBe(true) + expect(extensions.length).toBeGreaterThan(0) + }) + + it('includes common extensions', () => { + const extensions = getSupportedExtensions() + + expect(extensions).toContain('js') + expect(extensions).toContain('ts') + expect(extensions).toContain('tsx') + expect(extensions).toContain('py') + expect(extensions).toContain('go') + expect(extensions).toContain('rs') + expect(extensions).toContain('json') + }) +}) + +describe('getParserForFile', () => { + it('returns a parser for supported files', () => { + expect(getParserForFile('test.js')).not.toBeNull() + expect(getParserForFile('test.py')).not.toBeNull() + }) + + it('returns null for unsupported files', () => { + expect(getParserForFile('test.xyz')).toBeNull() + }) + + it('returns null for files without extension', () => { + expect(getParserForFile('noext')).toBeNull() + }) + + it('is case-insensitive for extensions', () => { + expect(getParserForFile('test.JS')).not.toBeNull() + expect(getParserForFile('test.Py')).not.toBeNull() + }) +}) diff --git a/packages/highlight/src/colors.ts b/packages/highlight/src/colors.ts new file mode 100644 index 000000000..f7f971990 --- /dev/null +++ b/packages/highlight/src/colors.ts @@ -0,0 +1,47 @@ +import type { HighlightStyle } from './types.js' + +export const darkHighlightColors: Record = { + keyword: '#ff7b72', + comment: '#8b949e', + string: '#a5d6ff', + number: '#79c0ff', + literal: '#79c0ff', + function: '#d2a8ff', + definition: '#d2a8ff', + class: '#ffa657', + type: '#ff7b72', + tag: '#7ee787', + attribute: '#79c0ff', + property: '#79c0ff', + variable: '#c9d1d9', + operator: '#79c0ff', + punctuation: '#c9d1d9', + regexp: '#a5d6ff', + escape: '#79c0ff', + meta: '#8b949e', + heading: '#79c0ff', + link: '#a5d6ff', +} + +export const lightHighlightColors: Record = { + keyword: '#cf222e', + comment: '#6e7781', + string: '#0a3069', + number: '#0550ae', + literal: '#0550ae', + function: '#8250df', + definition: '#8250df', + class: '#953800', + type: '#cf222e', + tag: '#116329', + attribute: '#0550ae', + property: '#0550ae', + variable: '#24292f', + operator: '#0550ae', + punctuation: '#24292f', + regexp: '#0a3069', + escape: '#0550ae', + meta: '#6e7781', + heading: '#0550ae', + link: '#0a3069', +} diff --git a/packages/highlight/src/highlighter.ts b/packages/highlight/src/highlighter.ts new file mode 100644 index 000000000..f09a07e48 --- /dev/null +++ b/packages/highlight/src/highlighter.ts @@ -0,0 +1,111 @@ +import { highlightTree, tagHighlighter, tags } from '@lezer/highlight' +import type { HighlightStyle, HighlightToken } from './types.js' +import { getParserForFile } from './parsers.js' + +const highlighter = tagHighlighter([ + { tag: tags.keyword, class: 'keyword' }, + { tag: tags.controlKeyword, class: 'keyword' }, + { tag: tags.operatorKeyword, class: 'keyword' }, + { tag: tags.definitionKeyword, class: 'keyword' }, + { tag: tags.moduleKeyword, class: 'keyword' }, + { tag: tags.comment, class: 'comment' }, + { tag: tags.lineComment, class: 'comment' }, + { tag: tags.blockComment, class: 'comment' }, + { tag: tags.docComment, class: 'comment' }, + { tag: tags.string, class: 'string' }, + { tag: tags.special(tags.string), class: 'string' }, + { tag: tags.number, class: 'number' }, + { tag: tags.integer, class: 'number' }, + { tag: tags.float, class: 'number' }, + { tag: tags.bool, class: 'literal' }, + { tag: tags.null, class: 'literal' }, + { tag: tags.function(tags.variableName), class: 'function' }, + { tag: tags.function(tags.propertyName), class: 'function' }, + { tag: tags.definition(tags.variableName), class: 'definition' }, + { tag: tags.definition(tags.propertyName), class: 'definition' }, + { tag: tags.definition(tags.function(tags.variableName)), class: 'definition' }, + { tag: tags.className, class: 'class' }, + { tag: tags.definition(tags.className), class: 'class' }, + { tag: tags.typeName, class: 'type' }, + { tag: tags.tagName, class: 'tag' }, + { tag: tags.attributeName, class: 'attribute' }, + { tag: tags.attributeValue, class: 'string' }, + { tag: tags.propertyName, class: 'property' }, + { tag: tags.variableName, class: 'variable' }, + { tag: tags.local(tags.variableName), class: 'variable' }, + { tag: tags.special(tags.variableName), class: 'variable' }, + { tag: tags.operator, class: 'operator' }, + { tag: tags.punctuation, class: 'punctuation' }, + { tag: tags.bracket, class: 'punctuation' }, + { tag: tags.separator, class: 'punctuation' }, + { tag: tags.regexp, class: 'regexp' }, + { tag: tags.escape, class: 'escape' }, + { tag: tags.meta, class: 'meta' }, + { tag: tags.heading, class: 'heading' }, + { tag: tags.link, class: 'link' }, + { tag: tags.url, class: 'link' }, +]) + +export function highlightCode(code: string, filename: string): HighlightToken[][] { + const parser = getParserForFile(filename) + + if (!parser) { + return code.split('\n').map((line) => [{ text: line, style: null }]) + } + + const tree = parser.parse(code) + const lines = code.split('\n') + const result: HighlightToken[][] = [] + + for (let i = 0; i < lines.length; i++) { + result.push([]) + } + + // Build a map of character positions to styles + const styleMap: Array = new Array(code.length).fill(null) + + highlightTree(tree, highlighter, (from, to, classes) => { + for (let i = from; i < to && i < styleMap.length; i++) { + styleMap[i] = classes as HighlightStyle + } + }) + + // Convert style map to tokens per line + let pos = 0 + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] + + if (line.length === 0) { + result[lineIndex].push({ text: '', style: null }) + pos++ // skip newline + continue + } + + let currentToken: HighlightToken = { text: '', style: styleMap[pos] } + + for (let i = 0; i < line.length; i++) { + const charStyle = styleMap[pos + i] + if (charStyle === currentToken.style) { + currentToken.text += line[i] + } else { + if (currentToken.text) { + result[lineIndex].push(currentToken) + } + currentToken = { text: line[i], style: charStyle } + } + } + + if (currentToken.text) { + result[lineIndex].push(currentToken) + } + + pos += line.length + 1 // +1 for newline + } + + return result +} + +export function highlightLine(line: string, filename: string): HighlightToken[] { + const result = highlightCode(line, filename) + return result[0] ?? [{ text: line, style: null }] +} diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts new file mode 100644 index 000000000..a5ff301f1 --- /dev/null +++ b/packages/highlight/src/index.ts @@ -0,0 +1,4 @@ +export type { HighlightStyle, HighlightToken } from './types.js' +export { getParserForFile, isLanguageSupported, getSupportedExtensions } from './parsers.js' +export { highlightCode, highlightLine } from './highlighter.js' +export { darkHighlightColors, lightHighlightColors } from './colors.js' diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts new file mode 100644 index 000000000..fee1212d8 --- /dev/null +++ b/packages/highlight/src/parsers.ts @@ -0,0 +1,78 @@ +import { parser as jsParser } from '@lezer/javascript' +import { parser as jsonParser } from '@lezer/json' +import { parser as cssParser } from '@lezer/css' +import { parser as cppParser } from '@lezer/cpp' +import { parser as goParser } from '@lezer/go' +import { parser as htmlParser } from '@lezer/html' +import { parser as javaParser } from '@lezer/java' +import { parser as pythonParser } from '@lezer/python' +import { parser as markdownParser } from '@lezer/markdown' +import { parser as phpParser } from '@lezer/php' +import { parser as rustParser } from '@lezer/rust' +import { parser as xmlParser } from '@lezer/xml' +import { parser as yamlParser } from '@lezer/yaml' +import { parser as elixirParser } from 'lezer-elixir' +import type { Parser } from '@lezer/common' + +const parsersByExtension: Record = { + // JavaScript/TypeScript + js: jsParser, + jsx: jsParser.configure({ dialect: 'jsx' }), + ts: jsParser.configure({ dialect: 'ts' }), + tsx: jsParser.configure({ dialect: 'ts jsx' }), + mjs: jsParser, + cjs: jsParser, + // C / C++ / Objective-C + c: cppParser, + h: cppParser, + cc: cppParser, + cpp: cppParser, + cxx: cppParser, + hpp: cppParser, + hxx: cppParser, + m: cppParser, + mm: cppParser, + // JSON + json: jsonParser, + // CSS + css: cssParser, + scss: cssParser, + // HTML + html: htmlParser, + htm: htmlParser, + // XML + xml: xmlParser, + // Java + java: javaParser, + // Python + py: pythonParser, + // Go + go: goParser, + // PHP + php: phpParser, + // YAML + yaml: yamlParser, + yml: yamlParser, + // Rust + rs: rustParser, + // Elixir + ex: elixirParser, + exs: elixirParser, + // Markdown + md: markdownParser, + mdx: markdownParser, +} + +export function getParserForFile(filename: string): Parser | null { + const ext = filename.split('.').pop()?.toLowerCase() + if (!ext) return null + return parsersByExtension[ext] ?? null +} + +export function isLanguageSupported(filename: string): boolean { + return getParserForFile(filename) !== null +} + +export function getSupportedExtensions(): string[] { + return Object.keys(parsersByExtension) +} diff --git a/packages/highlight/src/types.ts b/packages/highlight/src/types.ts new file mode 100644 index 000000000..b40e8fd62 --- /dev/null +++ b/packages/highlight/src/types.ts @@ -0,0 +1,26 @@ +export type HighlightStyle = + | 'keyword' + | 'comment' + | 'string' + | 'number' + | 'literal' + | 'function' + | 'definition' + | 'class' + | 'type' + | 'tag' + | 'attribute' + | 'property' + | 'variable' + | 'operator' + | 'punctuation' + | 'regexp' + | 'escape' + | 'meta' + | 'heading' + | 'link' + +export interface HighlightToken { + text: string + style: HighlightStyle | null +} diff --git a/packages/highlight/tsconfig.json b/packages/highlight/tsconfig.json new file mode 100644 index 000000000..1a33b4d28 --- /dev/null +++ b/packages/highlight/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2020"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/server/package.json b/packages/server/package.json index 8d76bf1df..5081aa9bc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -64,22 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", + "@getpaseo/highlight": "*", "@getpaseo/relay": "0.1.32", - "@lezer/common": "^1.5.0", - "@lezer/cpp": "^1.1.5", - "@lezer/css": "^1.3.0", - "@lezer/go": "^1.0.1", - "@lezer/highlight": "^1.2.3", - "@lezer/html": "^1.3.13", - "@lezer/java": "^1.1.3", - "@lezer/javascript": "^1.5.4", - "@lezer/json": "^1.0.3", - "@lezer/markdown": "^1.6.2", - "@lezer/php": "^1.0.5", - "@lezer/python": "^1.1.18", - "@lezer/rust": "^1.0.2", - "@lezer/xml": "^1.0.6", - "@lezer/yaml": "^1.0.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", "@sctg/sentencepiece-js": "^1.1.0", @@ -90,7 +76,6 @@ "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-uri": "^3.1.0", - "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 8ccbc57fe..4466363e6 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,33 +1,29 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { - highlightCode, - isLanguageSupported, - type HighlightToken, -} from "./syntax-highlighter.js"; +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { highlightCode, isLanguageSupported, type HighlightToken } from '@getpaseo/highlight' export interface DiffLine { - type: "add" | "remove" | "context" | "header"; - content: string; - tokens?: HighlightToken[]; + type: 'add' | 'remove' | 'context' | 'header' + content: string + tokens?: HighlightToken[] } export interface DiffHunk { - oldStart: number; - oldCount: number; - newStart: number; - newCount: number; - lines: DiffLine[]; + oldStart: number + oldCount: number + newStart: number + newCount: number + lines: DiffLine[] } export interface ParsedDiffFile { - path: string; - isNew: boolean; - isDeleted: boolean; - additions: number; - deletions: number; - hunks: DiffHunk[]; - status?: "ok" | "too_large" | "binary"; + path: string + isNew: boolean + isDeleted: boolean + additions: number + deletions: number + hunks: DiffHunk[] + status?: 'ok' | 'too_large' | 'binary' } /** @@ -35,93 +31,87 @@ export interface ParsedDiffFile { */ export function parseDiff(diffText: string): ParsedDiffFile[] { if (!diffText || diffText.trim().length === 0) { - return []; + return [] } - const files: ParsedDiffFile[] = []; - const fileSections = diffText.split(/^diff --git /m).filter(Boolean); + const files: ParsedDiffFile[] = [] + const fileSections = diffText.split(/^diff --git /m).filter(Boolean) for (const section of fileSections) { - const lines = section.split("\n"); - const firstLine = lines[0]; + const lines = section.split('\n') + const firstLine = lines[0] // Detect new/deleted file - const isNew = - section.includes("new file mode") || - section.includes("--- /dev/null"); - const isDeleted = - section.includes("deleted file mode") || - section.includes("+++ /dev/null"); + const isNew = section.includes('new file mode') || section.includes('--- /dev/null') + const isDeleted = section.includes('deleted file mode') || section.includes('+++ /dev/null') // Extract path - let path = "unknown"; - const pathMatch = firstLine.match(/a\/(.*?) b\//); + let path = 'unknown' + const pathMatch = firstLine.match(/a\/(.*?) b\//) if (pathMatch) { - path = pathMatch[1]; + path = pathMatch[1] } else { - const newFileMatch = firstLine.match(/b\/(.+)$/); + const newFileMatch = firstLine.match(/b\/(.+)$/) if (newFileMatch) { - path = newFileMatch[1]; + path = newFileMatch[1] } } - const hunks: DiffHunk[] = []; - let currentHunk: DiffHunk | null = null; - let additions = 0; - let deletions = 0; + const hunks: DiffHunk[] = [] + let currentHunk: DiffHunk | null = null + let additions = 0 + let deletions = 0 for (let i = 1; i < lines.length; i++) { - const line = lines[i]; + const line = lines[i] // Skip metadata lines - if (line.startsWith("index ")) continue; - if (line.startsWith("--- ")) continue; - if (line.startsWith("+++ ")) continue; - if (line.startsWith("new file mode")) continue; - if (line.startsWith("deleted file mode")) continue; + if (line.startsWith('index ')) continue + if (line.startsWith('--- ')) continue + if (line.startsWith('+++ ')) continue + if (line.startsWith('new file mode')) continue + if (line.startsWith('deleted file mode')) continue // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ - const hunkMatch = line.match( - /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ - ); + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/) if (hunkMatch) { if (currentHunk) { - hunks.push(currentHunk); + hunks.push(currentHunk) } currentHunk = { oldStart: parseInt(hunkMatch[1], 10), - oldCount: parseInt(hunkMatch[2] ?? "1", 10), + oldCount: parseInt(hunkMatch[2] ?? '1', 10), newStart: parseInt(hunkMatch[3], 10), - newCount: parseInt(hunkMatch[4] ?? "1", 10), - lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], - }; - continue; + newCount: parseInt(hunkMatch[4] ?? '1', 10), + lines: [{ type: 'header', content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], + } + continue } - if (!currentHunk) continue; + if (!currentHunk) continue - if (line.startsWith("+")) { - currentHunk.lines.push({ type: "add", content: line.slice(1) }); - additions++; - } else if (line.startsWith("-")) { - currentHunk.lines.push({ type: "remove", content: line.slice(1) }); - deletions++; - } else if (line.startsWith(" ")) { - currentHunk.lines.push({ type: "context", content: line.slice(1) }); - } else if (line.length > 0 && !line.startsWith("\\")) { + if (line.startsWith('+')) { + currentHunk.lines.push({ type: 'add', content: line.slice(1) }) + additions++ + } else if (line.startsWith('-')) { + currentHunk.lines.push({ type: 'remove', content: line.slice(1) }) + deletions++ + } else if (line.startsWith(' ')) { + currentHunk.lines.push({ type: 'context', content: line.slice(1) }) + } else if (line.length > 0 && !line.startsWith('\\')) { // Non-empty line that's not a "\ No newline" marker - currentHunk.lines.push({ type: "context", content: line }); + currentHunk.lines.push({ type: 'context', content: line }) } } if (currentHunk) { - hunks.push(currentHunk); + hunks.push(currentHunk) } - files.push({ path, isNew, isDeleted, additions, deletions, hunks }); + files.push({ path, isNew, isDeleted, additions, deletions, hunks }) } - return files; + return files } /** @@ -129,22 +119,22 @@ export function parseDiff(diffText: string): ParsedDiffFile[] { * Returns a map of new line numbers to their content. */ export function reconstructNewFile(hunks: DiffHunk[]): Map { - const lines = new Map(); + const lines = new Map() for (const hunk of hunks) { - let newLineNum = hunk.newStart; + let newLineNum = hunk.newStart for (const line of hunk.lines) { - if (line.type === "header") continue; + if (line.type === 'header') continue - if (line.type === "add" || line.type === "context") { - lines.set(newLineNum, line.content); - newLineNum++; + if (line.type === 'add' || line.type === 'context') { + lines.set(newLineNum, line.content) + newLineNum++ } } } - return lines; + return lines } /** @@ -152,58 +142,58 @@ export function reconstructNewFile(hunks: DiffHunk[]): Map { * Returns a map of old line numbers to their content. */ export function reconstructOldFile(hunks: DiffHunk[]): Map { - const lines = new Map(); + const lines = new Map() for (const hunk of hunks) { - let oldLineNum = hunk.oldStart; + let oldLineNum = hunk.oldStart for (const line of hunk.lines) { - if (line.type === "header") continue; + if (line.type === 'header') continue - if (line.type === "remove" || line.type === "context") { - lines.set(oldLineNum, line.content); - oldLineNum++; + if (line.type === 'remove' || line.type === 'context') { + lines.set(oldLineNum, line.content) + oldLineNum++ } } } - return lines; + return lines } function buildFileContent(lineMap: Map): string { - if (lineMap.size === 0) return ""; + if (lineMap.size === 0) return '' - const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b); - const minLine = lineNumbers[0]; - const maxLine = lineNumbers[lineNumbers.length - 1]; + const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b) + const minLine = lineNumbers[0] + const maxLine = lineNumbers[lineNumbers.length - 1] - const lines: string[] = []; + const lines: string[] = [] for (let i = minLine; i <= maxLine; i++) { - lines.push(lineMap.get(i) ?? ""); + lines.push(lineMap.get(i) ?? '') } - return lines.join("\n"); + return lines.join('\n') } function buildTokenLookup( lineMap: Map, highlighted: HighlightToken[][] ): Map { - const lookup = new Map(); + const lookup = new Map() - if (lineMap.size === 0) return lookup; + if (lineMap.size === 0) return lookup - const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b); - const minLine = lineNumbers[0]; + const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b) + const minLine = lineNumbers[0] for (let i = 0; i < highlighted.length; i++) { - const lineNum = minLine + i; + const lineNum = minLine + i if (lineMap.has(lineNum)) { - lookup.set(lineNum, highlighted[i]); + lookup.set(lineNum, highlighted[i]) } } - return lookup; + return lookup } /** @@ -212,26 +202,26 @@ function buildTokenLookup( */ export function highlightDiffFromHunks(file: ParsedDiffFile): ParsedDiffFile { if (!isLanguageSupported(file.path)) { - return file; + return file } // Reconstruct both versions from hunks - const newFileLines = reconstructNewFile(file.hunks); - const oldFileLines = reconstructOldFile(file.hunks); + const newFileLines = reconstructNewFile(file.hunks) + const oldFileLines = reconstructOldFile(file.hunks) // Build complete file content strings for highlighting - const newFileContent = buildFileContent(newFileLines); - const oldFileContent = buildFileContent(oldFileLines); + const newFileContent = buildFileContent(newFileLines) + const oldFileContent = buildFileContent(oldFileLines) // Highlight both versions - const newHighlighted = highlightCode(newFileContent, file.path); - const oldHighlighted = highlightCode(oldFileContent, file.path); + const newHighlighted = highlightCode(newFileContent, file.path) + const oldHighlighted = highlightCode(oldFileContent, file.path) // Build lookup maps: line number -> tokens - const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted); - const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted); + const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted) + const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted) - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); + return applyTokensToHunks(file, newTokensByLine, oldTokensByLine) } /** @@ -243,34 +233,34 @@ export async function highlightDiffWithFileContent( cwd: string ): Promise { if (!isLanguageSupported(file.path)) { - return file; + return file } - const filePath = resolve(cwd, file.path); + const filePath = resolve(cwd, file.path) try { // Read the current file content (the "new" version) - const fileContent = await readFile(filePath, "utf-8"); + const fileContent = await readFile(filePath, 'utf-8') // Highlight the entire file - const highlighted = highlightCode(fileContent, file.path); + const highlighted = highlightCode(fileContent, file.path) // Build lookup: line number (1-indexed) -> tokens - const tokensByLine = new Map(); + const tokensByLine = new Map() for (let i = 0; i < highlighted.length; i++) { - tokensByLine.set(i + 1, highlighted[i]); + tokensByLine.set(i + 1, highlighted[i]) } // For removed lines, we need to reconstruct from hunks since they're not in the file - const oldFileLines = reconstructOldFile(file.hunks); - const oldFileContent = buildFileContent(oldFileLines); - const oldHighlighted = highlightCode(oldFileContent, file.path); - const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted); + const oldFileLines = reconstructOldFile(file.hunks) + const oldFileContent = buildFileContent(oldFileLines) + const oldHighlighted = highlightCode(oldFileContent, file.path) + const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted) - return applyTokensToHunks(file, tokensByLine, oldTokensByLine); + return applyTokensToHunks(file, tokensByLine, oldTokensByLine) } catch { // If file read fails (deleted file, etc.), fall back to hunk-based highlighting - return highlightDiffFromHunks(file); + return highlightDiffFromHunks(file) } } @@ -280,36 +270,36 @@ function applyTokensToHunks( oldTokensByLine: Map ): ParsedDiffFile { const highlightedHunks = file.hunks.map((hunk) => { - let oldLineNum = hunk.oldStart; - let newLineNum = hunk.newStart; + let oldLineNum = hunk.oldStart + let newLineNum = hunk.newStart const highlightedLines = hunk.lines.map((line): DiffLine => { - if (line.type === "header") { - return line; + if (line.type === 'header') { + return line } - let tokens: HighlightToken[] | undefined; + let tokens: HighlightToken[] | undefined - if (line.type === "add") { - tokens = newTokensByLine.get(newLineNum); - newLineNum++; - } else if (line.type === "remove") { - tokens = oldTokensByLine.get(oldLineNum); - oldLineNum++; - } else if (line.type === "context") { + if (line.type === 'add') { + tokens = newTokensByLine.get(newLineNum) + newLineNum++ + } else if (line.type === 'remove') { + tokens = oldTokensByLine.get(oldLineNum) + oldLineNum++ + } else if (line.type === 'context') { // Context lines exist in both - use new file version - tokens = newTokensByLine.get(newLineNum); - oldLineNum++; - newLineNum++; + tokens = newTokensByLine.get(newLineNum) + oldLineNum++ + newLineNum++ } - return tokens ? { ...line, tokens } : line; - }); + return tokens ? { ...line, tokens } : line + }) - return { ...hunk, lines: highlightedLines }; - }); + return { ...hunk, lines: highlightedLines } + }) - return { ...file, hunks: highlightedHunks }; + return { ...file, hunks: highlightedHunks } } /** @@ -319,14 +309,14 @@ export async function parseAndHighlightDiff( diffText: string, cwd: string ): Promise { - const files = parseDiff(diffText); + const files = parseDiff(diffText) const highlightedFiles = await Promise.all( files.map((file) => highlightDiffWithFileContent(file, cwd)) - ); + ) - return highlightedFiles; + return highlightedFiles } // Re-export types -export type { HighlightToken }; +export type { HighlightToken } diff --git a/packages/server/src/server/utils/syntax-highlighter.ts b/packages/server/src/server/utils/syntax-highlighter.ts deleted file mode 100644 index 89483ebbb..000000000 --- a/packages/server/src/server/utils/syntax-highlighter.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { highlightTree, tagHighlighter, tags } from "@lezer/highlight"; -import { parser as jsParser } from "@lezer/javascript"; -import { parser as jsonParser } from "@lezer/json"; -import { parser as cssParser } from "@lezer/css"; -import { parser as cppParser } from "@lezer/cpp"; -import { parser as goParser } from "@lezer/go"; -import { parser as htmlParser } from "@lezer/html"; -import { parser as javaParser } from "@lezer/java"; -import { parser as pythonParser } from "@lezer/python"; -import { parser as markdownParser } from "@lezer/markdown"; -import { parser as phpParser } from "@lezer/php"; -import { parser as rustParser } from "@lezer/rust"; -import { parser as xmlParser } from "@lezer/xml"; -import { parser as yamlParser } from "@lezer/yaml"; -import { parser as elixirParser } from "lezer-elixir"; -import type { Parser } from "@lezer/common"; - -// Map file extensions to parsers -const parsersByExtension: Record = { - // JavaScript/TypeScript - js: jsParser, - jsx: jsParser.configure({ dialect: "jsx" }), - ts: jsParser.configure({ dialect: "ts" }), - tsx: jsParser.configure({ dialect: "ts jsx" }), - mjs: jsParser, - cjs: jsParser, - // C / C++ / Objective-C - c: cppParser, - h: cppParser, - cc: cppParser, - cpp: cppParser, - cxx: cppParser, - hpp: cppParser, - hxx: cppParser, - m: cppParser, - mm: cppParser, - // JSON - json: jsonParser, - // CSS - css: cssParser, - scss: cssParser, - // HTML - html: htmlParser, - htm: htmlParser, - // XML - xml: xmlParser, - // Java - java: javaParser, - // Python - py: pythonParser, - // Go - go: goParser, - // PHP - php: phpParser, - // YAML - yaml: yamlParser, - yml: yamlParser, - // Rust - rs: rustParser, - // Elixir - ex: elixirParser, - exs: elixirParser, - // Markdown - md: markdownParser, - mdx: markdownParser, -}; - -export type HighlightStyle = - | "keyword" - | "comment" - | "string" - | "number" - | "literal" - | "function" - | "definition" - | "class" - | "type" - | "tag" - | "attribute" - | "property" - | "variable" - | "operator" - | "punctuation" - | "regexp" - | "escape" - | "meta" - | "heading" - | "link"; - -export interface HighlightToken { - text: string; - style: HighlightStyle | null; -} - -// Create highlighter using tagHighlighter -const highlighter = tagHighlighter([ - { tag: tags.keyword, class: "keyword" }, - { tag: tags.controlKeyword, class: "keyword" }, - { tag: tags.operatorKeyword, class: "keyword" }, - { tag: tags.definitionKeyword, class: "keyword" }, - { tag: tags.moduleKeyword, class: "keyword" }, - { tag: tags.comment, class: "comment" }, - { tag: tags.lineComment, class: "comment" }, - { tag: tags.blockComment, class: "comment" }, - { tag: tags.docComment, class: "comment" }, - { tag: tags.string, class: "string" }, - { tag: tags.special(tags.string), class: "string" }, - { tag: tags.number, class: "number" }, - { tag: tags.integer, class: "number" }, - { tag: tags.float, class: "number" }, - { tag: tags.bool, class: "literal" }, - { tag: tags.null, class: "literal" }, - { tag: tags.function(tags.variableName), class: "function" }, - { tag: tags.function(tags.propertyName), class: "function" }, - { tag: tags.definition(tags.variableName), class: "definition" }, - { tag: tags.definition(tags.propertyName), class: "definition" }, - { tag: tags.definition(tags.function(tags.variableName)), class: "definition" }, - { tag: tags.className, class: "class" }, - { tag: tags.definition(tags.className), class: "class" }, - { tag: tags.typeName, class: "type" }, - { tag: tags.tagName, class: "tag" }, - { tag: tags.attributeName, class: "attribute" }, - { tag: tags.attributeValue, class: "string" }, - { tag: tags.propertyName, class: "property" }, - { tag: tags.variableName, class: "variable" }, - { tag: tags.local(tags.variableName), class: "variable" }, - { tag: tags.special(tags.variableName), class: "variable" }, - { tag: tags.operator, class: "operator" }, - { tag: tags.punctuation, class: "punctuation" }, - { tag: tags.bracket, class: "punctuation" }, - { tag: tags.separator, class: "punctuation" }, - { tag: tags.regexp, class: "regexp" }, - { tag: tags.escape, class: "escape" }, - { tag: tags.meta, class: "meta" }, - { tag: tags.heading, class: "heading" }, - { tag: tags.link, class: "link" }, - { tag: tags.url, class: "link" }, -]); - -function getParserForFile(filename: string): Parser | null { - const ext = filename.split(".").pop()?.toLowerCase(); - if (!ext) return null; - return parsersByExtension[ext] ?? null; -} - -export function highlightCode(code: string, filename: string): HighlightToken[][] { - const parser = getParserForFile(filename); - - if (!parser) { - // No parser available, return unhighlighted lines - return code.split("\n").map((line) => [{ text: line, style: null }]); - } - - const tree = parser.parse(code); - const lines = code.split("\n"); - const result: HighlightToken[][] = []; - - // Initialize with unhighlighted content - for (let i = 0; i < lines.length; i++) { - result.push([]); - } - - // Build a map of character positions to styles - const styleMap: Array = new Array(code.length).fill(null); - - // Use highlightTree to populate the style map - highlightTree(tree, highlighter, (from, to, classes) => { - for (let i = from; i < to && i < styleMap.length; i++) { - styleMap[i] = classes as HighlightStyle; - } - }); - - // Convert style map to tokens per line - let pos = 0; - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const line = lines[lineIndex]; - - if (line.length === 0) { - result[lineIndex].push({ text: "", style: null }); - pos++; // skip newline - continue; - } - - let currentToken: HighlightToken = { text: "", style: styleMap[pos] }; - - for (let i = 0; i < line.length; i++) { - const charStyle = styleMap[pos + i]; - if (charStyle === currentToken.style) { - currentToken.text += line[i]; - } else { - if (currentToken.text) { - result[lineIndex].push(currentToken); - } - currentToken = { text: line[i], style: charStyle }; - } - } - - if (currentToken.text) { - result[lineIndex].push(currentToken); - } - - pos += line.length + 1; // +1 for newline - } - - return result; -} - -export function highlightLine(line: string, filename: string): HighlightToken[] { - const result = highlightCode(line, filename); - return result[0] ?? [{ text: line, style: null }]; -} - -export function getSupportedExtensions(): string[] { - return Object.keys(parsersByExtension); -} - -export function isLanguageSupported(filename: string): boolean { - return getParserForFile(filename) !== null; -}