mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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)
This commit is contained in:
496
package-lock.json
generated
496
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HighlightStyle, string>
|
||||
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 (
|
||||
<View style={codeLineStyles.line}>
|
||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||
</View>
|
||||
<Text style={codeLineStyles.lineText}>
|
||||
{tokens.map((token, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
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<RNScrollView>(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<RNScrollView>(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<NativeScrollEvent>) => {
|
||||
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({
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
@@ -84,10 +171,26 @@ function FilePreviewBody({
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (preview.kind === 'text') {
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]]
|
||||
const codeLines = (
|
||||
<View>
|
||||
{lines.map((tokens, index) => (
|
||||
<CodeLine
|
||||
key={index}
|
||||
tokens={tokens}
|
||||
lineNumber={index + 1}
|
||||
gutterWidth={gutterWidth}
|
||||
colorMap={colorMap}
|
||||
baseColor={baseColor}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
@@ -96,17 +199,13 @@ function FilePreviewBody({
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</View>
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
@@ -114,7 +213,7 @@ function FilePreviewBody({
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
@@ -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 })
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
if (preview.kind === 'image' && preview.content) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
@@ -139,16 +238,14 @@ function FilePreviewBody({
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
@@ -158,11 +255,11 @@ function FilePreviewBody({
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -170,7 +267,7 @@ function FilePreviewBody({
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
@@ -220,9 +316,10 @@ export function FilePane({
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
filePath={filePath}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<number, string> {
|
||||
const lines = new Map<number, string>();
|
||||
const lines = new Map<number, string>()
|
||||
|
||||
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<number, string> {
|
||||
* Returns a map of old line numbers to their content.
|
||||
*/
|
||||
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
const lines = new Map<number, string>();
|
||||
const lines = new Map<number, string>()
|
||||
|
||||
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<number, string> {
|
||||
*/
|
||||
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<number, string>): 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<number, string>,
|
||||
highlighted: HighlightToken[][]
|
||||
): Map<number, HighlightToken[]> {
|
||||
const lookup = new Map<number, HighlightToken[]>();
|
||||
const lookup = new Map<number, HighlightToken[]>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<string, Parser> = {
|
||||
// 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<HighlightStyle | null> = 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;
|
||||
}
|
||||
36
packages/highlight/package.json
Normal file
36
packages/highlight/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
65
packages/highlight/src/__tests__/colors.test.ts
Normal file
65
packages/highlight/src/__tests__/colors.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
125
packages/highlight/src/__tests__/highlighter.test.ts
Normal file
125
packages/highlight/src/__tests__/highlighter.test.ts
Normal file
@@ -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 = <div className="test">hello</div>;'
|
||||
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 }])
|
||||
})
|
||||
})
|
||||
74
packages/highlight/src/__tests__/parsers.test.ts
Normal file
74
packages/highlight/src/__tests__/parsers.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
47
packages/highlight/src/colors.ts
Normal file
47
packages/highlight/src/colors.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { HighlightStyle } from './types.js'
|
||||
|
||||
export const darkHighlightColors: Record<HighlightStyle, string> = {
|
||||
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<HighlightStyle, string> = {
|
||||
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',
|
||||
}
|
||||
111
packages/highlight/src/highlighter.ts
Normal file
111
packages/highlight/src/highlighter.ts
Normal file
@@ -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<HighlightStyle | null> = 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 }]
|
||||
}
|
||||
4
packages/highlight/src/index.ts
Normal file
4
packages/highlight/src/index.ts
Normal file
@@ -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'
|
||||
78
packages/highlight/src/parsers.ts
Normal file
78
packages/highlight/src/parsers.ts
Normal file
@@ -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<string, Parser> = {
|
||||
// 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)
|
||||
}
|
||||
26
packages/highlight/src/types.ts
Normal file
26
packages/highlight/src/types.ts
Normal file
@@ -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
|
||||
}
|
||||
20
packages/highlight/tsconfig.json
Normal file
20
packages/highlight/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number, string> {
|
||||
const lines = new Map<number, string>();
|
||||
const lines = new Map<number, string>()
|
||||
|
||||
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<number, string> {
|
||||
* Returns a map of old line numbers to their content.
|
||||
*/
|
||||
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
|
||||
const lines = new Map<number, string>();
|
||||
const lines = new Map<number, string>()
|
||||
|
||||
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<number, string>): 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<number, string>,
|
||||
highlighted: HighlightToken[][]
|
||||
): Map<number, HighlightToken[]> {
|
||||
const lookup = new Map<number, HighlightToken[]>();
|
||||
const lookup = new Map<number, HighlightToken[]>()
|
||||
|
||||
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<ParsedDiffFile> {
|
||||
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<number, HighlightToken[]>();
|
||||
const tokensByLine = new Map<number, HighlightToken[]>()
|
||||
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<number, HighlightToken[]>
|
||||
): 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<ParsedDiffFile[]> {
|
||||
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 }
|
||||
|
||||
@@ -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<string, Parser> = {
|
||||
// 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<HighlightStyle | null> = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user