Skip to content

Commit 1a8d29b

Browse files
taskylizarddanielroewojtekmajautofix-ci[bot]
authored
feat: package diffing (#356)
Co-authored-by: taskylizard <75871323+taskylizard@users.noreply.github.com> Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: Wojciech Maj <kontakt@wojtekmaj.pl> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 56ee7b5 commit 1a8d29b

File tree

31 files changed

+3601
-8
lines changed

31 files changed

+3601
-8
lines changed

app/components/Button/Base.stories.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const Disabled: Story = {
3939
export const WithIcon: Story = {
4040
args: {
4141
default: 'Search',
42-
classicon: 'i-carbon:search',
42+
classicon: 'i-lucide:search',
4343
variant: 'secondary',
4444
},
4545
}

app/components/diff/FileTree.vue

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<script setup lang="ts">
2+
import type { FileChange } from '#shared/types'
3+
4+
interface DiffTreeNode {
5+
name: string
6+
path: string
7+
type: 'file' | 'directory'
8+
changeType?: 'added' | 'removed' | 'modified'
9+
children?: DiffTreeNode[]
10+
}
11+
12+
const props = defineProps<{
13+
files: FileChange[]
14+
selectedPath: string | null
15+
// Internal props for recursion
16+
treeNodes?: DiffTreeNode[]
17+
depth?: number
18+
}>()
19+
20+
const emit = defineEmits<{
21+
select: [file: FileChange]
22+
}>()
23+
24+
const depth = computed(() => props.depth ?? 0)
25+
26+
// Sort: directories first, then alphabetically
27+
function sortTree(nodes: DiffTreeNode[]): DiffTreeNode[] {
28+
return nodes
29+
.map(n => ({
30+
...n,
31+
children: n.children ? sortTree(n.children) : undefined,
32+
}))
33+
.sort((a, b) => {
34+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
35+
return a.name.localeCompare(b.name)
36+
})
37+
}
38+
39+
// Build tree structure from flat file list (only at root level)
40+
function buildTree(files: FileChange[]): DiffTreeNode[] {
41+
const root: DiffTreeNode[] = []
42+
43+
for (const file of files) {
44+
const parts = file.path.split('/')
45+
let current = root
46+
47+
for (let i = 0; i < parts.length; i++) {
48+
const part = parts[i]!
49+
const isFile = i === parts.length - 1
50+
const path = parts.slice(0, i + 1).join('/')
51+
52+
let node = current.find(n => n.name === part)
53+
if (!node) {
54+
node = {
55+
name: part,
56+
path,
57+
type: isFile ? 'file' : 'directory',
58+
changeType: isFile ? file.type : undefined,
59+
children: isFile ? undefined : [],
60+
}
61+
current.push(node)
62+
}
63+
64+
if (!isFile) {
65+
current = node.children!
66+
}
67+
}
68+
}
69+
70+
return sortTree(root)
71+
}
72+
73+
// Use provided tree nodes or build from files
74+
const tree = computed(() => props.treeNodes ?? buildTree(props.files))
75+
76+
// Check if a node or any of its children is currently selected
77+
function isNodeActive(node: DiffTreeNode): boolean {
78+
if (props.selectedPath === node.path) return true
79+
if (props.selectedPath?.startsWith(node.path + '/')) return true
80+
return false
81+
}
82+
83+
const expandedDirs = ref<Set<string>>(new Set())
84+
85+
function collectDirs(nodes: DiffTreeNode[]) {
86+
for (const node of nodes) {
87+
if (node.type === 'directory') {
88+
expandedDirs.value.add(node.path)
89+
if (node.children) collectDirs(node.children)
90+
}
91+
}
92+
}
93+
94+
// Auto-expand all directories eagerly (runs on both SSR and client)
95+
watchEffect(() => {
96+
if (props.depth === undefined || props.depth === 0) {
97+
collectDirs(tree.value)
98+
}
99+
})
100+
101+
function toggleDir(path: string) {
102+
if (expandedDirs.value.has(path)) {
103+
expandedDirs.value.delete(path)
104+
} else {
105+
expandedDirs.value.add(path)
106+
}
107+
}
108+
109+
function isExpanded(path: string): boolean {
110+
return expandedDirs.value.has(path)
111+
}
112+
113+
function getChangeIcon(type: 'added' | 'removed' | 'modified') {
114+
switch (type) {
115+
case 'added':
116+
return 'i-lucide:file-plus text-green-500'
117+
case 'removed':
118+
return 'i-lucide:file-minus text-red-500'
119+
case 'modified':
120+
return 'i-lucide:file-diff text-yellow-500'
121+
}
122+
}
123+
124+
function handleFileClick(node: DiffTreeNode) {
125+
const file = props.files.find(f => f.path === node.path)
126+
if (file) emit('select', file)
127+
}
128+
</script>
129+
130+
<template>
131+
<ul class="list-none m-0 p-0" :class="depth === 0 ? 'py-2' : ''">
132+
<li v-for="node in tree" :key="node.path">
133+
<!-- Directory -->
134+
<template v-if="node.type === 'directory'">
135+
<button
136+
type="button"
137+
class="w-full flex items-center gap-1.5 py-1.5 px-3 text-start font-mono text-sm transition-colors hover:bg-bg-muted"
138+
:class="isNodeActive(node) ? 'text-fg' : 'text-fg-muted'"
139+
:style="{ paddingLeft: `${depth * 12 + 12}px` }"
140+
@click="toggleDir(node.path)"
141+
>
142+
<span
143+
class="w-4 h-4 shrink-0 transition-transform"
144+
:class="[isExpanded(node.path) ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right']"
145+
/>
146+
<span
147+
class="w-4 h-4 shrink-0"
148+
:class="
149+
isExpanded(node.path)
150+
? 'i-lucide:folder-open text-yellow-500'
151+
: 'i-lucide:folder text-yellow-600'
152+
"
153+
/>
154+
<span class="truncate">{{ node.name }}</span>
155+
</button>
156+
<FileTree
157+
v-if="isExpanded(node.path) && node.children"
158+
:files="files"
159+
:tree-nodes="node.children"
160+
:selected-path="selectedPath"
161+
:depth="depth + 1"
162+
@select="emit('select', $event)"
163+
/>
164+
</template>
165+
166+
<!-- File -->
167+
<template v-else>
168+
<button
169+
type="button"
170+
class="w-full flex items-center gap-1.5 py-1.5 px-3 font-mono text-sm transition-colors hover:bg-bg-muted text-start"
171+
:class="selectedPath === node.path ? 'bg-bg-muted text-fg' : 'text-fg-muted'"
172+
:style="{ paddingLeft: `${depth * 12 + 32}px` }"
173+
@click="handleFileClick(node)"
174+
>
175+
<span class="w-4 h-4 shrink-0" :class="getChangeIcon(node.changeType!)" />
176+
<span class="truncate">{{ node.name }}</span>
177+
</button>
178+
</template>
179+
</li>
180+
</ul>
181+
</template>

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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script setup lang="ts">
2+
import type { DiffLine as DiffLineType } from '#shared/types'
3+
4+
const props = defineProps<{
5+
line: DiffLineType
6+
}>()
7+
8+
const diffContext = inject<{
9+
fileStatus: ComputedRef<'add' | 'delete' | 'modify'>
10+
wordWrap?: ComputedRef<boolean>
11+
}>('diffContext')
12+
13+
const lineNumberNew = computed(() => {
14+
if (props.line.type === 'normal') {
15+
return props.line.newLineNumber
16+
}
17+
return props.line.lineNumber ?? props.line.newLineNumber
18+
})
19+
20+
const lineNumberOld = computed(() => {
21+
if (props.line.type === 'normal') {
22+
return props.line.oldLineNumber
23+
}
24+
return props.line.type === 'delete'
25+
? (props.line.lineNumber ?? props.line.oldLineNumber)
26+
: undefined
27+
})
28+
29+
const rowClasses = computed(() => {
30+
const shouldWrap = diffContext?.wordWrap?.value ?? false
31+
const classes = ['whitespace-pre-wrap', 'box-border', 'border-none']
32+
if (shouldWrap) classes.push('min-h-6')
33+
else classes.push('h-6', 'min-h-6')
34+
const fileStatus = diffContext?.fileStatus.value
35+
36+
if (props.line.type === 'insert' && fileStatus !== 'add') {
37+
classes.push('bg-[var(--code-added)]/10')
38+
}
39+
if (props.line.type === 'delete' && fileStatus !== 'delete') {
40+
classes.push('bg-[var(--code-removed)]/10')
41+
}
42+
43+
return classes
44+
})
45+
46+
const borderClasses = computed(() => {
47+
const classes = ['border-transparent', 'w-1', 'border-is-3']
48+
49+
if (props.line.type === 'insert') {
50+
classes.push('border-[color:var(--code-added)]/60')
51+
}
52+
if (props.line.type === 'delete') {
53+
classes.push('border-[color:var(--code-removed)]/80')
54+
}
55+
56+
return classes
57+
})
58+
59+
const contentClasses = computed(() => {
60+
const shouldWrap = diffContext?.wordWrap?.value ?? false
61+
return ['pe-6', shouldWrap ? 'whitespace-pre-wrap break-words' : 'text-nowrap']
62+
})
63+
64+
function escapeHtml(str: string): string {
65+
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
66+
}
67+
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+
})),
75+
)
76+
</script>
77+
78+
<template>
79+
<tr
80+
:data-line-new="lineNumberNew"
81+
:data-line-old="lineNumberOld"
82+
:data-line-kind="line.type"
83+
:class="rowClasses"
84+
>
85+
<!-- Border indicator -->
86+
<td :class="borderClasses" />
87+
88+
<!-- Line number -->
89+
<td class="tabular-nums text-center opacity-50 px-2 text-xs select-none w-12 shrink-0">
90+
{{ line.type === 'delete' ? '–' : lineNumberNew }}
91+
</td>
92+
93+
<!-- Line content -->
94+
<td :class="contentClasses">
95+
<component :is="line.type === 'insert' ? 'ins' : line.type === 'delete' ? 'del' : 'span'">
96+
<span
97+
v-for="(seg, i) in renderedSegments"
98+
:key="i"
99+
:class="{
100+
'bg-[var(--code-added)]/20': seg.type === 'insert',
101+
'bg-[var(--code-removed)]/20': seg.type === 'delete',
102+
}"
103+
v-html="seg.html"
104+
/>
105+
</component>
106+
</td>
107+
</tr>
108+
</template>
109+
110+
<style scoped>
111+
ins,
112+
del {
113+
text-decoration: none;
114+
}
115+
</style>

0 commit comments

Comments
 (0)