Skip to content

Commit ebfdeef

Browse files
committed
WIP
WIP
1 parent c39683a commit ebfdeef

24 files changed

Lines changed: 2637 additions & 21 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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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' ? props.line.lineNumber ?? props.line.oldLineNumber : undefined
30+
})
31+
32+
const rowClasses = computed(() => {
33+
const shouldWrap = diffContext?.wordWrap?.value ?? false
34+
const classes = ['whitespace-pre-wrap', 'box-border', 'border-none']
35+
if (shouldWrap) classes.push('min-h-6')
36+
else classes.push('h-6', 'min-h-6')
37+
const fileStatus = diffContext?.fileStatus.value
38+
39+
if (props.line.type === 'insert' && fileStatus !== 'add') {
40+
classes.push('bg-[var(--code-added)]/10')
41+
}
42+
if (props.line.type === 'delete' && fileStatus !== 'delete') {
43+
classes.push('bg-[var(--code-removed)]/10')
44+
}
45+
46+
return classes
47+
})
48+
49+
const borderClasses = computed(() => {
50+
const classes = ['border-transparent', 'w-1', 'border-l-3']
51+
52+
if (props.line.type === 'insert') {
53+
classes.push('border-[color:var(--code-added)]/60')
54+
}
55+
if (props.line.type === 'delete') {
56+
classes.push('border-[color:var(--code-removed)]/80')
57+
}
58+
59+
return classes
60+
})
61+
62+
const contentClasses = computed(() => {
63+
const shouldWrap = diffContext?.wordWrap?.value ?? false
64+
return ['pr-6', shouldWrap ? 'whitespace-pre-wrap break-words' : 'text-nowrap']
65+
})
66+
67+
type RenderedSegment = { html: string; type: 'insert' | 'delete' | 'normal' }
68+
const renderedSegments = shallowRef<RenderedSegment[]>(
69+
props.line.content.map(seg => ({ html: escapeHtml(seg.value), type: seg.type })),
70+
)
71+
72+
function normalizeLanguage(raw?: string): 'javascript' | 'typescript' | 'json' | 'plaintext' {
73+
if (!raw) return 'plaintext'
74+
const lang = raw.toLowerCase()
75+
if (lang.includes('json')) return 'json'
76+
if (lang === 'ts' || lang.includes('typescript') || lang.includes('tsx')) return 'typescript'
77+
if (lang === 'js' || lang.includes('javascript') || lang.includes('mjs') || lang.includes('cjs')) return 'javascript'
78+
return 'plaintext'
79+
}
80+
81+
function escapeHtml(str: string): string {
82+
return str
83+
.replace(/&/g, '&amp;')
84+
.replace(/</g, '&lt;')
85+
.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 => ({ html: escapeHtml(seg.value), type: seg.type }))
95+
return
96+
}
97+
98+
const theme = colorMode.value === 'light' ? 'github-light' : 'github-dark'
99+
const highlighter = await getClientHighlighter()
100+
101+
renderedSegments.value = props.line.content.map(seg => {
102+
const code = seg.value.length ? seg.value : ' '
103+
const html = highlighter.codeToHtml(code, { lang, theme })
104+
const inner = html.match(/<code[^>]*>([\s\S]*?)<\/code>/)?.[1] ?? escapeHtml(code)
105+
return { html: inner, type: seg.type }
106+
})
107+
}
108+
109+
watch(
110+
() => [props.line, diffContext?.language?.value, colorMode.value],
111+
() => {
112+
highlightSegments()
113+
},
114+
{ immediate: true, deep: true },
115+
)
116+
</script>
117+
118+
<template>
119+
<tr
120+
:data-line-new="lineNumberNew"
121+
:data-line-old="lineNumberOld"
122+
:data-line-kind="line.type"
123+
:class="rowClasses"
124+
>
125+
<!-- Border indicator -->
126+
<td :class="borderClasses" />
127+
128+
<!-- Line number -->
129+
<td class="tabular-nums text-center opacity-50 px-2 text-xs select-none w-12 shrink-0">
130+
{{ line.type === 'delete' ? '–' : lineNumberNew }}
131+
</td>
132+
133+
<!-- Line content -->
134+
<td :class="contentClasses">
135+
<component :is="line.type === 'insert' ? 'ins' : line.type === 'delete' ? 'del' : 'span'">
136+
<span
137+
v-for="(seg, i) in renderedSegments"
138+
:key="i"
139+
:class="{
140+
'bg-[var(--code-added)]/20': seg.type === 'insert',
141+
'bg-[var(--code-removed)]/20': seg.type === 'delete',
142+
}"
143+
v-html="seg.html"
144+
/>
145+
</component>
146+
</td>
147+
</tr>
148+
</template>
149+
150+
<style scoped>
151+
ins,
152+
del {
153+
text-decoration: none;
154+
}
155+
</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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
14+
const language = computed(() => {
15+
if (!props.fileName) return 'text'
16+
return guessLanguageFromPath(props.fileName)
17+
})
18+
19+
// provide diff context into child components
20+
provide('diffContext', {
21+
fileStatus: computed(() => props.type),
22+
language: computed(() => language.value),
23+
enableShiki: computed(() => props.enableShiki ?? false),
24+
wordWrap: computed(() => props.wordWrap ?? false),
25+
})
26+
</script>
27+
28+
<template>
29+
<table
30+
class="diff-table font-mono text-sm w-full m-0 border-separate border-0 outline-none overflow-x-auto border-spacing-0"
31+
>
32+
<tbody class="w-full box-border">
33+
<template v-for="(hunk, index) in hunks" :key="index">
34+
<DiffHunk v-if="hunk.type === 'hunk'" :hunk="hunk" />
35+
<DiffSkipBlock v-else :count="hunk.count" :content="hunk.content" />
36+
</template>
37+
</tbody>
38+
</table>
39+
</template>
40+
41+
<style scoped>
42+
.diff-table {
43+
--code-added: oklch(0.723 0.219 149.579);
44+
--code-removed: oklch(0.704 0.191 22.216);
45+
}
46+
47+
:root[data-theme='light'] .diff-table {
48+
--code-added: oklch(0.527 0.154 150.069);
49+
--code-removed: oklch(0.577 0.184 27.325);
50+
}
51+
</style>

0 commit comments

Comments
 (0)