1
0
forked from wrenn/wrenn

Add syntax highlighting to file browser, harden capsules list

File browser:
- Add shiki-based syntax highlighting (lazy-loaded, zero initial bundle
  impact) with support for 30+ languages
- Cap highlighting at 2000 lines to avoid freezing on large files
- Pre-compute preview lines as derived state instead of re-splitting
  on every render
- Add content-visibility: auto on code lines for off-screen skip
- Remove per-line CSS transitions (unnecessary paint on 5000 elements)
- Cap row entrance animations to first 30 entries

Capsules list:
- Pause auto-refresh polling when browser tab is hidden
- Add empty state for search with no results
- Fix error state not clearing on successful refresh
- Fix action menu positioning near viewport edges
- Disable create button when no template selected
This commit is contained in:
2026-04-11 07:49:11 +06:00
parent 430fb9e70e
commit 26917d432d
6 changed files with 633 additions and 23 deletions

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
type Props = {
@ -292,7 +291,7 @@
</button>
<button
onclick={handleCreate}
disabled={creating}
disabled={creating || !templateQuery.trim()}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if creating}

View File

@ -8,6 +8,7 @@
formatFileSize,
type FileEntry,
} from '$lib/api/files';
import { tokenize, type ThemedToken } from '$lib/highlight';
type Props = {
sandboxId: string;
@ -29,17 +30,33 @@
let fileError = $state<string | null>(null);
let downloading = $state(false);
// Syntax highlighting (lazy — loaded on first use)
let highlightedTokens = $state<ThemedToken[][] | null>(null);
// Request generation counters — discard stale responses from rapid clicks
let dirGeneration = 0;
let fileGeneration = 0;
const MAX_PREVIEW_LINES = 5000;
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
// Path input
let pathInput = $state('~');
let pathInputFocused = $state(false);
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
// Pre-computed preview lines — avoids re-splitting on every render
const previewLines = $derived.by(() => {
if (!fileContent) return { lines: [] as string[], truncated: false, totalLines: 0 };
const allLines = fileContent.split('\n');
const truncated = allLines.length > MAX_PREVIEW_LINES;
return {
lines: truncated ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines,
truncated,
totalLines: allLines.length,
};
});
// Sorted entries: directories first, then files, alphabetical within each group
const sortedEntries = $derived(
[...entries].sort((a, b) => {
@ -71,6 +88,7 @@
selectedFile = null;
fileContent = null;
fileError = null;
highlightedTokens = null;
await loadDir();
}
@ -131,6 +149,7 @@
selectedFile = entry;
fileContent = null;
fileError = null;
highlightedTokens = null;
// Check if we should preview or prompt download
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
@ -147,6 +166,14 @@
fileContent = null;
} else {
fileContent = result.data;
// Kick off highlighting in the background — preview shows plain text immediately.
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
: result.data;
tokenize(linesToHighlight, entry.name).then((tokens) => {
if (gen === fileGeneration) highlightedTokens = tokens;
});
}
} else {
fileError = result.error;
@ -252,6 +279,71 @@
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Extension → color mapping for file icons and badges
function extColor(name: string): string {
const ext = fileExt(name);
switch (ext) {
case 'go': case 'mod': case 'sum':
return '#5a9fd4'; // blue — Go
case 'py': case 'pyi': case 'pyx':
return '#d4a73c'; // amber — Python
case 'js': case 'mjs': case 'cjs':
return '#d4a73c'; // amber — JavaScript
case 'ts': case 'mts': case 'cts': case 'tsx': case 'jsx':
return '#5a9fd4'; // blue — TypeScript/React
case 'rs':
return '#cf8172'; // red — Rust
case 'sh': case 'bash': case 'zsh': case 'fish':
return '#5e8c58'; // accent — shell
case 'json': case 'yaml': case 'yml': case 'toml': case 'ini': case 'env':
return '#8b7ec8'; // purple — config
case 'md': case 'mdx': case 'txt': case 'rst':
return 'var(--color-text-secondary)'; // neutral — docs
case 'sql':
return '#5a9fd4'; // blue — SQL
case 'proto':
return '#5e8c58'; // accent — protobuf
case 'svelte': case 'vue':
return '#cf8172'; // red — Svelte/Vue
case 'css': case 'scss': case 'less':
return '#5a9fd4'; // blue — styles
case 'html': case 'htm':
return '#cf8172'; // red — HTML
case 'dockerfile': case 'makefile':
return '#5e8c58'; // accent — build
default:
return 'var(--color-text-muted)';
}
}
// Descriptive label for file type badge in preview header
function extLabel(name: string): string {
const ext = fileExt(name);
const lower = name.toLowerCase();
if (lower === 'makefile') return 'Make';
if (lower === 'dockerfile') return 'Docker';
switch (ext) {
case 'go': return 'Go';
case 'py': return 'Python';
case 'js': case 'mjs': case 'cjs': return 'JS';
case 'ts': case 'mts': case 'cts': return 'TS';
case 'tsx': return 'TSX';
case 'jsx': return 'JSX';
case 'rs': return 'Rust';
case 'sh': case 'bash': return 'Shell';
case 'json': return 'JSON';
case 'yaml': case 'yml': return 'YAML';
case 'toml': return 'TOML';
case 'sql': return 'SQL';
case 'proto': return 'Proto';
case 'svelte': return 'Svelte';
case 'css': return 'CSS';
case 'html': case 'htm': return 'HTML';
case 'md': case 'mdx': return 'Markdown';
default: return ext ? ext.toUpperCase() : '';
}
}
// Load initial directory on mount, falling back to / if home can't be resolved
let hasInitiallyLoaded = false;
$effect(() => {
@ -277,10 +369,11 @@
}
.file-row.active {
background-color: var(--color-accent-glow);
border-left: 2px solid var(--color-accent);
border-left: 3px solid var(--color-accent);
box-shadow: inset 0 0 20px rgba(94, 140, 88, 0.06);
}
.file-row:not(.active) {
border-left: 2px solid transparent;
border-left: 3px solid transparent;
}
.preview-code {
@ -288,6 +381,12 @@
-moz-tab-size: 4;
}
/* Let the browser skip rendering off-screen lines in long files */
.code-line {
content-visibility: auto;
contain-intrinsic-size: auto 1.65rem;
}
/* Staggered row entrance */
@keyframes rowSlideIn {
from { opacity: 0; transform: translateX(-4px); }
@ -436,9 +535,10 @@
{#each sortedEntries as entry, idx (entry.path)}
<button
onclick={() => selectFile(entry)}
class="file-row row-enter flex w-full items-center gap-3 px-4 py-[7px] text-left
{selectedFile?.path === entry.path ? 'active' : ''}"
style="animation-delay: {Math.min(idx * 12, 200)}ms"
class="file-row flex w-full items-center gap-3 px-4 py-[7px] text-left
{selectedFile?.path === entry.path ? 'active' : ''}
{idx < 30 ? 'row-enter' : ''}"
style={idx < 30 ? `animation-delay: ${idx * 12}ms` : undefined}
>
<!-- Icon -->
{#if fileIcon(entry) === 'dir'}
@ -451,7 +551,7 @@
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{:else}
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="shrink-0" style="color: {extColor(entry.name)}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
@ -461,7 +561,7 @@
<div class="flex flex-1 items-center gap-2 overflow-hidden">
<span class="truncate font-mono text-meta
{entry.type === 'directory'
? 'text-[var(--color-text-primary)]'
? 'text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)]'}">
{entry.name}
</span>
@ -472,8 +572,13 @@
{/if}
</div>
<!-- Size (files only) -->
<!-- Size + extension hint (files only) -->
{#if entry.type === 'file'}
{#if fileExt(entry.name)}
<span class="shrink-0 font-mono text-[9px] uppercase tracking-[0.05em]" style="color: {extColor(entry.name)}; opacity: 0.7">
{fileExt(entry.name)}
</span>
{/if}
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
{formatFileSize(entry.size)}
</span>
@ -533,15 +638,18 @@
<polyline points="14 2 14 8 20 8" />
</svg>
{:else}
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="shrink-0" style="color: {extColor(selectedFile.name)}" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/if}
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
{#if fileExt(selectedFile.name)}
<span class="shrink-0 rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-badge uppercase text-[var(--color-text-muted)]">
{fileExt(selectedFile.name)}
{#if extLabel(selectedFile.name)}
<span
class="shrink-0 rounded-[3px] border px-1.5 py-0.5 font-mono text-badge font-semibold uppercase tracking-[0.03em]"
style="color: {extColor(selectedFile.name)}; border-color: color-mix(in srgb, {extColor(selectedFile.name)} 25%, transparent); background: color-mix(in srgb, {extColor(selectedFile.name)} 8%, transparent)"
>
{extLabel(selectedFile.name)}
</span>
{/if}
</div>
@ -632,16 +740,13 @@
</div>
{:else if fileContent !== null}
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
{@const allLines = fileContent.split('\n')}
{@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines}
{@const truncated = allLines.length > MAX_PREVIEW_LINES}
<div style="animation: fadeUp 0.15s ease both">
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
<pre class="preview-code p-0 m-0"><code class="block">{#each previewLines.lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)]">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem]">{#if highlightedTokens && highlightedTokens[i]}{#each highlightedTokens[i] as token}<span style="color: {token.color ?? 'var(--color-text-secondary)'}">{token.content}</span>{/each}{:else}<span class="text-[var(--color-text-secondary)]">{line || ' '}</span>{/if}</span></div>{/each}</code></pre>
</div>
{#if truncated}
{#if previewLines.truncated}
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
<span class="text-meta text-[var(--color-text-tertiary)]">
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {previewLines.totalLines.toLocaleString()} lines
</span>
<button
onclick={handleDownload}

View File

@ -0,0 +1,128 @@
/**
* Lazy syntax highlighting via shiki.
*
* The highlighter WASM engine + theme are loaded on first use.
* Language grammars load on-demand per extension.
* All imports are dynamic so nothing touches the main bundle.
*/
import type { HighlighterGeneric, ThemedToken } from 'shiki';
export type { ThemedToken };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let highlighter: HighlighterGeneric<any, any> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
const THEME = 'vesper';
// Extensions → shiki language IDs.
// Only map what we expect users to encounter in sandboxes.
const EXT_TO_LANG: Record<string, string> = {
// Go
go: 'go', mod: 'go', sum: 'go',
// Python
py: 'python', pyi: 'python', pyx: 'python',
// JavaScript / TypeScript
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
// Rust
rs: 'rust',
// Shell
sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript',
// Config
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
env: 'shellscript',
// Markup / docs
md: 'markdown', mdx: 'mdx', html: 'html', htm: 'html', xml: 'xml',
// CSS
css: 'css', scss: 'scss', less: 'less',
// SQL
sql: 'sql',
// Svelte / Vue
svelte: 'svelte', vue: 'vue',
// Docker / Make
dockerfile: 'dockerfile',
makefile: 'makefile',
// Proto
proto: 'protobuf',
// C / C++
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
// Java / Kotlin
java: 'java', kt: 'kotlin', kts: 'kotlin',
// Ruby
rb: 'ruby',
// PHP
php: 'php',
// Lua
lua: 'lua',
// Misc
txt: 'plaintext',
};
// Filenames without extensions
const NAME_TO_LANG: Record<string, string> = {
Dockerfile: 'dockerfile',
Makefile: 'makefile',
Containerfile: 'dockerfile',
Vagrantfile: 'ruby',
};
/** Resolve a filename to a shiki language ID, or null if unknown. */
export function langFromFilename(name: string): string | null {
// Check full filename first (Dockerfile, Makefile, etc.)
const basename = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
if (NAME_TO_LANG[basename]) return NAME_TO_LANG[basename];
const dot = basename.lastIndexOf('.');
if (dot <= 0) return null;
const ext = basename.slice(dot + 1).toLowerCase();
return EXT_TO_LANG[ext] ?? null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
if (highlighter) return highlighter;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
const { createHighlighter } = await import('shiki');
const h = await createHighlighter({
themes: [THEME],
langs: [], // load languages on demand
});
highlighter = h;
return h;
})();
return loadingPromise;
}
/**
* Tokenize code for a given language.
* Returns an array of lines, each containing themed tokens with `color` and `content`.
* Returns null if the language is unknown or highlighting fails.
*/
export async function tokenize(
code: string,
filename: string,
): Promise<ThemedToken[][] | null> {
const lang = langFromFilename(filename);
if (!lang || lang === 'plaintext') return null;
try {
const h = await getHighlighter();
// Load grammar on demand if not yet loaded
const loaded = h.getLoadedLanguages();
if (!loaded.includes(lang)) {
await h.loadLanguage(lang);
}
return h.codeToTokensBase(code, { lang, theme: THEME });
} catch {
// Grammar not available or other error — fall back to plain text
return null;
}
}

View File

@ -132,6 +132,9 @@
const result = await listCapsules();
if (result.ok) {
capsules = result.data;
error = null;
} else {
error = result.error;
}
loading = false;
@ -236,10 +239,23 @@
}
}
function handleVisibility() {
if (document.hidden) {
stopAutoRefresh();
} else if (autoRefresh) {
fetchCapsules();
startAutoRefresh();
}
}
onMount(() => {
fetchCapsules();
startAutoRefresh();
return () => stopAutoRefresh();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stopAutoRefresh();
document.removeEventListener('visibilitychange', handleVisibility);
};
});
</script>
@ -376,7 +392,31 @@
Loading capsules...
</div>
</div>
{:else if filteredCapsules.length === 0 && searchQuery}
<!-- No search results -->
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-3)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
</div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
No matching capsules
</p>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
No capsules match "<span class="font-mono text-[var(--color-text-secondary)]">{searchQuery}</span>". Try a different ID.
</p>
<button
onclick={() => { searchQuery = ''; }}
class="mt-4 rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]"
>
Clear search
</button>
</div>
{:else if filteredCapsules.length === 0}
<!-- No capsules at all -->
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="absolute inset-0 -m-4 rounded-full" style="background: radial-gradient(circle, rgba(94,140,88,0.08) 0%, transparent 70%)"></div>
@ -480,7 +520,13 @@
openMenuId = null;
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
menuPos = { top: rect.bottom + 4, left: rect.right - 180 };
const menuW = 180;
const menuH = 140; // approximate max menu height
const top = rect.bottom + 4 + menuH > window.innerHeight
? rect.top - menuH - 4
: rect.bottom + 4;
const left = Math.max(8, Math.min(rect.right - menuW, window.innerWidth - menuW - 8));
menuPos = { top, left };
openMenuId = capsule.id;
}
}}