Skip to content

Commit 5063edd

Browse files
committed
WIP
WIP fix: detect file modifications using hashes fix: surface diff fetch failures fix: handle file-directory transitions in compare WIP
1 parent 77769b1 commit 5063edd

23 files changed

Lines changed: 2801 additions & 114 deletions

File tree

app/components/diff/Hunk.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script setup lang="ts">
2+
import type { DiffHunk as DiffHunkType } from '#shared/types'
3+
4+
defineProps<{
5+
hunk: DiffHunkType
6+
}>()
7+
</script>
8+
9+
<template>
10+
<DiffLine v-for="(line, index) in hunk.lines" :key="index" :line="line" />
11+
</template>

app/components/diff/Line.vue

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<script setup lang="ts">
2+
import type { DiffLine as DiffLineType } from '#shared/types'
3+
import { getClientHighlighter } from '~/utils/shiki-client'
4+
5+
const props = defineProps<{
6+
line: DiffLineType
7+
}>()
8+
9+
const diffContext = inject<{
10+
fileStatus: ComputedRef<'add' | 'delete' | 'modify'>
11+
language?: ComputedRef<string>
12+
enableShiki?: ComputedRef<boolean>
13+
wordWrap?: ComputedRef<boolean>
14+
}>('diffContext')
15+
16+
const colorMode = useColorMode()
17+
18+
const lineNumberNew = computed(() => {
19+
if (props.line.type === 'normal') {
20+
return props.line.newLineNumber
21+
}
22+
return props.line.lineNumber ?? props.line.newLineNumber
23+
})
24+
25+
const lineNumberOld = computed(() => {
26+
if (props.line.type === 'normal') {
27+
return props.line.oldLineNumber
28+
}
29+
return props.line.type === 'delete'
30+
? (props.line.lineNumber ?? props.line.oldLineNumber)
31+
: undefined
32+
})
33+
34+
const rowClasses = computed(() => {
35+
const shouldWrap = diffContext?.wordWrap?.value ?? false
36+
const classes = ['whitespace-pre-wrap', 'box-border', 'border-none']
37+
if (shouldWrap) classes.push('min-h-6')
38+
else classes.push('h-6', 'min-h-6')
39+
const fileStatus = diffContext?.fileStatus.value
40+
41+
if (props.line.type === 'insert' && fileStatus !== 'add') {
42+
classes.push('bg-[var(--code-added)]/10')
43+
}
44+
if (props.line.type === 'delete' && fileStatus !== 'delete') {
45+
classes.push('bg-[var(--code-removed)]/10')
46+
}
47+
48+
return classes
49+
})
50+
51+
const borderClasses = computed(() => {
52+
const classes = ['border-transparent', 'w-1', 'border-l-3']
53+
54+
if (props.line.type === 'insert') {
55+
classes.push('border-[color:var(--code-added)]/60')
56+
}
57+
if (props.line.type === 'delete') {
58+
classes.push('border-[color:var(--code-removed)]/80')
59+
}
60+
61+
return classes
62+
})
63+
64+
const contentClasses = computed(() => {
65+
const shouldWrap = diffContext?.wordWrap?.value ?? false
66+
return ['pr-6', shouldWrap ? 'whitespace-pre-wrap break-words' : 'text-nowrap']
67+
})
68+
69+
type RenderedSegment = { html: string; type: 'insert' | 'delete' | 'normal' }
70+
const renderedSegments = shallowRef<RenderedSegment[]>(
71+
props.line.content.map(seg => ({ html: escapeHtml(seg.value), type: seg.type })),
72+
)
73+
74+
function normalizeLanguage(raw?: string): 'javascript' | 'typescript' | 'json' | 'plaintext' {
75+
if (!raw) return 'plaintext'
76+
const lang = raw.toLowerCase()
77+
if (lang.includes('json')) return 'json'
78+
if (lang === 'ts' || lang.includes('typescript') || lang.includes('tsx')) return 'typescript'
79+
if (lang === 'js' || lang.includes('javascript') || lang.includes('mjs') || lang.includes('cjs'))
80+
return 'javascript'
81+
return 'plaintext'
82+
}
83+
84+
function escapeHtml(str: string): string {
85+
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
86+
}
87+
88+
async function highlightSegments() {
89+
if (!import.meta.client) return
90+
91+
const lang = normalizeLanguage(diffContext?.language?.value)
92+
// If language unsupported, keep escaped plain text
93+
if (lang === 'plaintext') {
94+
renderedSegments.value = props.line.content.map(seg => ({
95+
html: escapeHtml(seg.value),
96+
type: seg.type,
97+
}))
98+
return
99+
}
100+
101+
const theme = colorMode.value === 'light' ? 'github-light' : 'github-dark'
102+
const highlighter = await getClientHighlighter()
103+
104+
renderedSegments.value = props.line.content.map(seg => {
105+
const code = seg.value.length ? seg.value : ' '
106+
const html = highlighter.codeToHtml(code, { lang, theme })
107+
const inner = html.match(/<code[^>]*>([\s\S]*?)<\/code>/)?.[1] ?? escapeHtml(code)
108+
return { html: inner, type: seg.type }
109+
})
110+
}
111+
112+
watch(
113+
() => [props.line, diffContext?.language?.value, colorMode.value],
114+
() => {
115+
highlightSegments()
116+
},
117+
{ immediate: true, deep: true },
118+
)
119+
</script>
120+
121+
<template>
122+
<tr
123+
:data-line-new="lineNumberNew"
124+
:data-line-old="lineNumberOld"
125+
:data-line-kind="line.type"
126+
:class="rowClasses"
127+
>
128+
<!-- Border indicator -->
129+
<td :class="borderClasses" />
130+
131+
<!-- Line number -->
132+
<td class="tabular-nums text-center opacity-50 px-2 text-xs select-none w-12 shrink-0">
133+
{{ line.type === 'delete' ? '–' : lineNumberNew }}
134+
</td>
135+
136+
<!-- Line content -->
137+
<td :class="contentClasses">
138+
<component :is="line.type === 'insert' ? 'ins' : line.type === 'delete' ? 'del' : 'span'">
139+
<span
140+
v-for="(seg, i) in renderedSegments"
141+
:key="i"
142+
:class="{
143+
'bg-[var(--code-added)]/20': seg.type === 'insert',
144+
'bg-[var(--code-removed)]/20': seg.type === 'delete',
145+
}"
146+
v-html="seg.html"
147+
/>
148+
</component>
149+
</td>
150+
</tr>
151+
</template>
152+
153+
<style scoped>
154+
ins,
155+
del {
156+
text-decoration: none;
157+
}
158+
</style>

