Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 73 additions & 40 deletions app/components/Code/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,38 @@ const emit = defineEmits<{

const codeRef = useTemplateRef('codeRef')

// Generate line numbers array
const lineNumbers = computed(() => {
return Array.from({ length: props.lines }, (_, i) => i + 1)
})
// Using this so we can track the height of each line, and therefore compute digit sidebar
const lineMultipliers = ref<number[]>([])
const LINE_HEIGHT_PX = 24 // also used in css

function updateLineMultipliers() {
if (!codeRef.value) return
const lines = Array.from(codeRef.value.querySelectorAll('code > .line'))
lineMultipliers.value = lines.map(line =>
Math.max(1, Math.round(parseFloat(getComputedStyle(line).height) / LINE_HEIGHT_PX)),
)
Comment on lines +18 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Throttle resize-driven line measurement to avoid jank on large files.

Line 31 currently triggers a full recomputation, and Lines 20-23 read computed style for every rendered code line each time. On large files this can become expensive during resize and cause visible lag.

⚡ Proposed fix
+const scheduleLineMultiplierUpdate = useDebounceFn(() => {
+  updateLineMultipliers()
+}, 16)
+
 watch(
   () => props.html,
-  () => nextTick(updateLineMultipliers),
+  () => nextTick(scheduleLineMultiplierUpdate),
   { immediate: true },
 )
-useResizeObserver(codeRef, updateLineMultipliers)
+useResizeObserver(codeRef, scheduleLineMultiplierUpdate)

Also applies to: 31-31

}

// Used for CSS calculation of line number column width
const lineDigits = computed(() => {
return String(props.lines).length
watch(
() => props.html,
() => nextTick(updateLineMultipliers),
{ immediate: true },
)
useResizeObserver(codeRef, updateLineMultipliers)

// Line numbers ++ blank rows for the wrapped lines
const displayLines = computed(() => {
const result: (number | null)[] = []
for (let i = 0; i < props.lines; i++) {
result.push(i + 1)
const extra = (lineMultipliers.value[i] ?? 1) - 1
for (let j = 0; j < extra; j++) result.push(null)
}
return result
})

const lineDigits = computed(() => String(props.lines).length)

// Check if a line is selected
function isLineSelected(lineNum: number): boolean {
if (!props.selectedLines) return false
Expand Down Expand Up @@ -86,36 +108,37 @@ watch(
</script>

<template>
<div class="code-viewer flex min-h-full max-w-full">
<div class="code-viewer flex min-h-full max-w-full" :style="{ '--line-digits': lineDigits }">
<!-- Line numbers column -->
<div
class="line-numbers shrink-0 bg-bg-subtle border-ie border-solid border-border text-end select-none relative"
:style="{ '--line-digits': lineDigits }"
aria-hidden="true"
>
<!-- This needs to be a native <a> element, because `LinkBase` (or specifically `NuxtLink`) does not seem to work when trying to prevent default behavior (jumping to the anchor) -->
<a
v-for="lineNum in lineNumbers"
:id="`L${lineNum}`"
:key="lineNum"
:href="`#L${lineNum}`"
tabindex="-1"
class="line-number block px-3 py-0 font-mono text-sm leading-6 cursor-pointer transition-colors no-underline"
:class="[
isLineSelected(lineNum)
? 'bg-yellow-500/20 text-fg'
: 'text-fg-subtle hover:text-fg-muted',
]"
@click.prevent="onLineClick(lineNum, $event)"
>
{{ lineNum }}
</a>
<template v-for="(lineNum, idx) in displayLines" :key="idx">
<a
v-if="lineNum !== null"
:id="`L${lineNum}`"
:href="`#L${lineNum}`"
tabindex="-1"
class="line-number block px-3 py-0 font-mono text-sm leading-6 cursor-pointer transition-colors no-underline"
:class="[
isLineSelected(lineNum)
? 'bg-yellow-500/20 text-fg'
: 'text-fg-subtle hover:text-fg-muted',
]"
@click.prevent="onLineClick(lineNum, $event)"
>
{{ lineNum }}
</a>
<span v-else class="block px-3 leading-6">&nbsp;</span>
</template>
</div>

<!-- Code content -->
<div class="code-content flex-1 overflow-x-auto min-w-0">
<div class="code-content">
<!-- eslint-disable vue/no-v-html -- HTML is generated server-side by Shiki -->
<div ref="codeRef" class="code-lines min-w-full w-fit" v-html="html" />
<div ref="codeRef" class="code-lines" v-html="html" />
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
Expand All @@ -124,46 +147,56 @@ watch(
<style scoped>
.code-viewer {
font-size: 14px;
/* 1ch per digit + 1.5rem (px-3 * 2) padding */
--line-numbers-width: calc(var(--line-digits) * 1ch + 1.5rem);
}

.line-numbers {
/* 1ch per digit + 1.5rem (px-3 * 2) padding */
min-width: calc(var(--line-digits) * 1ch + 1.5rem);
min-width: var(--line-numbers-width);
}

.code-content :deep(pre) {
.code-content {
flex: 1;
min-width: 0;
max-width: calc(100% - var(--line-numbers-width));
}

.code-content:deep(pre) {
margin: 0;
padding: 0;
background: transparent !important;
overflow: visible;
max-width: 100%;
}

.code-content :deep(code) {
.code-content:deep(code) {
display: block;
padding: 0 1rem;
background: transparent !important;
max-width: 100%;
}

.code-content :deep(.line) {
display: block;
.code-content:deep(.line) {
display: flex;
flex-wrap: wrap;
/* Ensure consistent height matching line numbers */
line-height: 24px;
min-height: 24px;
max-height: 24px;
white-space: pre;
line-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
min-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
white-space: pre-wrap;
overflow: hidden;
Comment on lines +179 to 186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Long unbroken tokens can still overflow in wrap mode.

Line 185 uses white-space: pre-wrap, which does not hard-break long strings without natural breakpoints (e.g. minified blobs, hashes, base64). This can reintroduce horizontal overflow despite the wrapping objective.

🩹 Proposed fix
 .code-content:deep(.line) {
   display: flex;
   flex-wrap: wrap;
   /* Ensure consistent height matching line numbers */
   line-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
   min-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
   white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  word-break: break-word;
   overflow: hidden;
   transition: background-color 0.1s;
   max-width: 100%;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.code-content:deep(.line) {
display: flex;
flex-wrap: wrap;
/* Ensure consistent height matching line numbers */
line-height: 24px;
min-height: 24px;
max-height: 24px;
white-space: pre;
line-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
min-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
white-space: pre-wrap;
overflow: hidden;
.code-content:deep(.line) {
display: flex;
flex-wrap: wrap;
/* Ensure consistent height matching line numbers */
line-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
min-height: calc(v-bind(LINE_HEIGHT_PX) * 1px);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
overflow: hidden;

transition: background-color 0.1s;
max-width: 100%;
}

/* Highlighted lines in code content - extend full width with negative margin */
.code-content :deep(.line.highlighted) {
.code-content:deep(.line.highlighted) {
@apply bg-yellow-500/20;
margin: 0 -1rem;
padding: 0 1rem;
}

/* Clickable import links */
.code-content :deep(.import-link) {
.code-content:deep(.import-link) {
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
Expand All @@ -175,7 +208,7 @@ watch(
cursor: pointer;
}

.code-content :deep(.import-link:hover) {
.code-content:deep(.import-link:hover) {
text-decoration-style: solid;
text-decoration-color: #9ecbff; /* syntax.str - light blue */
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,10 @@ defineOgImageComponent('Default', {
</div>

<!-- Main content: file tree + file viewer -->
<div v-else-if="fileTree" class="flex flex-1" dir="ltr">
<div v-else-if="fileTree" class="main-content flex flex-1" dir="ltr">
<!-- File tree sidebar - sticky with internal scroll -->
<aside
class="w-64 lg:w-72 border-ie border-border shrink-0 hidden md:block bg-bg-subtle sticky top-25 self-start h-[calc(100vh-7rem)] overflow-y-auto"
class="file-tree border-ie border-border shrink-0 hidden md:block bg-bg-subtle sticky top-25 self-start h-[calc(100vh-7rem)] overflow-y-auto"
>
<CodeFileTree
:tree="fileTree.tree"
Expand All @@ -361,7 +361,7 @@ defineOgImageComponent('Default', {
</aside>

<!-- File content / Directory listing - sticky with internal scroll on desktop -->
<div class="flex-1 min-w-0 self-start">
<div class="file-viewer flex-1 min-w-0 self-start">
<div
class="sticky z-10 top-25 bg-bg border-b border-border px-4 py-2 flex items-center justify-between gap-2 text-nowrap overflow-x-auto max-w-full"
>
Expand Down Expand Up @@ -584,3 +584,21 @@ defineOgImageComponent('Default', {
</ClientOnly>
</main>
</template>

<style scoped>
.main-content {
--sidebar-space: calc(var(--spacing) * 64);
}
@screen lg {
.main-content {
--sidebar-space: calc(var(--spacing) * 72);
}
}

.file-tree {
width: var(--sidebar-space);
}
.file-viewer {
width: calc(100% - var(--sidebar-space));
}
</style>
12 changes: 4 additions & 8 deletions app/utils/chart-data-buckets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,13 @@ export function buildWeeklyEvolution(
if (sorted.length === 0) return []

const rangeStartDate = parseIsoDate(rangeStartIso)
const rangeEndDate = parseIsoDate(rangeEndIso)

// Align from last day with actual data (npm has 1-2 day delay, today is incomplete)
const lastNonZero = sorted.findLast(d => d.value > 0)
const pickerEnd = parseIsoDate(rangeEndIso)
const effectiveEnd = lastNonZero ? parseIsoDate(lastNonZero.day) : pickerEnd
const rangeEndDate = effectiveEnd.getTime() < pickerEnd.getTime() ? effectiveEnd : pickerEnd

// Group into 7-day buckets from END backwards
const buckets = new Map<number, number>()

for (const item of sorted) {
const offset = Math.floor((rangeEndDate.getTime() - parseIsoDate(item.day).getTime()) / DAY_MS)
const itemDate = parseIsoDate(item.day)
const offset = Math.floor((rangeEndDate.getTime() - itemDate.getTime()) / DAY_MS)
if (offset < 0) continue
const idx = Math.floor(offset / 7)
buckets.set(idx, (buckets.get(idx) ?? 0) + item.value)
Expand Down
12 changes: 8 additions & 4 deletions test/unit/app/utils/chart-data-buckets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('buildWeeklyEvolution', () => {
expect(result[1]!.weekEnd).toBe('2025-03-10')
})

it('aligns from last non-zero data day, ignoring trailing zeros', () => {
it('always aligns from rangeEnd, even with trailing zeros', () => {
const daily = [
{ day: '2025-03-01', value: 10 },
{ day: '2025-03-02', value: 10 },
Expand All @@ -99,10 +99,14 @@ describe('buildWeeklyEvolution', () => {

const result = buildWeeklyEvolution(daily, '2025-03-01', '2025-03-09')

expect(result).toHaveLength(1)
expect(result[0]!.value).toBe(70)
// Bucket 0: 03-03..03-09 = 50, Bucket 1: 03-01..03-02 (partial, scaled)
expect(result).toHaveLength(2)
expect(result[0]!.weekStart).toBe('2025-03-01')
expect(result[0]!.weekEnd).toBe('2025-03-07')
expect(result[0]!.weekEnd).toBe('2025-03-02')
expect(result[0]!.value).toBe(Math.round((20 * 7) / 2))
expect(result[1]!.weekStart).toBe('2025-03-03')
expect(result[1]!.weekEnd).toBe('2025-03-09')
expect(result[1]!.value).toBe(50)
})

it('returns empty array for empty input', () => {
Expand Down
Loading