@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,402 @@
1
+ <script setup lang="ts">
2
+ import { AlertCircle, RefreshCw, Loader2, Keyboard, FileCode, FileDiff as FileDiffIcon } from 'lucide-vue-next'
3
+ import { ScrollArea } from '~/components/ui/scroll-area'
4
+ import { Button } from '~/components/ui/button'
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipProvider,
9
+ TooltipTrigger,
10
+ } from '~/components/ui/tooltip'
11
+ import type { FileDiff, DiffHunk } from '~/types/git'
12
+
13
+ const props = defineProps<{
14
+ /** Repository ID */
15
+ repoId: string
16
+ /** Commit SHA to display diff for */
17
+ commitSha: string
18
+ /** Optional relative path to git repo (for pseudo-monorepos) */
19
+ repoPath?: string
20
+ }>()
21
+
22
+ const emit = defineEmits<{
23
+ close: []
24
+ }>()
25
+
26
+ const { fetchDiff, fetchFileDiff, fetchFileContent, isLoadingDiff, isLoadingFileDiff, isLoadingFileContent } = useGit()
27
+
28
+ // State
29
+ const files = ref<FileDiff[]>([])
30
+ const selectedFile = ref<string | undefined>()
31
+ const hunks = ref<DiffHunk[]>([])
32
+ const fileContent = ref<string | null>(null)
33
+ const error = ref<string | null>(null)
34
+ const fileDiffError = ref<string | null>(null)
35
+
36
+ // View mode: 'changes' shows only hunks, 'full' shows entire file with changes highlighted
37
+ const viewMode = ref<'changes' | 'full'>('changes')
38
+
39
+ // Get the selected file's metadata
40
+ const selectedFileDiff = computed(() => files.value.find(f => f.path === selectedFile.value))
41
+
42
+ // Fetch file list on mount and when commitSha changes
43
+ async function loadDiff() {
44
+ error.value = null
45
+ files.value = []
46
+ selectedFile.value = undefined
47
+ hunks.value = []
48
+
49
+ const result = await fetchDiff(props.repoId, props.commitSha, props.repoPath)
50
+
51
+ if (result.length === 0 && !isLoadingDiff.value) {
52
+ // Check if it was an error (empty could be valid, but error is set by toast)
53
+ // We'll set a generic message if truly no files
54
+ error.value = null // Let empty state show naturally
55
+ }
56
+
57
+ files.value = result
58
+
59
+ // Auto-select first file
60
+ if (result.length > 0) {
61
+ selectedFile.value = result[0]!.path
62
+ }
63
+ }
64
+
65
+ // Fetch file diff when selection changes
66
+ async function loadFileDiff() {
67
+ if (!selectedFile.value) {
68
+ hunks.value = []
69
+ fileContent.value = null
70
+ return
71
+ }
72
+
73
+ fileDiffError.value = null
74
+
75
+ // Fetch hunks (always needed for highlighting changes)
76
+ const result = await fetchFileDiff(props.repoId, props.commitSha, selectedFile.value, props.repoPath)
77
+ hunks.value = result
78
+
79
+ // Fetch full file content if in full mode
80
+ if (viewMode.value === 'full') {
81
+ const content = await fetchFileContent(props.repoId, props.commitSha, selectedFile.value, props.repoPath)
82
+ fileContent.value = content
83
+ }
84
+ }
85
+
86
+ // Toggle view mode
87
+ function toggleViewMode() {
88
+ viewMode.value = viewMode.value === 'changes' ? 'full' : 'changes'
89
+ }
90
+
91
+ // Reload file content when switching to full mode
92
+ watch(viewMode, async (mode) => {
93
+ if (mode === 'full' && selectedFile.value && !fileContent.value) {
94
+ const content = await fetchFileContent(props.repoId, props.commitSha, selectedFile.value, props.repoPath)
95
+ fileContent.value = content
96
+ }
97
+ })
98
+
99
+ // Handle file selection from minimap
100
+ function handleFileSelect(path: string) {
101
+ selectedFile.value = path
102
+ }
103
+
104
+ // Retry loading
105
+ function retry() {
106
+ loadDiff()
107
+ }
108
+
109
+ function retryFileDiff() {
110
+ loadFileDiff()
111
+ }
112
+
113
+ // Watch for selection changes
114
+ watch(selectedFile, () => {
115
+ loadFileDiff()
116
+ })
117
+
118
+ // Watch for prop changes
119
+ watch(
120
+ () => [props.repoId, props.commitSha, props.repoPath] as const,
121
+ () => {
122
+ loadDiff()
123
+ },
124
+ { immediate: true }
125
+ )
126
+
127
+ // Keyboard navigation
128
+ const panelRef = ref<HTMLElement | null>(null)
129
+ const diffViewerRef = ref<HTMLElement | null>(null)
130
+
131
+ // Navigate to next/previous file
132
+ function navigateFile(direction: 'next' | 'prev') {
133
+ if (files.value.length === 0) return
134
+
135
+ const currentIndex = selectedFile.value
136
+ ? files.value.findIndex(f => f.path === selectedFile.value)
137
+ : -1
138
+
139
+ let newIndex: number
140
+ if (direction === 'next') {
141
+ newIndex = currentIndex < files.value.length - 1 ? currentIndex + 1 : 0
142
+ } else {
143
+ newIndex = currentIndex > 0 ? currentIndex - 1 : files.value.length - 1
144
+ }
145
+
146
+ const file = files.value[newIndex]
147
+ if (file) {
148
+ selectedFile.value = file.path
149
+ }
150
+ }
151
+
152
+ // Jump between hunks
153
+ function jumpToHunk(direction: 'next' | 'prev') {
154
+ if (!diffViewerRef.value) return
155
+
156
+ const separators = diffViewerRef.value.querySelectorAll('.diff-separator')
157
+ if (separators.length === 0) return
158
+
159
+ const container = diffViewerRef.value.closest('.overflow-y-auto, [data-radix-scroll-area-viewport]')
160
+ if (!container) return
161
+
162
+ const scrollTop = container.scrollTop
163
+ const containerRect = container.getBoundingClientRect()
164
+
165
+ let targetSeparator: Element | null = null
166
+
167
+ if (direction === 'next') {
168
+ // Find the first separator below current scroll position
169
+ for (const sep of separators) {
170
+ const rect = sep.getBoundingClientRect()
171
+ const relativeTop = rect.top - containerRect.top + scrollTop
172
+ if (relativeTop > scrollTop + 50) {
173
+ targetSeparator = sep
174
+ break
175
+ }
176
+ }
177
+ // If no separator found below, wrap to first
178
+ if (!targetSeparator && separators.length > 0) {
179
+ targetSeparator = separators[0]!
180
+ }
181
+ } else {
182
+ // Find the last separator above current scroll position
183
+ for (let i = separators.length - 1; i >= 0; i--) {
184
+ const sep = separators[i]!
185
+ const rect = sep.getBoundingClientRect()
186
+ const relativeTop = rect.top - containerRect.top + scrollTop
187
+ if (relativeTop < scrollTop - 10) {
188
+ targetSeparator = sep
189
+ break
190
+ }
191
+ }
192
+ // If no separator found above, wrap to last
193
+ if (!targetSeparator && separators.length > 0) {
194
+ targetSeparator = separators[separators.length - 1]!
195
+ }
196
+ }
197
+
198
+ if (targetSeparator) {
199
+ targetSeparator.scrollIntoView({ behavior: 'smooth', block: 'start' })
200
+ }
201
+ }
202
+
203
+ // Handle keyboard events
204
+ function handleKeydown(event: KeyboardEvent) {
205
+ // Skip if user is typing in an input
206
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
207
+ return
208
+ }
209
+
210
+ switch (event.key) {
211
+ case 'j':
212
+ case 'ArrowDown':
213
+ if (!event.ctrlKey && !event.metaKey) {
214
+ event.preventDefault()
215
+ navigateFile('next')
216
+ }
217
+ break
218
+ case 'k':
219
+ case 'ArrowUp':
220
+ if (!event.ctrlKey && !event.metaKey) {
221
+ event.preventDefault()
222
+ navigateFile('prev')
223
+ }
224
+ break
225
+ case '[':
226
+ event.preventDefault()
227
+ jumpToHunk('prev')
228
+ break
229
+ case ']':
230
+ event.preventDefault()
231
+ jumpToHunk('next')
232
+ break
233
+ case 'Escape':
234
+ event.preventDefault()
235
+ emit('close')
236
+ break
237
+ }
238
+ }
239
+
240
+ // Set up keyboard listeners when mounted
241
+ onMounted(() => {
242
+ document.addEventListener('keydown', handleKeydown)
243
+ })
244
+
245
+ onUnmounted(() => {
246
+ document.removeEventListener('keydown', handleKeydown)
247
+ })
248
+ </script>
249
+
250
+ <template>
251
+ <div ref="panelRef" class="flex h-full flex-col overflow-hidden" tabindex="-1">
252
+ <!-- Error state -->
253
+ <div v-if="error" class="flex flex-1 flex-col items-center justify-center gap-4 p-8">
254
+ <AlertCircle class="size-12 text-destructive" />
255
+ <div class="text-center">
256
+ <p class="font-medium">Failed to load diff</p>
257
+ <p class="text-sm text-muted-foreground">{{ error }}</p>
258
+ </div>
259
+ <Button variant="outline" size="sm" @click="retry">
260
+ <RefreshCw class="mr-2 size-4" />
261
+ Retry
262
+ </Button>
263
+ </div>
264
+
265
+ <!-- Loading state (initial load) -->
266
+ <div v-else-if="isLoadingDiff && files.length === 0" class="flex flex-1 items-center justify-center">
267
+ <Loader2 class="size-8 animate-spin text-muted-foreground" />
268
+ </div>
269
+
270
+ <!-- Empty state -->
271
+ <div
272
+ v-else-if="files.length === 0"
273
+ class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center text-muted-foreground"
274
+ >
275
+ <p>No files changed in this commit</p>
276
+ </div>
277
+
278
+ <!-- Main content -->
279
+ <div v-else class="flex min-h-0 flex-1 overflow-hidden">
280
+ <!-- Minimap sidebar -->
281
+ <div class="flex w-56 shrink-0 flex-col border-r border-border">
282
+ <div class="flex items-center justify-between border-b border-border px-3 py-2">
283
+ <span class="text-xs font-medium text-muted-foreground">
284
+ {{ files.length }} file{{ files.length !== 1 ? 's' : '' }} changed
285
+ </span>
286
+ <!-- Keyboard shortcuts hint -->
287
+ <TooltipProvider>
288
+ <Tooltip>
289
+ <TooltipTrigger as-child>
290
+ <button class="text-muted-foreground/50 transition-colors hover:text-muted-foreground">
291
+ <Keyboard class="size-3.5" />
292
+ </button>
293
+ </TooltipTrigger>
294
+ <TooltipContent side="bottom" align="end" class="max-w-xs">
295
+ <div class="space-y-1 text-xs">
296
+ <div class="flex justify-between gap-4">
297
+ <span class="text-muted-foreground">Navigate files</span>
298
+ <span class="font-mono">j/k or ↑/↓</span>
299
+ </div>
300
+ <div class="flex justify-between gap-4">
301
+ <span class="text-muted-foreground">Jump to hunk</span>
302
+ <span class="font-mono">[ / ]</span>
303
+ </div>
304
+ <div class="flex justify-between gap-4">
305
+ <span class="text-muted-foreground">Close</span>
306
+ <span class="font-mono">Esc</span>
307
+ </div>
308
+ </div>
309
+ </TooltipContent>
310
+ </Tooltip>
311
+ </TooltipProvider>
312
+ </div>
313
+ <div class="min-h-0 flex-1 overflow-y-auto">
314
+ <GitChangesMinimap
315
+ :files="files"
316
+ :selected-file="selectedFile"
317
+ @select="handleFileSelect"
318
+ />
319
+ </div>
320
+ </div>
321
+
322
+ <!-- Diff viewer area -->
323
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
324
+ <!-- File header with rename support -->
325
+ <div v-if="selectedFile" class="flex items-center gap-2 border-b border-border px-4 py-2">
326
+ <span v-if="selectedFileDiff?.oldPath" class="min-w-0 flex-1 truncate font-mono text-sm">
327
+ <span class="text-muted-foreground">{{ selectedFileDiff.oldPath }}</span>
328
+ <span class="mx-2 text-muted-foreground">→</span>
329
+ <span>{{ selectedFile }}</span>
330
+ </span>
331
+ <span v-else class="min-w-0 flex-1 truncate font-mono text-sm">{{ selectedFile }}</span>
332
+ <span v-if="selectedFileDiff?.binary" class="rounded bg-yellow-500/10 px-1.5 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
333
+ binary
334
+ </span>
335
+ <!-- View mode toggle -->
336
+ <TooltipProvider v-if="!selectedFileDiff?.binary">
337
+ <Tooltip>
338
+ <TooltipTrigger as-child>
339
+ <Button
340
+ variant="ghost"
341
+ size="sm"
342
+ class="h-7 shrink-0 gap-1.5 text-xs"
343
+ @click="toggleViewMode"
344
+ >
345
+ <component :is="viewMode === 'changes' ? FileDiffIcon : FileCode" class="size-3.5" />
346
+ {{ viewMode === 'changes' ? 'Changes' : 'Full file' }}
347
+ </Button>
348
+ </TooltipTrigger>
349
+ <TooltipContent>
350
+ {{ viewMode === 'changes' ? 'Show full file with changes' : 'Show changes only' }}
351
+ </TooltipContent>
352
+ </Tooltip>
353
+ </TooltipProvider>
354
+ </div>
355
+
356
+ <!-- File diff loading -->
357
+ <div v-if="isLoadingFileDiff" class="flex flex-1 items-center justify-center">
358
+ <Loader2 class="size-6 animate-spin text-muted-foreground" />
359
+ </div>
360
+
361
+ <!-- File diff error -->
362
+ <div
363
+ v-else-if="fileDiffError"
364
+ class="flex flex-1 flex-col items-center justify-center gap-4 p-8"
365
+ >
366
+ <AlertCircle class="size-8 text-destructive" />
367
+ <div class="text-center">
368
+ <p class="text-sm font-medium">Failed to load file diff</p>
369
+ <p class="text-xs text-muted-foreground">{{ fileDiffError }}</p>
370
+ </div>
371
+ <Button variant="outline" size="sm" @click="retryFileDiff">
372
+ <RefreshCw class="mr-2 size-4" />
373
+ Retry
374
+ </Button>
375
+ </div>
376
+
377
+ <!-- Diff viewer (min-h-0 enables flex item to shrink for scrolling) -->
378
+ <ScrollArea v-else-if="selectedFile" class="h-0 flex-1 overflow-hidden">
379
+ <div ref="diffViewerRef">
380
+ <GitDiffViewer
381
+ :hunks="hunks"
382
+ :file-path="selectedFile"
383
+ :binary="selectedFileDiff?.binary"
384
+ :old-path="selectedFileDiff?.oldPath"
385
+ :file-content="fileContent"
386
+ :show-full-file="viewMode === 'full'"
387
+ :is-loading-content="isLoadingFileContent"
388
+ />
389
+ </div>
390
+ </ScrollArea>
391
+
392
+ <!-- No file selected -->
393
+ <div
394
+ v-else
395
+ class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
396
+ >
397
+ Select a file to view changes
398
+ </div>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </template>