|
| 1 | +# DiffTool — Requirements & Rebuild Specification |
| 2 | + |
| 3 | +A fully client-side React web app for comparing two code files or text snippets. |
| 4 | +No backend. Deployable as a static site on GitHub Pages. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## 1. Goals |
| 9 | + |
| 10 | +- User can paste two blocks of text/code and compare them. |
| 11 | +- User can upload two files and compare them. |
| 12 | +- Both sides can mix paste and upload independently. |
| 13 | +- Supports any plain-text format including `.ipynb` Jupyter notebooks. |
| 14 | +- Diff renders with line numbers, added/removed highlighting, and a change summary. |
| 15 | +- Zero server dependency — everything runs in the browser. |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## 2. Tech Stack |
| 20 | + |
| 21 | +| Tool | Version | Purpose | |
| 22 | +|---|---|---| |
| 23 | +| React | ^18.3.1 | UI framework | |
| 24 | +| Vite | ^5.4.10 | Build tool + dev server | |
| 25 | +| @vitejs/plugin-react | ^4.3.1 | JSX support in Vite | |
| 26 | +| diff (jsdiff) | ^5.2.0 | Compute unified diff patches and line-level stats | |
| 27 | +| diff2html | ^3.4.48 | Render unified diff as HTML (side-by-side or unified) | |
| 28 | + |
| 29 | +No CSS framework, no router, no state management library. |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## 3. Directory Structure |
| 34 | + |
| 35 | +``` |
| 36 | +project-root/ |
| 37 | +├── .github/ |
| 38 | +│ └── workflows/ |
| 39 | +│ └── deploy.yml # GitHub Actions: build + deploy to Pages |
| 40 | +├── src/ |
| 41 | +│ ├── components/ |
| 42 | +│ │ ├── InputPane.jsx # One input panel (paste tab + upload tab) |
| 43 | +│ │ └── DiffViewer.jsx # Renders the diff output |
| 44 | +│ ├── utils/ |
| 45 | +│ │ └── parseFile.js # Reads File objects → plain text string |
| 46 | +│ ├── App.jsx # Root component, all state lives here |
| 47 | +│ ├── App.css # All styles (single CSS file, no modules) |
| 48 | +│ └── main.jsx # React entry point |
| 49 | +├── index.html |
| 50 | +├── package.json |
| 51 | +└── vite.config.js |
| 52 | +``` |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## 4. Feature Requirements |
| 57 | + |
| 58 | +### 4.1 Input Panes |
| 59 | + |
| 60 | +There are two input panes side by side: **Original** (left) and **Modified** (right). |
| 61 | + |
| 62 | +Each pane has two tabs: |
| 63 | + |
| 64 | +**Paste tab** |
| 65 | +- Monospace `<textarea>` with `spellCheck={false}`. |
| 66 | +- Placeholder: `"Paste original code here..."` / `"Paste modified code here..."`. |
| 67 | +- User can type or paste any text. |
| 68 | + |
| 69 | +**Upload tab** |
| 70 | +- A dashed-border drop zone that accepts drag-and-drop or click-to-browse. |
| 71 | +- Clicking the zone opens a hidden `<input type="file">`. |
| 72 | +- Dragging a file over the zone changes its border and background color (visual feedback). |
| 73 | +- After a file is loaded: |
| 74 | + - Show the file name and a file icon. |
| 75 | + - Show two buttons: **Change** (re-opens file picker) and **Remove** (clears the pane). |
| 76 | + - Do NOT switch back to the Paste tab — stay on Upload tab showing the loaded file. |
| 77 | +- The parsed file text is fed into the same state as the paste textarea (shared `value`). |
| 78 | + |
| 79 | +### 4.2 Swap Button |
| 80 | + |
| 81 | +A narrow vertical button between the two panes with a swap/arrows icon. |
| 82 | +- Clicking it swaps left content ↔ right content AND left filename ↔ right filename. |
| 83 | +- Resets `compared` state to `false` (hides the old diff output). |
| 84 | + |
| 85 | +### 4.3 Controls Bar |
| 86 | + |
| 87 | +A horizontal bar below the input section containing: |
| 88 | + |
| 89 | +**Left side:** |
| 90 | +- **Compare** button — primary action, indigo/purple, disabled when both sides are empty. |
| 91 | +- **Clear** button — ghost style, disabled when both sides are empty. Resets all state. |
| 92 | + |
| 93 | +**Right side:** |
| 94 | +- **Diff stats badge** — shown only after Compare is clicked. Displays `+N` (green) and `-N` (red) line counts. Label: "lines changed". |
| 95 | +- **View toggle** — two buttons: "Side by Side" and "Unified". Switching is instant; does NOT require re-clicking Compare. |
| 96 | + |
| 97 | +### 4.4 Diff Output |
| 98 | + |
| 99 | +Shown only after Compare is clicked. Hidden (replaced by empty state) before first Compare. |
| 100 | + |
| 101 | +- Uses `diff2html` to render the diff. |
| 102 | +- `drawFileList: false` — do not show the file list header. |
| 103 | +- `matching: 'lines'` — match lines for better diff quality. |
| 104 | +- `outputFormat`: `'side-by-side'` or `'line-by-line'` based on the view toggle. |
| 105 | +- The diff is computed from a **snapshot** taken at the moment Compare is clicked, not live. Editing the textarea after Compare does not update the diff until Compare is clicked again. |
| 106 | +- The `fileName` passed to `createPatch` is the uploaded file name if one exists, otherwise `'file'`. |
| 107 | + |
| 108 | +### 4.5 Empty State |
| 109 | + |
| 110 | +When no diff has been run yet, show a centered placeholder with: |
| 111 | +- A faint SVG icon (diff-like visual). |
| 112 | +- Text: `Paste code or upload files above, then click Compare`. |
| 113 | + |
| 114 | +### 4.6 Footer |
| 115 | + |
| 116 | +Full-width footer at the bottom: |
| 117 | +> "Runs entirely in your browser — no data is sent to any server" |
| 118 | +
|
| 119 | +--- |
| 120 | + |
| 121 | +## 5. File Parsing Logic (`src/utils/parseFile.js`) |
| 122 | + |
| 123 | +``` |
| 124 | +parseFile(file: File) → Promise<string> |
| 125 | +``` |
| 126 | + |
| 127 | +- Read the file as text using `file.text()`. |
| 128 | +- If `file.name` ends with `.ipynb`: run `parseNotebook(text)`. |
| 129 | +- Otherwise: return the raw text string. |
| 130 | + |
| 131 | +**`parseNotebook(text: string) → string`** |
| 132 | + |
| 133 | +- Parse JSON. If parse fails, return raw text as fallback. |
| 134 | +- Access `nb.cells` array (default to `[]`). |
| 135 | +- For each cell: |
| 136 | + - Extract `cell.source`: if it's an array, join with `''`; if string, use as-is; fallback to `''`. |
| 137 | + - Build a header: `# ── Cell N [cell_type] ──` where N is 1-indexed. |
| 138 | + - Combine: `header + '\n' + source`. |
| 139 | +- Join all cells with `'\n\n'` between them. |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## 6. State (App.jsx) |
| 144 | + |
| 145 | +| State variable | Type | Purpose | |
| 146 | +|---|---|---| |
| 147 | +| `left` | string | Current text in the left pane | |
| 148 | +| `right` | string | Current text in the right pane | |
| 149 | +| `leftFile` | string \| null | Filename of the uploaded left file | |
| 150 | +| `rightFile` | string \| null | Filename of the uploaded right file | |
| 151 | +| `viewType` | `'side-by-side'` \| `'unified'` | Which diff view to render | |
| 152 | +| `compared` | boolean | Whether a diff has been computed and shown | |
| 153 | +| `diffSnapshot` | `{ left, right, fileName }` | Frozen copy of content at Compare time | |
| 154 | + |
| 155 | +**Key behavior:** |
| 156 | +- `compared` resets to `false` whenever either `left` or `right` content changes (typing or new file). |
| 157 | +- `compared` resets to `false` on Swap and Clear. |
| 158 | +- `stats` (added/removed line counts) is derived via `useMemo` from `diffSnapshot` when `compared` is `true`. |
| 159 | +- `isEmpty` is `true` when both `left.trim()` and `right.trim()` are empty strings — used to disable Compare and Clear. |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +## 7. Component Props |
| 164 | + |
| 165 | +### `InputPane` |
| 166 | + |
| 167 | +| Prop | Type | Description | |
| 168 | +|---|---|---| |
| 169 | +| `label` | string | `"Original"` or `"Modified"` — shown in pane header, used in textarea placeholder | |
| 170 | +| `value` | string | Controlled text value | |
| 171 | +| `onChange` | `(text: string) => void` | Called when text changes (paste or file upload) | |
| 172 | +| `onFile` | `(name: string \| null) => void` | Called with filename when file is loaded, or `null` when removed | |
| 173 | + |
| 174 | +Internal state: `tab` (`'paste'` \| `'upload'`), `fileName` (string \| null), `dragging` (boolean). |
| 175 | + |
| 176 | +### `DiffViewer` |
| 177 | + |
| 178 | +| Prop | Type | Description | |
| 179 | +|---|---|---| |
| 180 | +| `left` | string | Original text (from snapshot) | |
| 181 | +| `right` | string | Modified text (from snapshot) | |
| 182 | +| `viewType` | string | `'side-by-side'` or `'unified'` | |
| 183 | +| `fileName` | string | Passed to `createPatch` as the file name | |
| 184 | + |
| 185 | +Implementation: uses `useEffect` + `useRef`. On every prop change, calls `createPatch` from the `diff` library, then `Diff2Html.html()`, and sets `containerRef.current.innerHTML` to the result. |
| 186 | + |
| 187 | +Import the diff2html CSS bundle: `import 'diff2html/bundles/css/diff2html.min.css'` |
| 188 | + |
| 189 | +--- |
| 190 | + |
| 191 | +## 8. Design System |
| 192 | + |
| 193 | +### Color Palette |
| 194 | + |
| 195 | +| Role | Value | |
| 196 | +|---|---| |
| 197 | +| Page background | `#f0f2f5` | |
| 198 | +| Primary text | `#1a1a2e` | |
| 199 | +| Header background | `#1e1b4b` (dark indigo) | |
| 200 | +| Header accent border | `#4f46e5` (indigo) | |
| 201 | +| Header icon color | `#818cf8` | |
| 202 | +| Header subtitle | `#a5b4fc` | |
| 203 | +| Primary button / active states | `#4f46e5` | |
| 204 | +| Primary button hover | `#4338ca` | |
| 205 | +| Added lines stat | `#16a34a` (green) | |
| 206 | +| Removed lines stat | `#dc2626` (red) | |
| 207 | +| Panel background | `#ffffff` | |
| 208 | +| Panel border | `#e2e8f0` | |
| 209 | +| Muted text / labels | `#64748b` | |
| 210 | +| Disabled / placeholder | `#94a3b8` | |
| 211 | +| Pane header background | `#f8fafc` | |
| 212 | +| Drop zone background | `#fafbfc` | |
| 213 | +| Drop zone hover background | `#f5f3ff` | |
| 214 | +| Drop zone dragging background | `#ede9fe` | |
| 215 | + |
| 216 | +### Typography |
| 217 | + |
| 218 | +- Body / UI: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` |
| 219 | +- Code textarea and diff table: `'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace` |
| 220 | +- Code textarea font size: `0.82rem`, line-height `1.6` |
| 221 | + |
| 222 | +### Layout |
| 223 | + |
| 224 | +- Max content width: `1600px`, centered with `margin: 0 auto`. |
| 225 | +- Input section: two panes (`flex: 1`) separated by a 40px wide swap button column. No gap between panes — borders handle separation. |
| 226 | +- Textarea minimum height: `280px`, `resize: vertical`. |
| 227 | +- Drop zone minimum height: `280px`. |
| 228 | +- Controls bar: `justify-content: space-between` with left group (Compare, Clear) and right group (stats badge + view toggle). |
| 229 | + |
| 230 | +### diff2html CSS Overrides |
| 231 | + |
| 232 | +Apply these overrides on `.diff-output` to integrate with the app style: |
| 233 | +- Remove wrapper margins: `.d2h-wrapper { margin: 0 }` |
| 234 | +- Remove file wrapper border/radius/margin |
| 235 | +- Style `.d2h-file-header` with `#f8fafc` background, `0.8rem` font size |
| 236 | +- Use monospace font on `.d2h-diff-table` |
| 237 | +- Line number cells: `color: #94a3b8`, `background: #f8fafc` with `!important` |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## 9. Build Configuration |
| 242 | + |
| 243 | +### `vite.config.js` |
| 244 | +```js |
| 245 | +import { defineConfig } from 'vite' |
| 246 | +import react from '@vitejs/plugin-react' |
| 247 | + |
| 248 | +export default defineConfig({ |
| 249 | + plugins: [react()], |
| 250 | + base: './', // relative base — required for GitHub Pages subdirectory hosting |
| 251 | +}) |
| 252 | +``` |
| 253 | + |
| 254 | +### `index.html` |
| 255 | +Standard Vite HTML entry. Script tag: `<script type="module" src="/src/main.jsx"></script>`. |
| 256 | + |
| 257 | +### `package.json` scripts |
| 258 | +```json |
| 259 | +"dev": "vite", |
| 260 | +"build": "vite build", |
| 261 | +"preview": "vite preview" |
| 262 | +``` |
| 263 | + |
| 264 | +--- |
| 265 | + |
| 266 | +## 10. GitHub Pages Deployment |
| 267 | + |
| 268 | +### GitHub Actions workflow (`.github/workflows/deploy.yml`) |
| 269 | + |
| 270 | +Trigger: push to `main` branch. |
| 271 | + |
| 272 | +Required permissions: `contents: read`, `pages: write`, `id-token: write`. |
| 273 | + |
| 274 | +Steps: |
| 275 | +1. `actions/checkout@v4` |
| 276 | +2. `actions/setup-node@v4` — Node 20, cache `npm` |
| 277 | +3. `npm ci` |
| 278 | +4. `npm run build` — outputs to `dist/` |
| 279 | +5. `actions/upload-pages-artifact@v3` — path: `dist` |
| 280 | +6. `actions/deploy-pages@v4` — deploys artifact |
| 281 | + |
| 282 | +Environment name: `github-pages`. Concurrency group: `pages`, cancel-in-progress: `false`. |
| 283 | + |
| 284 | +### One-time GitHub repo setup |
| 285 | + |
| 286 | +After pushing, go to: |
| 287 | +**Repo → Settings → Pages → Build and deployment → Source → GitHub Actions** |
| 288 | + |
| 289 | +The app will then auto-deploy on every push to `main`. |
| 290 | + |
| 291 | +--- |
| 292 | + |
| 293 | +## 11. Known Behaviors & Edge Cases |
| 294 | + |
| 295 | +- **Both-empty guard**: Compare and Clear buttons are disabled when both sides are empty (checked via `.trim()`). |
| 296 | +- **Identical content**: diff2html renders a "no changes" message — this is acceptable behavior. |
| 297 | +- **Binary files**: The browser's `file.text()` will attempt to decode them as UTF-8. No explicit binary detection is implemented; garbled output is acceptable. |
| 298 | +- **Large files**: No size limit is enforced. Performance degrades gracefully in the browser. |
| 299 | +- **`.ipynb` parse failure**: If JSON parsing fails, `parseNotebook` returns the raw text string as a fallback. |
| 300 | +- **Changing view type** (side-by-side ↔ unified) does NOT re-snapshot; it re-renders from the existing `diffSnapshot` — this is intentional for instant toggling without re-clicking Compare. |
| 301 | +- **Changing content after Compare**: `compared` is set to `false`, hiding the old diff. The user must click Compare again to see the new diff. |
0 commit comments