Skip to content

Commit 0ca89dc

Browse files
committed
perf: move diff + highlighting to server
1 parent 2f43b6b commit 0ca89dc

File tree

9 files changed

+59
-317
lines changed

9 files changed

+59
-317
lines changed

app/components/diff/Line.vue

Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
<script setup lang="ts">
22
import type { DiffLine as DiffLineType } from '#shared/types'
3-
import { getClientHighlighter } from '~/utils/shiki-client'
43
54
const props = defineProps<{
65
line: DiffLineType
76
}>()
87
98
const diffContext = inject<{
109
fileStatus: ComputedRef<'add' | 'delete' | 'modify'>
11-
language?: ComputedRef<string>
12-
enableShiki?: ComputedRef<boolean>
1310
wordWrap?: ComputedRef<boolean>
1411
}>('diffContext')
1512
16-
const colorMode = useColorMode()
17-
1813
const lineNumberNew = computed(() => {
1914
if (props.line.type === 'normal') {
2015
return props.line.newLineNumber
@@ -66,57 +61,17 @@ const contentClasses = computed(() => {
6661
return ['pe-6', shouldWrap ? 'whitespace-pre-wrap break-words' : 'text-nowrap']
6762
})
6863
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-
8464
function escapeHtml(str: string): string {
8565
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
8666
}
8767
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-
const mounted = useMounted()
113-
114-
watch(
115-
() => [mounted.value, props.line, diffContext?.language?.value, colorMode.value],
116-
() => {
117-
if (mounted.value) highlightSegments()
118-
},
119-
{ deep: true },
68+
// Segments carry pre-highlighted HTML from the server API. Fall back to
69+
// escaped plain text for unsupported languages.
70+
const renderedSegments = computed(() =>
71+
props.line.content.map(seg => ({
72+
html: seg.html ?? escapeHtml(seg.value),
73+
type: seg.type,
74+
})),
12075
)
12176
</script>
12277

app/components/diff/Table.vue

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,16 @@
11
<script setup lang="ts">
22
import type { DiffHunk, DiffSkipBlock } from '#shared/types'
3-
import { guessLanguageFromPath } from '~/utils/language-detection'
43
54
const props = defineProps<{
65
hunks: (DiffHunk | DiffSkipBlock)[]
76
type: 'add' | 'delete' | 'modify'
87
fileName?: string
9-
enableShiki?: boolean
108
wordWrap?: boolean
119
}>()
1210
13-
const language = computed(() => {
14-
if (!props.fileName) return 'text'
15-
return guessLanguageFromPath(props.fileName)
16-
})
17-
1811
// provide diff context into child components
1912
provide('diffContext', {
2013
fileStatus: computed(() => props.type),
21-
language: computed(() => language.value),
22-
enableShiki: computed(() => props.enableShiki ?? false),
2314
wordWrap: computed(() => props.wordWrap ?? false),
2415
})
2516
</script>

app/components/diff/ViewerPanel.vue

Lines changed: 18 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script setup lang="ts">
2-
import type { FileDiffResponse, FileChange, DiffHunk } from '#shared/types'
3-
import { createDiff, insertSkipBlocks, countDiffStats } from '#shared/utils/diff'
2+
import type { FileDiffResponse, FileChange } from '#shared/types'
43
import { onClickOutside } from '@vueuse/core'
54
65
const props = defineProps<{
@@ -20,159 +19,26 @@ const optionsDropdownRef = useTemplateRef('optionsDropdownRef')
2019
onClickOutside(optionsDropdownRef, () => {
2120
showOptions.value = false
2221
})
23-
const loading = ref(true)
24-
const loadError = ref<Error | null>(null)
25-
const diff = ref<FileDiffResponse | null>(null)
26-
const fromContent = ref<string | null>(null)
27-
const toContent = ref<string | null>(null)
28-
const loadToken = ref(0)
29-
30-
const DIFF_TIMEOUT = 15000
31-
const MAX_DIFF_FILE_SIZE = 250 * 1024
32-
33-
const optionsParams = computed(() => ({
34-
mergeModifiedLines: mergeModifiedLines.value,
35-
maxChangeRatio: maxChangeRatio.value,
36-
maxDiffDistance: maxDiffDistance.value,
37-
inlineMaxCharEdits: inlineMaxCharEdits.value,
38-
}))
39-
40-
const status = computed(() => {
41-
if (loadError.value) return 'error'
42-
if (loading.value) return 'pending'
43-
return 'success'
44-
})
45-
46-
async function fetchFileContent(version: string): Promise<string | null> {
47-
const controller = new AbortController()
48-
const timeoutId = setTimeout(() => controller.abort(), DIFF_TIMEOUT)
49-
const url = `https://cdn.jsdelivr.net/npm/${props.packageName}@${version}/${props.file.path}`
50-
try {
51-
const res = await fetch(url, { signal: controller.signal })
52-
if (res.status === 404) return null
53-
if (!res.ok) throw new Error(`Failed to fetch file (status ${res.status})`)
54-
55-
const length = res.headers.get('content-length')
56-
if (length && parseInt(length, 10) > MAX_DIFF_FILE_SIZE) {
57-
throw new Error(
58-
`File too large to diff (${(parseInt(length, 10) / 1024).toFixed(0)}KB). Maximum is ${
59-
MAX_DIFF_FILE_SIZE / 1024
60-
}KB.`,
61-
)
62-
}
63-
64-
const text = await res.text()
65-
if (text.length > MAX_DIFF_FILE_SIZE) {
66-
throw new Error(
67-
`File too large to diff (${(text.length / 1024).toFixed(0)}KB). Maximum is ${
68-
MAX_DIFF_FILE_SIZE / 1024
69-
}KB.`,
70-
)
71-
}
72-
73-
return text
74-
} catch (err) {
75-
// Provide specific error message for timeout
76-
if (err instanceof Error && err.name === 'AbortError') {
77-
throw new Error(`Request timed out after ${DIFF_TIMEOUT / 1000}s`, { cause: err })
78-
}
79-
throw err
80-
} finally {
81-
clearTimeout(timeoutId)
82-
}
83-
}
84-
85-
function computeDiff() {
86-
if (loadError.value) return
87-
if (loading.value && fromContent.value === null && toContent.value === null) return
88-
89-
const oldContent = fromContent.value ?? ''
90-
const newContent = toContent.value ?? ''
91-
92-
// Determine diff type based on content availability
93-
// Note: FileDiffResponse uses 'add'/'delete'/'modify' while FileChange uses
94-
// 'added'/'removed'/'modified' - this is intentional to distinguish between
95-
// the file-level change info (FileChange) and the diff content type (FileDiff)
96-
let type: FileDiffResponse['type'] = 'modify'
97-
if (fromContent.value === null && toContent.value !== null) type = 'add'
98-
else if (fromContent.value !== null && toContent.value === null) type = 'delete'
99-
100-
const parsed = createDiff(oldContent, newContent, props.file.path, optionsParams.value)
101-
102-
if (!parsed) {
103-
diff.value = {
104-
package: props.packageName,
105-
from: props.fromVersion,
106-
to: props.toVersion,
107-
path: props.file.path,
108-
type,
109-
hunks: [],
110-
stats: { additions: 0, deletions: 0 },
111-
meta: {},
112-
}
113-
return
114-
}
11522
116-
const hunksWithSkips = insertSkipBlocks(
117-
parsed.hunks.filter((h): h is DiffHunk => h.type === 'hunk'),
118-
)
119-
const stats = countDiffStats(hunksWithSkips)
120-
121-
diff.value = {
122-
package: props.packageName,
123-
from: props.fromVersion,
124-
to: props.toVersion,
125-
path: props.file.path,
126-
// Use the type computed from file existence (modify/add/delete) rather than
127-
// parsed.type which incorrectly classifies addition-only modifications as 'add'.
128-
// This matters because DiffLine skips green background highlighting when
129-
// fileStatus is 'add' (since for truly new files all lines are additions).
130-
type,
131-
hunks: hunksWithSkips,
132-
stats,
133-
meta: {},
134-
}
135-
}
136-
137-
async function loadContents() {
138-
const token = ++loadToken.value
139-
loading.value = true
140-
loadError.value = null
141-
try {
142-
const [from, to] = await Promise.all([
143-
fetchFileContent(props.fromVersion),
144-
fetchFileContent(props.toVersion),
145-
])
146-
147-
if (token !== loadToken.value) return
148-
149-
fromContent.value = from
150-
toContent.value = to
151-
152-
if (from === null && to === null) {
153-
throw new Error('File not found in either version')
154-
}
155-
156-
computeDiff()
157-
} catch (err) {
158-
if (token !== loadToken.value) return
159-
loadError.value = err as Error
160-
diff.value = null
161-
} finally {
162-
if (token === loadToken.value) loading.value = false
163-
}
164-
}
165-
166-
watch(
167-
[() => props.file.path, () => props.fromVersion, () => props.toVersion],
168-
() => {
169-
loadContents()
170-
},
171-
{ immediate: true },
23+
const apiUrl = computed(
24+
() =>
25+
`/api/registry/compare-file/${props.packageName}/v/${props.fromVersion}...${props.toVersion}/${props.file.path}`,
17226
)
17327
174-
watch([mergeModifiedLines, maxChangeRatio, maxDiffDistance, inlineMaxCharEdits], () => {
175-
computeDiff()
28+
const apiQuery = computed(() => ({
29+
mergeModifiedLines: String(mergeModifiedLines.value),
30+
maxChangeRatio: String(maxChangeRatio.value),
31+
maxDiffDistance: String(maxDiffDistance.value),
32+
inlineMaxCharEdits: String(inlineMaxCharEdits.value),
33+
}))
34+
35+
const {
36+
data: diff,
37+
status,
38+
error: loadError,
39+
} = useFetch<FileDiffResponse>(apiUrl, {
40+
query: apiQuery,
41+
timeout: 15000,
17642
})
17743
17844
function calcPercent(value: number, min: number, max: number): number {
@@ -466,7 +332,6 @@ function getCodeUrl(version: string): string {
466332
:hunks="diff.hunks"
467333
:type="diff.type"
468334
:file-name="file.path"
469-
:enable-shiki="true"
470335
:word-wrap="wordWrap"
471336
/>
472337
</div>

app/utils/language-detection.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

0 commit comments

Comments
 (0)