@thxgg/steward 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/app/app.vue +14 -0
  5. package/app/assets/css/main.css +129 -0
  6. package/app/components/CommandPalette.vue +182 -0
  7. package/app/components/ShortcutsHelp.vue +85 -0
  8. package/app/components/git/ChangesMinimap.vue +143 -0
  9. package/app/components/git/CommitList.vue +224 -0
  10. package/app/components/git/DiffPanel.vue +402 -0
  11. package/app/components/git/DiffViewer.vue +803 -0
  12. package/app/components/layout/RepoSelector.vue +358 -0
  13. package/app/components/layout/Sidebar.vue +91 -0
  14. package/app/components/prd/Meta.vue +69 -0
  15. package/app/components/prd/Viewer.vue +285 -0
  16. package/app/components/tasks/Board.vue +86 -0
  17. package/app/components/tasks/Card.vue +108 -0
  18. package/app/components/tasks/Column.vue +108 -0
  19. package/app/components/tasks/Detail.vue +291 -0
  20. package/app/components/ui/badge/Badge.vue +26 -0
  21. package/app/components/ui/badge/index.ts +26 -0
  22. package/app/components/ui/button/Button.vue +29 -0
  23. package/app/components/ui/button/index.ts +38 -0
  24. package/app/components/ui/card/Card.vue +22 -0
  25. package/app/components/ui/card/CardAction.vue +17 -0
  26. package/app/components/ui/card/CardContent.vue +17 -0
  27. package/app/components/ui/card/CardDescription.vue +17 -0
  28. package/app/components/ui/card/CardFooter.vue +17 -0
  29. package/app/components/ui/card/CardHeader.vue +17 -0
  30. package/app/components/ui/card/CardTitle.vue +17 -0
  31. package/app/components/ui/card/index.ts +7 -0
  32. package/app/components/ui/combobox/Combobox.vue +19 -0
  33. package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
  34. package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
  35. package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
  36. package/app/components/ui/combobox/ComboboxInput.vue +42 -0
  37. package/app/components/ui/combobox/ComboboxItem.vue +24 -0
  38. package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
  39. package/app/components/ui/combobox/ComboboxList.vue +33 -0
  40. package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
  41. package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
  42. package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
  43. package/app/components/ui/combobox/index.ts +13 -0
  44. package/app/components/ui/command/Command.vue +103 -0
  45. package/app/components/ui/command/CommandDialog.vue +33 -0
  46. package/app/components/ui/command/CommandEmpty.vue +27 -0
  47. package/app/components/ui/command/CommandGroup.vue +45 -0
  48. package/app/components/ui/command/CommandInput.vue +54 -0
  49. package/app/components/ui/command/CommandItem.vue +76 -0
  50. package/app/components/ui/command/CommandList.vue +25 -0
  51. package/app/components/ui/command/CommandSeparator.vue +21 -0
  52. package/app/components/ui/command/CommandShortcut.vue +17 -0
  53. package/app/components/ui/command/index.ts +25 -0
  54. package/app/components/ui/dialog/Dialog.vue +19 -0
  55. package/app/components/ui/dialog/DialogClose.vue +15 -0
  56. package/app/components/ui/dialog/DialogContent.vue +53 -0
  57. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  58. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  59. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  60. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  61. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  62. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  63. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  64. package/app/components/ui/dialog/index.ts +10 -0
  65. package/app/components/ui/input/Input.vue +33 -0
  66. package/app/components/ui/input/index.ts +1 -0
  67. package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
  68. package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
  69. package/app/components/ui/scroll-area/index.ts +2 -0
  70. package/app/components/ui/separator/Separator.vue +29 -0
  71. package/app/components/ui/separator/index.ts +1 -0
  72. package/app/components/ui/sheet/Sheet.vue +19 -0
  73. package/app/components/ui/sheet/SheetClose.vue +15 -0
  74. package/app/components/ui/sheet/SheetContent.vue +62 -0
  75. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  76. package/app/components/ui/sheet/SheetFooter.vue +16 -0
  77. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  78. package/app/components/ui/sheet/SheetOverlay.vue +21 -0
  79. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  80. package/app/components/ui/sheet/SheetTrigger.vue +15 -0
  81. package/app/components/ui/sheet/index.ts +8 -0
  82. package/app/components/ui/tabs/Tabs.vue +24 -0
  83. package/app/components/ui/tabs/TabsContent.vue +21 -0
  84. package/app/components/ui/tabs/TabsList.vue +24 -0
  85. package/app/components/ui/tabs/TabsTrigger.vue +26 -0
  86. package/app/components/ui/tabs/index.ts +4 -0
  87. package/app/components/ui/tooltip/Tooltip.vue +19 -0
  88. package/app/components/ui/tooltip/TooltipContent.vue +34 -0
  89. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  90. package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
  91. package/app/components/ui/tooltip/index.ts +4 -0
  92. package/app/composables/useFileWatch.ts +78 -0
  93. package/app/composables/useGit.ts +180 -0
  94. package/app/composables/useKeyboard.ts +180 -0
  95. package/app/composables/usePrd.ts +86 -0
  96. package/app/composables/useRepos.ts +108 -0
  97. package/app/composables/useThemeMode.ts +38 -0
  98. package/app/composables/useToast.ts +31 -0
  99. package/app/layouts/default.vue +197 -0
  100. package/app/lib/utils.ts +7 -0
  101. package/app/pages/[repo]/[prd].vue +263 -0
  102. package/app/pages/index.vue +257 -0
  103. package/app/types/git.ts +81 -0
  104. package/app/types/index.ts +29 -0
  105. package/app/types/prd.ts +49 -0
  106. package/app/types/repo.ts +37 -0
  107. package/app/types/task.ts +134 -0
  108. package/bin/prd +21 -0
  109. package/components.json +21 -0
  110. package/dist/app/types/git.js +1 -0
  111. package/dist/app/types/prd.js +1 -0
  112. package/dist/app/types/repo.js +1 -0
  113. package/dist/app/types/task.js +1 -0
  114. package/dist/host/src/api/git.js +96 -0
  115. package/dist/host/src/api/index.js +4 -0
  116. package/dist/host/src/api/prds.js +195 -0
  117. package/dist/host/src/api/repos.js +47 -0
  118. package/dist/host/src/api/state.js +63 -0
  119. package/dist/host/src/executor.js +109 -0
  120. package/dist/host/src/index.js +95 -0
  121. package/dist/host/src/mcp.js +62 -0
  122. package/dist/host/src/ui.js +64 -0
  123. package/dist/server/utils/db.js +125 -0
  124. package/dist/server/utils/git.js +396 -0
  125. package/dist/server/utils/prd-state.js +229 -0
  126. package/dist/server/utils/repos.js +256 -0
  127. package/docs/MCP.md +180 -0
  128. package/nuxt.config.ts +34 -0
  129. package/package.json +88 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/robots.txt +1 -0
  132. package/server/api/browse.get.ts +52 -0
  133. package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
  134. package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
  135. package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
  136. package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
  137. package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
  138. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
  139. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
  140. package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
  141. package/server/api/repos/[repoId]/prds.get.ts +85 -0
  142. package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
  143. package/server/api/repos/[repoId].delete.ts +27 -0
  144. package/server/api/repos/index.get.ts +5 -0
  145. package/server/api/repos/index.post.ts +39 -0
  146. package/server/api/watch.get.ts +63 -0
  147. package/server/plugins/migrate-legacy-state.ts +19 -0
  148. package/server/tsconfig.json +3 -0
  149. package/server/utils/db.ts +169 -0
  150. package/server/utils/git.ts +478 -0
  151. package/server/utils/prd-state.ts +335 -0
  152. package/server/utils/repos.ts +322 -0
  153. package/server/utils/watcher.ts +179 -0
  154. package/tsconfig.json +4 -0
