Skip to content
Draft
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
127 changes: 78 additions & 49 deletions app/components/Code/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,59 @@ const emit = defineEmits<{

const codeRef = useTemplateRef('codeRef')

// Generate line numbers array
const lineNumbers = computed(() => {
return Array.from({ length: props.lines }, (_, i) => i + 1)
})
const LINE_HEIGHT_PX = 24
const lineMultipliers = ref<number[]>([])

// Used for CSS calculation of line number column width
const lineDigits = computed(() => {
return String(props.lines).length
})
function updateLineMultipliers() {
if (!codeRef.value) return
const lines = codeRef.value.querySelectorAll<HTMLElement>('code > .line')
const result: number[] = Array.from({ length: lines.length })
for (let i = 0; i < lines.length; i++)
result[i] = Math.max(1, Math.round(lines[i]!.offsetHeight / LINE_HEIGHT_PX))
lineMultipliers.value = result
}

watch(
() => props.html,
() => nextTick(updateLineMultipliers),
{ immediate: true },
)
useResizeObserver(codeRef, updateLineMultipliers)

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

// Check if a line is selected
function isLineSelected(lineNum: number): boolean {
if (!props.selectedLines) return false
return lineNum >= props.selectedLines.start && lineNum <= props.selectedLines.end
}

// Handle line number click
function onLineClick(lineNum: number, event: MouseEvent) {
emit('lineClick', lineNum, event)
const lineNumbersHtml = computed(() => {
const multipliers = lineMultipliers.value
const total = props.lines
const parts: string[] = []

for (let i = 0; i < total; i++) {
const num = i + 1
const cls = isLineSelected(num)
? 'bg-yellow-500/20 text-fg'
: 'text-fg-subtle hover:text-fg-muted'
parts.push(
`<a id="L${num}" href="#L${num}" tabindex="-1" class="line-number block px-3 py-0 font-mono text-sm leading-6 cursor-pointer transition-colors no-underline ${cls}" data-line="${num}">${num}</a>`,
)

const extra = (multipliers[i] ?? 1) - 1
for (let j = 0; j < extra; j++) parts.push('<span class="block px-3 leading-6">\u00a0</span>')
}

return parts.join('')
})

function onLineNumberClick(event: MouseEvent) {
const target = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[data-line]')
if (!target) return
event.preventDefault()
const lineNum = Number(target.dataset.line)
if (lineNum) emit('lineClick', lineNum, event)
}

// Apply highlighting to code lines when selection changes
Expand Down Expand Up @@ -86,36 +120,21 @@ watch(
</script>

<template>
<div class="code-viewer flex min-h-full max-w-full">
<!-- Line numbers column -->
<div class="code-viewer flex min-h-full max-w-full" :style="{ '--line-digits': lineDigits }">
<!-- Line numbers column — raw HTML + event delegation to avoid v-for overhead on large files -->
<!-- eslint-disable vue/no-v-html -->
<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>
</div>
v-html="lineNumbersHtml"
@click="onLineNumberClick"
/>
<!-- eslint-enable vue/no-v-html -->

<!-- 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 +143,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 {
flex: 1;
min-width: 0;
max-width: calc(100% - var(--line-numbers-width));
}

.code-content :deep(pre) {
.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 +175 to 182
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 +204,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)
Comment on lines +37 to +43
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these changes intended in this PR?

Copy link
Author

Choose a reason for hiding this comment

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

They are not, a mistake on my part for merging latest instead of rebasing :)

I will have to remove them. Don't think this PR will end up being merged to be honest, given the above conversation

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