app/components/diff/SkipBlock.vue

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
count: number
4+
content?: string
5+
}>()
6+
</script>
7+
8+
<template>
9+
<!-- Spacer row -->
10+
<tr class="h-2" />
11+
12+
<!-- Skip block row -->
13+
<tr class="h-10 font-mono bg-bg-muted text-fg-muted">
14+
<td />
15+
<td class="opacity-50 select-none text-center">
16+
<span class="i-lucide-chevrons-up-down w-4 h-4" />
17+
</td>
18+
<td>
19+
<span class="px-0 sticky left-2 italic opacity-50">
20+
{{ content || `${count} lines hidden` }}
21+
</span>
22+
</td>
23+
</tr>
24+
25+
<!-- Spacer row -->
26+
<tr class="h-2" />
27+
</template>

app/components/diff/Table.vue

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<script setup lang="ts">
2+
import type { DiffHunk, DiffSkipBlock } from '#shared/types'
3+
import { guessLanguageFromPath } from '~/utils/language-detection'
4+
5+
const props = defineProps<{
6+
hunks: (DiffHunk | DiffSkipBlock)[]
7+
type: 'add' | 'delete' | 'modify'
8+
fileName?: string
9+
enableShiki?: boolean
10+
wordWrap?: boolean
11+
}>()
12+
13+
const language = computed(() => {
14+
if (!props.fileName) return 'text'
15+
return guessLanguageFromPath(props.fileName)
16+
})
17+
18+
// provide diff context into child components
19+
provide('diffContext', {
20+
fileStatus: computed(() => props.type),
21+
language: computed(() => language.value),
22+
enableShiki: computed(() => props.enableShiki ?? false),
23+
wordWrap: computed(() => props.wordWrap ?? false),
24+
})
25+
</script>
26+
27+
<template>
28+
<table
29+
class="diff-table font-mono text-sm w-full m-0 border-separate border-0 outline-none overflow-x-auto border-spacing-0"
30+
>
31+
<tbody class="w-full box-border">
32+
<template v-for="(hunk, index) in hunks" :key="index">
33+
<DiffHunk v-if="hunk.type === 'hunk'" :hunk="hunk" />
34+
<DiffSkipBlock v-else :count="hunk.count" :content="hunk.content" />
35+
</template>
36+
</tbody>
37+
</table>
38+
</template>
39+
40+
<style scoped>
41+
.diff-table {
42+
--code-added: oklch(0.723 0.219 149.579);
43+
--code-removed: oklch(0.704 0.191 22.216);
44+
}
45+
46+
:root[data-theme='light'] .diff-table {
47+
--code-added: oklch(0.527 0.154 150.069);
48+
--code-removed: oklch(0.577 0.184 27.325);
49+
}
50+
</style>

0 commit comments

Comments
 (0)