@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.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- 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, '&')
|
|
342
|
+
.replace(/</g, '<')
|
|
343
|
+
.replace(/>/g, '>')
|
|
344
|
+
.replace(/"/g, '"')
|
|
345
|
+
.replace(/'/g, ''')
|
|
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>
|