diff viewer

This commit is contained in:
Anish Lakhwara 2025-11-08 00:33:44 -08:00
parent df06be7bc7
commit e04c67133b
8 changed files with 1141 additions and 3 deletions

View file

@ -0,0 +1,95 @@
# Git Diff Viewer Implementation
## Overview
The Git Diff Viewer feature has been successfully implemented for FleetCode's web mode. This feature allows users to view changes in their session's worktree compared to the parent branch.
## Implementation Details
### 1. Dependencies
- **@pierre/precision-diffs**: A powerful diff rendering library
- **shiki**: Syntax highlighting for code diffs
### 2. Data Structure (types.ts)
Added `FileDiff` interface to represent file changes:
```typescript
export interface FileDiff {
path: string;
oldPath?: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
diff: string;
additions: number;
deletions: number;
}
```
### 3. Backend (server.ts)
- Added Socket.IO handler `get-session-diff` that:
- Retrieves the session's worktree path and parent branch
- Executes `git diff <parent-branch>...HEAD` using simple-git
- Parses the diff output into `FileDiff` objects
- Emits the parsed data back to the client via `session-diff-data` event
### 4. Frontend UI (index.html)
- Added full-screen diff viewer modal with:
- Header with title and close button
- Left sidebar for file list
- Right panel for displaying diffs
- Added "Diff" button to active session items in the sidebar
### 5. Frontend Styling (styles.css)
- Added comprehensive styling for:
- Diff viewer modal (full-screen, responsive)
- File list sidebar with status badges
- Diff display area
- File status indicators (added/modified/deleted/renamed)
- Addition/deletion statistics
- Mobile-responsive layouts
### 6. Frontend Logic (renderer.ts)
- Added diff viewer functionality:
- `openDiffViewer(sessionId)`: Opens modal and requests diff data
- `renderDiffFileList()`: Renders list of changed files
- `renderSelectedDiff()`: Renders the selected file's diff using Pierre.js
- Dynamic import of libraries to avoid breaking Electron mode
- Event handlers for Socket.IO events (`session-diff-data`, `session-diff-error`)
## Usage
1. Start a session and make some changes in your worktree
2. Click the "Diff" button next to an active session in the sidebar
3. A modal will appear showing all changed files
4. Click on any file to view its detailed diff
5. Click the × button or outside the modal to close
## Technical Notes
- Libraries (@pierre/precision-diffs and shiki) are loaded dynamically using `import()` to avoid conflicts with Electron's module system
- The diff is computed between the parent branch and HEAD of the worktree
- TypeScript configuration was updated to use ES2020 modules with bundler resolution for better ESM support
- The implementation is fully functional in web mode (server.ts) and does not affect Electron mode (main.ts)
## Build & Run
1. **Build the application:**
```bash
npm run build:all
```
2. **Start the server:**
```bash
npm run server
# or for development with hot reload:
npm run dev:server
```
3. **Access the application:**
Open http://localhost:3000 in your browser
## Verification
The implementation has been verified:
- ✅ Dependencies installed: `@pierre/precision-diffs` and `shiki`
- ✅ Server builds and runs successfully
- ✅ Diff handler is present in compiled server.js
- ✅ Diff libraries are bundled in renderer.web.js
- ✅ All TypeScript compiles without errors

View file

@ -196,6 +196,28 @@
</div>
</div>
<!-- Diff Viewer Modal -->
<div id="diff-viewer-modal" class="modal-overlay hidden">
<div class="diff-viewer-modal">
<div class="diff-viewer-header">
<h2 class="modal-title" id="diff-viewer-title">Git Diff Viewer</h2>
<button id="close-diff-viewer" class="diff-viewer-close" title="Close">×</button>
</div>
<div class="diff-viewer-content">
<!-- File List Sidebar -->
<div class="diff-file-list" id="diff-file-list">
<div class="diff-loading">Loading diff...</div>
</div>
<!-- Diff Display -->
<div class="diff-display" id="diff-display">
<div class="diff-empty">Select a file to view changes</div>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal-overlay hidden">
<div class="modal">