@@ -0,0 +1,803 @@
1
+ <script setup lang="ts">
2
+ import { codeToHtml } from 'shiki/bundle/web'
3
+ import { Link, Link2Off, FileWarning, AlertTriangle, ChevronDown } from 'lucide-vue-next'
4
+ import { Button } from '~/components/ui/button'
5
+ import type { DiffHunk, DiffLine } from '~/types/git'
6
+
7
+ const props = defineProps<{
8
+ /** Diff hunks to display */
9
+ hunks: DiffHunk[]
10
+ /** File path for syntax detection */
11
+ filePath: string
12
+ /** Whether this file is binary */
13
+ binary?: boolean
14
+ /** Old file path (for renames) */
15
+ oldPath?: string
16
+ /** Full file content (for full file view) */
17
+ fileContent?: string | null
18
+ /** Whether to show full file with changes highlighted */
19
+ showFullFile?: boolean
20
+ /** Loading state for file content */
21
+ isLoadingContent?: boolean
22
+ }>()
23
+
24
+ // Line limit for large files (truncation threshold)
25
+ const LINE_LIMIT = 10000
26
+ const showAll = ref(false)
27
+
28
+ // Synchronized scrolling state
29
+ const syncScrollEnabled = ref(true)
30
+
31
+ // Refs for scroll sync
32
+ const leftScrollRef = ref<HTMLElement | null>(null)
33
+ const rightScrollRef = ref<HTMLElement | null>(null)
34
+ const isScrolling = ref(false)
35
+
36
+ // Scroll sync handlers
37
+ function onLeftScroll(event: Event) {
38
+ if (!syncScrollEnabled.value || isScrolling.value) return
39
+ const target = event.target as HTMLElement
40
+ if (rightScrollRef.value) {
41
+ isScrolling.value = true
42
+ rightScrollRef.value.scrollLeft = target.scrollLeft
43
+ requestAnimationFrame(() => {
44
+ isScrolling.value = false
45
+ })
46
+ }
47
+ }
48
+
49
+ function onRightScroll(event: Event) {
50
+ if (!syncScrollEnabled.value || isScrolling.value) return
51
+ const target = event.target as HTMLElement
52
+ if (leftScrollRef.value) {
53
+ isScrolling.value = true
54
+ leftScrollRef.value.scrollLeft = target.scrollLeft
55
+ requestAnimationFrame(() => {
56
+ isScrolling.value = false
57
+ })
58
+ }
59
+ }
60
+
61
+ // Calculate total line count for large file detection
62
+ const totalLines = computed(() => {
63
+ let count = 0
64
+ for (const hunk of props.hunks) {
65
+ count += hunk.lines.length
66
+ }
67
+ return count
68
+ })
69
+
70
+ // Check if file is large and should be truncated
71
+ const isLargeFile = computed(() => totalLines.value > LINE_LIMIT)
72
+
73
+ // Check if file has no actual changes (empty diff)
74
+ const isEmpty = computed(() => props.hunks.length === 0 && !props.binary)
75
+
76
+ // Build a set of changed line numbers for full file highlighting
77
+ const changedLines = computed(() => {
78
+ const added = new Set<number>()
79
+ const removed = new Set<number>()
80
+
81
+ for (const hunk of props.hunks) {
82
+ for (const line of hunk.lines) {
83
+ if (line.type === 'add' && line.newNumber !== undefined) {
84
+ added.add(line.newNumber)
85
+ }
86
+ if (line.type === 'remove' && line.oldNumber !== undefined) {
87
+ removed.add(line.oldNumber)
88
+ }
89
+ }
90
+ }
91
+
92
+ return { added, removed }
93
+ })
94
+
95
+ // Full file lines for the full file view
96
+ const fullFileLines = computed(() => {
97
+ if (!props.fileContent) return []
98
+ return props.fileContent.split('\n')
99
+ })
100
+
101
+ // Detect language from file extension
102
+ function detectLanguage(path: string): string {
103
+ const ext = path.split('.').pop()?.toLowerCase() || ''
104
+ const langMap: Record<string, string> = {
105
+ ts: 'typescript',
106
+ tsx: 'tsx',
107
+ js: 'javascript',
108
+ jsx: 'jsx',
109
+ vue: 'vue',
110
+ svelte: 'svelte',
111
+ py: 'python',
112
+ rb: 'ruby',
113
+ rs: 'rust',
114
+ go: 'go',
115
+ java: 'java',
116
+ kt: 'kotlin',
117
+ swift: 'swift',
118
+ c: 'c',
119
+ cpp: 'cpp',
120
+ h: 'c',
121
+ hpp: 'cpp',
122
+ cs: 'csharp',
123
+ php: 'php',
124
+ html: 'html',
125
+ css: 'css',
126
+ scss: 'scss',
127
+ sass: 'sass',
128
+ less: 'less',
129
+ json: 'json',
130
+ yaml: 'yaml',
131
+ yml: 'yaml',
132
+ toml: 'toml',
133
+ xml: 'xml',
134
+ md: 'markdown',
135
+ mdx: 'mdx',
136
+ sh: 'bash',
137
+ bash: 'bash',
138
+ zsh: 'bash',
139
+ fish: 'fish',
140
+ ps1: 'powershell',
141
+ sql: 'sql',
142
+ graphql: 'graphql',
143
+ gql: 'graphql',
144
+ dockerfile: 'dockerfile',
145
+ makefile: 'makefile',
146
+ }
147
+ return langMap[ext] || 'text'
148
+ }
149
+
150
+ // Detect language from file path
151
+ const language = computed(() => detectLanguage(props.filePath))
152
+
153
+ // Highlighted lines state
154
+ const highlightedLines = ref<Map<string, string>>(new Map())
155
+ const isLoading = ref(true)
156
+
157
+ // Generate line pairs for side-by-side view
158
+ interface LinePair {
159
+ id: string
160
+ left: {
161
+ lineNum?: number
162
+ content: string
163
+ type: 'add' | 'remove' | 'context' | 'empty'
164
+ highlighted?: string
165
+ }
166
+ right: {
167
+ lineNum?: number
168
+ content: string
169
+ type: 'add' | 'remove' | 'context' | 'empty'
170
+ highlighted?: string
171
+ }
172
+ }
173
+
174
+ interface DisplayItem {
175
+ type: 'line' | 'separator'
176
+ pair?: LinePair
177
+ hunkIndex?: number
178
+ }
179
+
180
+ // Generate display items including line pairs and separators
181
+ const displayItems = computed<DisplayItem[]>(() => {
182
+ const items: DisplayItem[] = []
183
+
184
+ for (let hunkIndex = 0; hunkIndex < props.hunks.length; hunkIndex++) {
185
+ const hunk = props.hunks[hunkIndex]!
186
+
187
+ // Add separator before each hunk (except the first)
188
+ if (hunkIndex > 0) {
189
+ items.push({ type: 'separator', hunkIndex })
190
+ }
191
+
192
+ // Process lines into pairs
193
+ const hunkLines = hunk.lines
194
+ let i = 0
195
+
196
+ while (i < hunkLines.length) {
197
+ const line = hunkLines[i]!
198
+
199
+ if (line.type === 'context') {
200
+ // Context line - show on both sides
201
+ items.push({
202
+ type: 'line',
203
+ pair: {
204
+ id: `${hunkIndex}-${i}`,
205
+ left: {
206
+ lineNum: line.oldNumber,
207
+ content: line.content,
208
+ type: 'context',
209
+ },
210
+ right: {
211
+ lineNum: line.newNumber,
212
+ content: line.content,
213
+ type: 'context',
214
+ },
215
+ },
216
+ })
217
+ i++
218
+ } else if (line.type === 'remove') {
219
+ // Check for consecutive additions after removals to pair them
220
+ const removeLines: DiffLine[] = []
221
+ const addLines: DiffLine[] = []
222
+
223
+ // Collect consecutive removals
224
+ while (i < hunkLines.length && hunkLines[i]!.type === 'remove') {
225
+ removeLines.push(hunkLines[i]!)
226
+ i++
227
+ }
228
+
229
+ // Collect consecutive additions
230
+ while (i < hunkLines.length && hunkLines[i]!.type === 'add') {
231
+ addLines.push(hunkLines[i]!)
232
+ i++
233
+ }
234
+
235
+ // Pair up removals and additions
236
+ const maxLen = Math.max(removeLines.length, addLines.length)
237
+ for (let j = 0; j < maxLen; j++) {
238
+ const removeLine = removeLines[j]
239
+ const addLine = addLines[j]
240
+
241
+ items.push({
242
+ type: 'line',
243
+ pair: {
244
+ id: `${hunkIndex}-${i - maxLen + j}`,
245
+ left: removeLine
246
+ ? {
247
+ lineNum: removeLine.oldNumber,
248
+ content: removeLine.content,
249
+ type: 'remove',
250
+ }
251
+ : { content: '', type: 'empty' },
252
+ right: addLine
253
+ ? {
254
+ lineNum: addLine.newNumber,
255
+ content: addLine.content,
256
+ type: 'add',
257
+ }
258
+ : { content: '', type: 'empty' },
259
+ },
260
+ })
261
+ }
262
+ } else if (line.type === 'add') {
263
+ // Standalone addition (shouldn't happen often in this flow)
264
+ items.push({
265
+ type: 'line',
266
+ pair: {
267
+ id: `${hunkIndex}-${i}`,
268
+ left: { content: '', type: 'empty' },
269
+ right: {
270
+ lineNum: line.newNumber,
271
+ content: line.content,
272
+ type: 'add',
273
+ },
274
+ },
275
+ })
276
+ i++
277
+ } else {
278
+ i++
279
+ }
280
+ }
281
+ }
282
+
283
+ return items
284
+ })
285
+
286
+ // Highlight code with shiki using dual themes for instant theme switching
287
+ async function highlightLine(content: string, lang: string): Promise<string> {
288
+ if (!content.trim()) {
289
+ return ''
290
+ }
291
+
292
+ try {
293
+ const html = await codeToHtml(content, {
294
+ lang,
295
+ themes: {
296
+ light: 'catppuccin-latte',
297
+ dark: 'catppuccin-mocha',
298
+ },
299
+ })
300
+ // Extract just the code content from shiki output
301
+ // Shiki wraps in <pre><code>...</code></pre>
302
+ const codeMatch = html.match(/<code[^>]*>([\s\S]*?)<\/code>/)
303
+ return codeMatch ? codeMatch[1] || '' : escapeHtml(content)
304
+ } catch {
305
+ return escapeHtml(content)
306
+ }
307
+ }
308
+
309
+ // Highlight full file content at once (preserves context for Vue/JSX files)
310
+ // then split into individual lines
311
+ async function highlightFullContent(content: string, lang: string): Promise<string[]> {
312
+ if (!content) {
313
+ return []
314
+ }
315
+
316
+ try {
317
+ const html = await codeToHtml(content, {
318
+ lang,
319
+ themes: {
320
+ light: 'catppuccin-latte',
321
+ dark: 'catppuccin-mocha',
322
+ },
323
+ })
324
+ // Extract the code content from shiki output
325
+ const codeMatch = html.match(/<code[^>]*>([\s\S]*?)<\/code>/)
326
+ if (!codeMatch || !codeMatch[1]) {
327
+ return content.split('\n').map(escapeHtml)
328
+ }
329
+
330
+ // Split by newlines, preserving HTML tags that span lines
331
+ // Shiki outputs each line's content, with newlines as literal \n
332
+ const highlightedContent = codeMatch[1]
333
+ return highlightedContent.split('\n')
334
+ } catch {
335
+ return content.split('\n').map(escapeHtml)
336
+ }
337
+ }
338
+
339
+ function escapeHtml(text: string): string {
340
+ return text
341
+ .replace(/&/g, '&amp;')
342
+ .replace(/</g, '&lt;')
343
+ .replace(/>/g, '&gt;')
344
+ .replace(/"/g, '&quot;')
345
+ .replace(/'/g, '&#039;')
346
+ }
347
+
348
+ // Highlighted full file lines (used for both full file view and diff view)
349
+ const highlightedFullFile = ref<string[]>([])
350
+ const isLoadingFullFile = ref(false)
351
+
352
+ // Highlight full file when content changes
353
+ // Uses full-content highlighting to preserve context (important for Vue/JSX)
354
+ watch(
355
+ () => [props.fileContent, props.filePath] as const,
356
+ async ([content]) => {
357
+ if (!content) {
358
+ highlightedFullFile.value = []
359
+ return
360
+ }
361
+
362
+ isLoadingFullFile.value = true
363
+ const lang = language.value
364
+
365
+ // Highlight entire file at once to preserve context for Vue/JSX files
366
+ highlightedFullFile.value = await highlightFullContent(content, lang)
367
+ isLoadingFullFile.value = false
368
+ },
369
+ { immediate: true }
370
+ )
371
+
372
+ // Highlight diff lines - uses full file context when available
373
+ watch(
374
+ () => [props.hunks, props.filePath, highlightedFullFile.value] as const,
375
+ async () => {
376
+ isLoading.value = true
377
+ const lang = language.value
378
+ const newHighlighted = new Map<string, string>()
379
+ const fullFileLines = highlightedFullFile.value
380
+
381
+ // Collect lines that need individual highlighting (removed lines from old file)
382
+ const linesToHighlight: { key: string; content: string }[] = []
383
+
384
+ for (const item of displayItems.value) {
385
+ if (item.type === 'line' && item.pair) {
386
+ // Left side (old file) - removed lines need individual highlighting
387
+ if (item.pair.left.content && item.pair.left.type !== 'empty') {
388
+ const key = `${item.pair.id}-old`
389
+ // For context lines, we can use the new file's highlighting if line numbers match
390
+ if (item.pair.left.type === 'context' && item.pair.left.lineNum && fullFileLines.length > 0) {
391
+ // Context lines exist in both files at same content, use new file highlight
392
+ const lineIndex = item.pair.right.lineNum ? item.pair.right.lineNum - 1 : -1
393
+ if (lineIndex >= 0 && lineIndex < fullFileLines.length) {
394
+ newHighlighted.set(key, fullFileLines[lineIndex] || '')
395
+ } else {
396
+ linesToHighlight.push({ key, content: item.pair.left.content })
397
+ }
398
+ } else {
399
+ // Removed lines - must highlight individually
400
+ linesToHighlight.push({ key, content: item.pair.left.content })
401
+ }
402
+ }
403
+
404
+ // Right side (new file) - use full file highlighting when available
405
+ if (item.pair.right.content && item.pair.right.type !== 'empty') {
406
+ const key = `${item.pair.id}-new`
407
+ const lineNum = item.pair.right.lineNum
408
+ if (lineNum && fullFileLines.length > 0 && lineNum <= fullFileLines.length) {
409
+ // Use pre-highlighted line from full file
410
+ newHighlighted.set(key, fullFileLines[lineNum - 1] || '')
411
+ } else {
412
+ // Fallback to individual highlighting
413
+ linesToHighlight.push({ key, content: item.pair.right.content })
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ // Highlight remaining lines individually (removed lines)
420
+ const batchSize = 50
421
+ for (let i = 0; i < linesToHighlight.length; i += batchSize) {
422
+ const batch = linesToHighlight.slice(i, i + batchSize)
423
+ const results = await Promise.all(
424
+ batch.map(async ({ key, content }) => {
425
+ const result = await highlightLine(content, lang)
426
+ return { key, result }
427
+ })
428
+ )
429
+
430
+ for (const { key, result } of results) {
431
+ newHighlighted.set(key, result)
432
+ }
433
+ }
434
+
435
+ highlightedLines.value = newHighlighted
436
+ isLoading.value = false
437
+ },
438
+ { immediate: true }
439
+ )
440
+
441
+ // Get highlighted content for a line
442
+ function getHighlightedContent(pairId: string, side: 'old' | 'new'): string {
443
+ return highlightedLines.value.get(`${pairId}-${side}`) || ''
444
+ }
445
+
446
+ // Get line type for full file view
447
+ function getFullFileLineType(lineNum: number): 'add' | 'remove' | 'context' {
448
+ if (changedLines.value.added.has(lineNum)) return 'add'
449
+ return 'context'
450
+ }
451
+ </script>
452
+
453
+ <template>
454
+ <div class="diff-viewer">
455
+ <!-- Loading state -->
456
+ <div v-if="(isLoading || isLoadingContent || isLoadingFullFile) && !binary" class="flex items-center justify-center py-8">
457
+ <div class="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
458
+ </div>
459
+
460
+ <!-- Binary file state -->
461
+ <div v-else-if="binary" class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
462
+ <FileWarning class="size-10 opacity-50" />
463
+ <div class="text-center">
464
+ <p class="font-medium">Binary file</p>
465
+ <p class="text-sm">This file cannot be displayed as a diff</p>
466
+ </div>
467
+ </div>
468
+
469
+ <!-- Empty diff state (only for changes view) -->
470
+ <div v-else-if="isEmpty && !showFullFile" class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
471
+ <AlertTriangle class="size-10 opacity-50" />
472
+ <div class="text-center">
473
+ <p class="font-medium">No changes</p>
474
+ <p class="text-sm">This file was touched but has no content changes</p>
475
+ </div>
476
+ </div>
477
+
478
+ <!-- Large file warning (truncated) - only for changes view -->
479
+ <div v-else-if="isLargeFile && !showAll && !showFullFile" class="diff-container">
480
+ <div class="flex flex-col items-center justify-center gap-3 border-b border-border bg-muted/30 py-6">
481
+ <AlertTriangle class="size-8 text-yellow-500" />
482
+ <div class="text-center">
483
+ <p class="font-medium">Large file</p>
484
+ <p class="text-sm text-muted-foreground">
485
+ This file has {{ totalLines.toLocaleString() }} lines (threshold: {{ LINE_LIMIT.toLocaleString() }})
486
+ </p>
487
+ </div>
488
+ <Button variant="outline" size="sm" @click="showAll = true">
489
+ <ChevronDown class="mr-2 size-4" />
490
+ Show full diff
491
+ </Button>
492
+ </div>
493
+ </div>
494
+
495
+ <!-- Full file view -->
496
+ <div v-else-if="showFullFile && fileContent" class="diff-container">
497
+ <div class="full-file-view">
498
+ <div
499
+ v-for="(line, index) in fullFileLines"
500
+ :key="index"
501
+ class="full-file-line"
502
+ :class="{
503
+ 'diff-add': getFullFileLineType(index + 1) === 'add',
504
+ }"
505
+ >
506
+ <div class="diff-gutter">
507
+ <span class="line-number">{{ index + 1 }}</span>
508
+ </div>
509
+ <div class="diff-content">
510
+ <span
511
+ class="diff-code"
512
+ v-html="highlightedFullFile[index] || escapeHtml(line)"
513
+ />
514
+ </div>
515
+ </div>
516
+ </div>
517
+ </div>
518
+
519
+ <!-- Normal diff view (changes only) -->
520
+ <div v-else class="diff-container">
521
+ <!-- Sync scroll toggle -->
522
+ <div class="diff-toolbar">
523
+ <Button
524
+ variant="ghost"
525
+ size="sm"
526
+ class="h-7 gap-1.5 text-xs"
527
+ :class="{ 'text-primary': syncScrollEnabled }"
528
+ @click="syncScrollEnabled = !syncScrollEnabled"
529
+ >
530
+ <component :is="syncScrollEnabled ? Link : Link2Off" class="size-3.5" />
531
+ {{ syncScrollEnabled ? 'Sync scroll' : 'Scroll unlocked' }}
532
+ </Button>
533
+ </div>
534
+ <!-- Side-by-side view with independent scrollable columns -->
535
+ <div class="diff-split">
536
+ <!-- Left column (old) -->
537
+ <div ref="leftScrollRef" class="diff-column diff-column-left" @scroll="onLeftScroll">
538
+ <div class="diff-column-content">
539
+ <template v-for="item in displayItems" :key="item.type === 'line' ? `left-${item.pair?.id}` : `left-sep-${item.hunkIndex}`">
540
+ <!-- Hunk separator -->
541
+ <div v-if="item.type === 'separator'" class="diff-separator-half">
542
+ <div class="separator-line" />
543
+ <span class="separator-text">···</span>
544
+ </div>
545
+
546
+ <!-- Line -->
547
+ <div
548
+ v-else-if="item.pair"
549
+ class="diff-line"
550
+ :class="{
551
+ 'diff-remove': item.pair.left.type === 'remove',
552
+ 'diff-empty': item.pair.left.type === 'empty',
553
+ 'diff-context': item.pair.left.type === 'context',
554
+ }"
555
+ >
556
+ <div class="diff-gutter">
557
+ <span v-if="item.pair.left.lineNum" class="line-number">
558
+ {{ item.pair.left.lineNum }}
559
+ </span>
560
+ </div>
561
+ <div class="diff-content">
562
+ <span
563
+ v-if="item.pair.left.type !== 'empty'"
564
+ class="diff-code"
565
+ v-html="getHighlightedContent(item.pair.id, 'old') || escapeHtml(item.pair.left.content)"
566
+ />
567
+ </div>
568
+ </div>
569
+ </template>
570
+ </div>
571
+ </div>
572
+
573
+ <!-- Right column (new) -->
574
+ <div ref="rightScrollRef" class="diff-column diff-column-right" @scroll="onRightScroll">
575
+ <div class="diff-column-content">
576
+ <template v-for="item in displayItems" :key="item.type === 'line' ? `right-${item.pair?.id}` : `right-sep-${item.hunkIndex}`">
577
+ <!-- Hunk separator -->
578
+ <div v-if="item.type === 'separator'" class="diff-separator-half">
579
+ <span class="separator-text">···</span>
580
+ <div class="separator-line" />
581
+ </div>
582
+
583
+ <!-- Line -->
584
+ <div
585
+ v-else-if="item.pair"
586
+ class="diff-line"
587
+ :class="{
588
+ 'diff-add': item.pair.right.type === 'add',
589
+ 'diff-empty': item.pair.right.type === 'empty',
590
+ 'diff-context': item.pair.right.type === 'context',
591
+ }"
592
+ >
593
+ <div class="diff-gutter">
594
+ <span v-if="item.pair.right.lineNum" class="line-number">
595
+ {{ item.pair.right.lineNum }}
596
+ </span>
597
+ </div>
598
+ <div class="diff-content">
599
+ <span
600
+ v-if="item.pair.right.type !== 'empty'"
601
+ class="diff-code"
602
+ v-html="getHighlightedContent(item.pair.id, 'new') || escapeHtml(item.pair.right.content)"
603
+ />
604
+ </div>
605
+ </div>
606
+ </template>
607
+ </div>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </template>
613
+
614
+ <style scoped>
615
+ .diff-viewer {
616
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
617
+ font-size: 0.8125rem;
618
+ line-height: 1.5;
619
+ color: hsl(var(--foreground));
620
+ }
621
+
622
+ .diff-container {
623
+ overflow-x: auto;
624
+ }
625
+
626
+ .diff-toolbar {
627
+ display: flex;
628
+ justify-content: flex-end;
629
+ padding: 0.25rem 0.5rem;
630
+ border-bottom: 1px solid hsl(var(--border));
631
+ background: hsl(var(--muted) / 0.3);
632
+ }
633
+
634
+ .diff-split {
635
+ display: flex;
636
+ min-width: 100%;
637
+ }
638
+
639
+ .diff-column {
640
+ flex: 1;
641
+ min-width: 0;
642
+ overflow-x: auto;
643
+ }
644
+
645
+ .diff-column-left {
646
+ border-right: 1px solid hsl(var(--border));
647
+ }
648
+
649
+ .diff-column-content {
650
+ min-width: fit-content;
651
+ }
652
+
653
+ .diff-line {
654
+ display: flex;
655
+ }
656
+
657
+ .diff-gutter {
658
+ flex-shrink: 0;
659
+ width: 3.5rem;
660
+ padding: 0 0.5rem;
661
+ text-align: right;
662
+ color: hsl(var(--muted-foreground));
663
+ background: hsl(var(--muted) / 0.3);
664
+ user-select: none;
665
+ }
666
+
667
+ .line-number {
668
+ display: inline-block;
669
+ min-width: 2rem;
670
+ opacity: 0.7;
671
+ }
672
+
673
+ .diff-content {
674
+ flex: 1;
675
+ min-width: 0;
676
+ padding: 0 0.5rem;
677
+ white-space: pre;
678
+ color: hsl(var(--foreground));
679
+ }
680
+
681
+ .diff-code {
682
+ display: inline;
683
+ color: inherit;
684
+ }
685
+
686
+ /* Line type styles */
687
+ .diff-add {
688
+ background: hsl(142 76% 36% / 0.15);
689
+ }
690
+
691
+ .diff-add .diff-gutter {
692
+ background: hsl(142 76% 36% / 0.25);
693
+ }
694
+
695
+ .diff-remove {
696
+ background: hsl(0 84% 60% / 0.15);
697
+ }
698
+
699
+ .diff-remove .diff-gutter {
700
+ background: hsl(0 84% 60% / 0.25);
701
+ }
702
+
703
+ .diff-empty {
704
+ background: hsl(var(--muted) / 0.2);
705
+ }
706
+
707
+ .diff-empty .diff-gutter {
708
+ background: hsl(var(--muted) / 0.3);
709
+ }
710
+
711
+ .diff-context {
712
+ background: transparent;
713
+ }
714
+
715
+ /* Full file view */
716
+ .full-file-view {
717
+ min-width: 100%;
718
+ }
719
+
720
+ .full-file-line {
721
+ display: flex;
722
+ }
723
+
724
+ .full-file-line .diff-gutter {
725
+ width: 4rem;
726
+ }
727
+
728
+ .full-file-line.diff-add {
729
+ background: hsl(142 76% 36% / 0.15);
730
+ }
731
+
732
+ .full-file-line.diff-add .diff-gutter {
733
+ background: hsl(142 76% 36% / 0.25);
734
+ }
735
+
736
+ .dark .full-file-line.diff-add {
737
+ background: hsl(142 76% 36% / 0.1);
738
+ }
739
+
740
+ .dark .full-file-line.diff-add .diff-gutter {
741
+ background: hsl(142 76% 36% / 0.2);
742
+ }
743
+
744
+ /* Hunk separator */
745
+ .diff-separator {
746
+ display: flex;
747
+ align-items: center;
748
+ padding: 0.25rem 0;
749
+ background: hsl(var(--muted) / 0.4);
750
+ }
751
+
752
+ .diff-separator-half {
753
+ display: flex;
754
+ align-items: center;
755
+ padding: 0.25rem 0;
756
+ background: hsl(var(--muted) / 0.4);
757
+ }
758
+
759
+ .separator-line {
760
+ flex: 1;
761
+ height: 1px;
762
+ background: hsl(var(--border));
763
+ }
764
+
765
+ .separator-text {
766
+ padding: 0 0.75rem;
767
+ font-size: 0.75rem;
768
+ color: hsl(var(--muted-foreground));
769
+ }
770
+
771
+ /* Dark mode adjustments */
772
+ .dark .diff-add {
773
+ background: hsl(142 76% 36% / 0.1);
774
+ }
775
+
776
+ .dark .diff-add .diff-gutter {
777
+ background: hsl(142 76% 36% / 0.2);
778
+ }
779
+
780
+ .dark .diff-remove {
781
+ background: hsl(0 84% 60% / 0.1);
782
+ }
783
+
784
+ .dark .diff-remove .diff-gutter {
785
+ background: hsl(0 84% 60% / 0.2);
786
+ }
787
+
788
+ /* Shiki output styling */
789
+ :deep(.shiki),
790
+ :deep(.shiki span) {
791
+ background: transparent !important;
792
+ }
793
+ </style>
794
+
795
+ <style>
796
+ /* Shiki dual-theme switching - must be global to reach html.dark
797
+ We target spans with --shiki-dark variable since we extract only
798
+ the inner content from Shiki's output (without the .shiki wrapper) */
799
+ html.dark .diff-viewer span[style*="--shiki-dark"] {
800
+ color: var(--shiki-dark) !important;
801
+ background-color: transparent !important;
802
+ }
803
+ </style>