|
| 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, '&').replace(/</g, '<').replace(/>/g, '>') |
| 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> |
0 commit comments