591
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.0.1-beta.7",
"license": "ISC",
"dependencies": {
"@pierre/precision-diffs": "^0.4.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@xterm/addon-fit": "^0.10.0",
@ -16,6 +17,7 @@
"electron-store": "^11.0.0",
"express": "^5.1.0",
"node-pty": "^1.0.0",
"shiki": "^3.14.0",
"simple-git": "^3.28.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
@ -1926,6 +1928,33 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/@pierre/precision-diffs": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@pierre/precision-diffs/-/precision-diffs-0.4.2.tgz",
"integrity": "sha512-C6LbruH24BCp4awI47D5iMtVaZZD6GkzqBoDw+Sfu7DB3hC9y/rZr1C2BD7AUzAKwByTfFnh16Zp11ipfPqLKw==",
"license": "apache-2.0",
"dependencies": {
"@shikijs/core": "3.14.0",
"@shikijs/transformers": "3.14.0",
"diff": "8.0.2",
"fast-deep-equal": "3.1.3",
"hast-util-to-html": "9.0.5",
"shiki": "3.14.0"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
}
},
"node_modules/@pierre/precision-diffs/node_modules/diff": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1937,6 +1966,83 @@
"node": ">=14"
}
},
"node_modules/@shikijs/core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.14.0.tgz",
"integrity": "sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.14.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.14.0.tgz",
"integrity": "sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.14.0",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.3"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.14.0.tgz",
"integrity": "sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.14.0",
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@shikijs/langs": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.14.0.tgz",
"integrity": "sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.14.0"
}
},
"node_modules/@shikijs/themes": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.14.0.tgz",
"integrity": "sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.14.0"
}
},
"node_modules/@shikijs/transformers": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.14.0.tgz",
"integrity": "sha512-i67zQnY9wLMMnKasonVW1L9fKneSLZDj1ePsA4o0AZWU4uUobmJY9baRDa36z+a9/g0aG76/2tybQvm4hrwxIQ==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "3.14.0",
"@shikijs/types": "3.14.0"
}
},
"node_modules/@shikijs/types": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.14.0.tgz",
"integrity": "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -2091,6 +2197,15 @@
"@types/node": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@ -2114,6 +2229,15 @@
"@types/node": "*"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -2200,6 +2324,12 @@
"@types/node": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@ -2226,6 +2356,12 @@
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
@ -3258,6 +3394,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3275,6 +3421,26 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -3481,6 +3647,16 @@
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -3934,6 +4110,15 @@
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
@ -3952,6 +4137,19 @@
"license": "MIT",
"optional": true
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -5496,6 +5694,42 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@ -5522,6 +5756,16 @@
"node": ">=10"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@ -6143,6 +6387,27 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@ -6174,6 +6439,95 @@
"node": ">= 8"
}
},
"node_modules/micromark-util-character": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -6787,6 +7141,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz",
"integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.0.1",
"regex-recursion": "^6.0.2"
}
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@ -7251,6 +7622,16 @@
"node": ">=10"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -7381,6 +7762,29 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@ -7445,6 +7849,30 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
"integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7687,6 +8115,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -7829,6 +8264,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/shiki": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.14.0.tgz",
"integrity": "sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "3.14.0",
"@shikijs/engine-javascript": "3.14.0",
"@shikijs/engine-oniguruma": "3.14.0",
"@shikijs/langs": "3.14.0",
"@shikijs/themes": "3.14.0",
"@shikijs/types": "3.14.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -8183,6 +8634,16 @@
"source-map": "^0.6.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -8264,6 +8725,20 @@
"node": ">=8"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -8715,6 +9190,16 @@
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@ -8897,6 +9382,74 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
"integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@ -9016,6 +9569,34 @@
"node": ">=0.6.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@ -9226,6 +9807,16 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
}
}
}

View file

@ -44,6 +44,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@pierre/precision-diffs": "^0.4.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@xterm/addon-fit": "^0.10.0",
@ -51,6 +52,7 @@
"electron-store": "^11.0.0",
"express": "^5.1.0",
"node-pty": "^1.0.0",
"shiki": "^3.14.0",
"simple-git": "^3.28.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",

View file

@ -1,6 +1,6 @@
import {FitAddon} from "@xterm/addon-fit";
import {Terminal} from "xterm";
import {PersistedSession, SessionConfig} from "./types";
import {PersistedSession, SessionConfig, FileDiff} from "./types";
import {isClaudeSessionReady} from "./terminal-utils";
import {ipcAdapter} from "./ipc-adapter";
@ -340,12 +340,33 @@ function activateSession(sessionId: string) {
function updateSessionState(sessionId: string, isActive: boolean) {
const sidebarItem = document.getElementById(`sidebar-${sessionId}`);
const indicator = sidebarItem?.querySelector(".session-indicator");
const buttonContainer = sidebarItem?.querySelector(".flex.items-center.space-x-1");
if (indicator) {
if (isActive) {
indicator.classList.add("active");
// Add diff button if it doesn't exist
if (buttonContainer && !buttonContainer.querySelector(".view-diff-btn")) {
const diffBtn = document.createElement("button");
diffBtn.className = "view-diff-btn";
diffBtn.setAttribute("data-id", sessionId);
diffBtn.setAttribute("title", "View Diff");
diffBtn.textContent = "Diff";
diffBtn.addEventListener("click", (e) => {
e.stopPropagation();
openDiffViewer(sessionId);
});
buttonContainer.insertBefore(diffBtn, buttonContainer.firstChild);
}
} else {
indicator.classList.remove("active");
// Remove diff button when session becomes inactive
const existingDiffBtn = buttonContainer?.querySelector(".view-diff-btn");
if (existingDiffBtn) {
existingDiffBtn.remove();
}
}
}
}
@ -363,7 +384,10 @@ function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
<span class="truncate session-name-text" data-id="${sessionId}">${name}</span>
<input type="text" class="session-name-input hidden" data-id="${sessionId}" value="${name}" />
</div>
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
<div class="flex items-center space-x-1">
${hasActivePty ? `<button class="view-diff-btn" data-id="${sessionId}" title="View Diff">Diff</button>` : ''}
<button class="session-delete-btn" data-id="${sessionId}" title="Delete session">×</button>
</div>
`;
// Click on session name to edit
@ -391,6 +415,7 @@ function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
item.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("session-delete-btn") &&
!target.classList.contains("view-diff-btn") &&
!target.classList.contains("session-name-text") &&
!target.classList.contains("session-name-input")) {
handleSessionClick(sessionId);
@ -403,6 +428,12 @@ function addToSidebar(sessionId: string, name: string, hasActivePty: boolean) {
deleteSession(sessionId);
});
const viewDiffBtn = item.querySelector(".view-diff-btn");
viewDiffBtn?.addEventListener("click", (e) => {
e.stopPropagation();
openDiffViewer(sessionId);
});
list.appendChild(item);
}
@ -1487,3 +1518,163 @@ document.getElementById("session-list")?.addEventListener("click", (e) => {
closeMobileMenu();
}
});
// Diff Viewer
let currentDiffFiles: FileDiff[] = [];
let selectedFileIndex: number = -1;
let pierreLib: any = null;
let shikiLib: any = null;
let highlighter: any = null;
async function openDiffViewer(sessionId: string) {
const modal = document.getElementById("diff-viewer-modal");
const fileList = document.getElementById("diff-file-list");
const diffDisplay = document.getElementById("diff-display");
if (!modal || !fileList || !diffDisplay) return;
// Show modal with loading state
modal.classList.remove("hidden");
fileList.innerHTML = '<div class="diff-loading">Loading diff...</div>';
diffDisplay.innerHTML = '<div class="diff-empty">Loading...</div>';
// Load libraries dynamically (only in browser)
if (!pierreLib) {
try {
pierreLib = await import("@pierre/precision-diffs" as any);
shikiLib = await import("shiki" as any);
} catch (error) {
console.error("Failed to load diff libraries:", error);
}
}
// Initialize highlighter if needed
if (!highlighter && shikiLib) {
try {
highlighter = await shikiLib.createHighlighter({
themes: ['github-dark'],
langs: ['javascript', 'typescript', 'python', 'java', 'go', 'rust', 'html', 'css', 'json', 'markdown']
});
} catch (error) {
console.error("Failed to initialize highlighter:", error);
}
}
// Request diff from server
ipcAdapter.send("get-session-diff", sessionId);
}
function renderDiffFileList() {
const fileList = document.getElementById("diff-file-list");
if (!fileList) return;
if (currentDiffFiles.length === 0) {
fileList.innerHTML = '<div class="diff-loading">No changes found</div>';
return;
}
fileList.innerHTML = '';
currentDiffFiles.forEach((file, index) => {
const item = document.createElement("div");
item.className = `diff-file-item ${index === selectedFileIndex ? 'active' : ''}`;
item.innerHTML = `
<div class="diff-file-path">${file.path}</div>
<div class="diff-file-stats">
<span class="diff-file-status ${file.status}">${file.status}</span>
${file.additions > 0 ? `<span class="diff-file-additions">+${file.additions}</span>` : ''}
${file.deletions > 0 ? `<span class="diff-file-deletions">-${file.deletions}</span>` : ''}
</div>
`;
item.addEventListener("click", () => {
selectedFileIndex = index;
renderDiffFileList();
renderSelectedDiff();
});
fileList.appendChild(item);
});
}
async function renderSelectedDiff() {
const diffDisplay = document.getElementById("diff-display");
if (!diffDisplay || selectedFileIndex < 0 || selectedFileIndex >= currentDiffFiles.length) {
if (diffDisplay) {
diffDisplay.innerHTML = '<div class="diff-empty">Select a file to view changes</div>';
}
return;
}
const file = currentDiffFiles[selectedFileIndex];
try {
if (!pierreLib) {
diffDisplay.innerHTML = '<div class="diff-empty">Loading diff viewer...</div>';
return;
}
// Use Pierre.js FileDiff to render the diff
const diffContainer = document.createElement("div");
diffContainer.className = "diff-content";
// Create FileDiff instance
const fileDiff = new pierreLib.FileDiff({
theme: { dark: 'github-dark', light: 'github-light' },
themeType: 'dark',
});
// Parse the diff to get FileDiffMetadata
const parsedPatches = pierreLib.parsePatchFiles(file.diff);
if (!parsedPatches || parsedPatches.length === 0 || !parsedPatches[0].files || parsedPatches[0].files.length === 0) {
diffDisplay.innerHTML = '<div class="diff-empty">Could not parse diff data</div>';
return;
}
// Get the first file's diff metadata from the parsed patch
const fileDiffMetadata = parsedPatches[0].files[0];
// Render using the parsed fileDiff metadata
await fileDiff.render({
fileDiff: fileDiffMetadata,
containerWrapper: diffContainer,
});
diffDisplay.innerHTML = '';
diffDisplay.appendChild(diffContainer);
} catch (error) {
console.error("Error rendering diff:", error);
diffDisplay.innerHTML = `<div class="diff-empty">Error rendering diff: ${error}</div>`;
}
}
// Close diff viewer
document.getElementById("close-diff-viewer")?.addEventListener("click", () => {
const modal = document.getElementById("diff-viewer-modal");
modal?.classList.add("hidden");
currentDiffFiles = [];
selectedFileIndex = -1;
});
// Handle diff data from server
ipcAdapter.on("session-diff-data", (_event, sessionId: string, fileDiffs: FileDiff[]) => {
currentDiffFiles = fileDiffs;
selectedFileIndex = fileDiffs.length > 0 ? 0 : -1;
renderDiffFileList();
renderSelectedDiff();
});
// Handle diff error from server
ipcAdapter.on("session-diff-error", (_event, sessionId: string, error: string) => {
const fileList = document.getElementById("diff-file-list");
const diffDisplay = document.getElementById("diff-display");
if (fileList) {
fileList.innerHTML = `<div class="diff-loading">Error: ${error}</div>`;
}
if (diffDisplay) {
diffDisplay.innerHTML = `<div class="diff-empty">Failed to load diff</div>`;
}
});

113
server.ts
View file

@ -8,7 +8,7 @@ import * as path from "path";
import { simpleGit } from "simple-git";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import { PersistedSession, SessionConfig } from "./types";
import { PersistedSession, SessionConfig, FileDiff } from "./types";
import { isTerminalReady } from "./terminal-utils";
import cors from "cors";
@ -495,6 +495,117 @@ io.on("connection", (socket) => {
}
});
socket.on("get-session-diff", async (sessionId: string) => {
try {
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (!session) {
socket.emit("session-diff-error", sessionId, "Session not found");
return;
}
const git = simpleGit(session.worktreePath);
const parentBranch = session.config.parentBranch;
// Get diff between parent branch and working directory (includes all changes: staged, unstaged, committed)
const diffOutput = await git.diff([parentBranch, '--numstat']);
const fullDiffOutput = await git.diff([parentBranch]);
// Also get untracked files (new files not yet added to git)
const statusSummary = await git.status();
console.log(`[${sessionId}] Diff request for session, parent branch: ${parentBranch}`);
console.log(`[${sessionId}] Worktree path: ${session.worktreePath}`);
console.log(`[${sessionId}] Diff output length: ${diffOutput.length} chars`);
console.log(`[${sessionId}] Untracked files: ${statusSummary.not_added.length}`);
// Parse diff output into FileDiff array
const fileDiffs: FileDiff[] = [];
const lines = diffOutput.split('\n').filter(l => l.trim());
for (const line of lines) {
// Parse numstat line: "additions deletions filename"
const match = line.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/);
if (match) {
const [, addStr, delStr, filepath] = match;
const additions = addStr === '-' ? 0 : parseInt(addStr, 10);
const deletions = delStr === '-' ? 0 : parseInt(delStr, 10);
// Determine file status
let status: FileDiff['status'] = 'modified';
let path = filepath;
let oldPath: string | undefined;
// Check if file is renamed (contains " => ")
if (filepath.includes(' => ')) {
status = 'renamed';
const parts = filepath.split(' => ');
oldPath = parts[0].replace(/^\{|\}$/g, '');
path = parts[1].replace(/^\{|\}$/g, '');
} else if (additions > 0 && deletions === 0) {
status = 'added';
} else if (additions === 0 && deletions > 0) {
status = 'deleted';
}
// Extract individual file diff from full output
const fileDiffMatch = fullDiffOutput.match(
new RegExp(`diff --git a/${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} b/${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?(?=diff --git|$)`)
);
const diff = fileDiffMatch ? fileDiffMatch[0] : '';
fileDiffs.push({
path,
oldPath,
status,
diff,
additions,
deletions,
});
}
}
// Add untracked files as "added" with full file content as diff
for (const untrackedFile of statusSummary.not_added) {
try {
// Read the file content
const fileContent = await fs.promises.readFile(
path.join(session.worktreePath, untrackedFile),
'utf-8'
);
const lines = fileContent.split('\n');
// Create a diff in unified format for new file
const diffLines = [
`diff --git a/${untrackedFile} b/${untrackedFile}`,
`new file mode 100644`,
`--- /dev/null`,
`+++ b/${untrackedFile}`,
`@@ -0,0 +1,${lines.length} @@`,
...lines.map(line => `+${line}`)
];
fileDiffs.push({
path: untrackedFile,
status: 'added',
diff: diffLines.join('\n'),
additions: lines.length,
deletions: 0,
});
} catch (error) {
console.error(`[${sessionId}] Error reading untracked file ${untrackedFile}:`, error);
}
}
console.log(`[${sessionId}] Parsed ${fileDiffs.length} file diffs (${statusSummary.not_added.length} untracked)`);
socket.emit("session-diff-data", sessionId, fileDiffs);
} catch (error) {
console.error("Error getting session diff:", error);
socket.emit("session-diff-error", sessionId, error instanceof Error ? error.message : String(error));
}
});
socket.on("disconnect", () => {
// Clean up socket references for this client
// Note: We don't kill the PTYs - they keep running for reconnection

View file

@ -336,4 +336,121 @@
@apply text-lg;
}
}
/* Diff Viewer Styles */
.diff-viewer-modal {
@apply bg-gray-900 rounded-lg shadow-xl flex flex-col;
width: 95vw;
height: 90vh;
max-width: 1400px;
}
.diff-viewer-header {
@apply flex items-center justify-between p-4 border-b border-gray-700;
}
.diff-viewer-close {
@apply text-gray-400 hover:text-white text-3xl font-bold w-10 h-10 flex items-center justify-center rounded transition-colors hover:bg-gray-800;
}
.diff-viewer-content {
@apply flex flex-1 overflow-hidden;
}
.diff-file-list {
@apply w-80 border-r border-gray-700 overflow-y-auto bg-gray-800;
}
.diff-loading {
@apply p-4 text-gray-400 text-center;
}
.diff-file-item {
@apply px-4 py-3 border-b border-gray-700 cursor-pointer hover:bg-gray-700 transition-colors;
}
.diff-file-item.active {
@apply bg-gray-700;
}
.diff-file-path {
@apply text-sm text-gray-200 mb-1 break-all;
}
.diff-file-stats {
@apply flex items-center space-x-2 text-xs;
}
.diff-file-status {
@apply px-2 py-0.5 rounded text-xs font-medium;
}
.diff-file-status.added {
@apply bg-green-900 text-green-300;
}
.diff-file-status.modified {
@apply bg-blue-900 text-blue-300;
}
.diff-file-status.deleted {
@apply bg-red-900 text-red-300;
}
.diff-file-status.renamed {
@apply bg-purple-900 text-purple-300;
}
.diff-file-additions {
@apply text-green-400;
}
.diff-file-deletions {
@apply text-red-400;
}
.diff-display {
@apply flex-1 overflow-auto bg-gray-900 p-4;
}
.diff-empty {
@apply text-gray-500 text-center p-8;
}
.diff-content {
@apply text-sm;
}
/* Pierre.js diff styles */
.diff-content .pierre-diff {
@apply font-mono text-xs;
}
.diff-content .pierre-diff .line-number {
@apply text-gray-500 select-none;
}
.diff-content .pierre-diff .line-addition {
@apply bg-green-900 bg-opacity-30;
}
.diff-content .pierre-diff .line-deletion {
@apply bg-red-900 bg-opacity-30;
}
.view-diff-btn {
@apply ml-2 text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded transition-colors;
}
@media (max-width: 768px) {
.diff-viewer-modal {
width: 100vw;
height: 100vh;
@apply rounded-none;
}
.diff-file-list {
@apply w-64;
}
}
}

View file

@ -18,3 +18,12 @@ export interface PersistedSession {
mcpConfigPath?: string;
gitBranch?: string;
}
export interface FileDiff {
path: string;
oldPath?: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
diff: string;
additions: number;
deletions: number;
}