Skip to content

Commit 3500327

Browse files
committed
feat(site): CsvViewer, NotebookViewer, syntax highlighting
Site-side implementations for the lib's new renderer slots: - `CsvViewer` — range-paginated 256 KB chunks, sticky-header table. Header probed once from the first 32 KB; body pages drop the partial first/last lines so rows never split across chunk boundaries. - `NotebookViewer` — parses .ipynb cells, renders markdown via `renderMarkdown`, code in a numbered block, and outputs (stream, execute_result, display_data with text/html/text-plain/image-png/ image-jpeg/image-svg, error w/ ANSI-stripped traceback). - `CodeHighlight` — `highlight.js` with explicit language imports (bash, c, cpp, css, go, html, ini, java, js, jsx, python, ruby, rust, scss, sql, ts, tsx, yaml, toml) + the github-dark stylesheet. Pre-registered languages stay cheap (~30 KB) vs autoload. Wired into `MockDemo`, `HttpDemo`, and `BucketBrowser`.
1 parent 09c2d8e commit 3500327

8 files changed

Lines changed: 412 additions & 0 deletions

File tree

site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@rdub/file-tree": "link:..",
14+
"highlight.js": "^11.11.1",
1415
"hyparquet": "^1.25.8",
1516
"react": "^18.3.1",
1617
"react-dom": "^18.3.1",

site/pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/CodeHighlight.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/** Syntax highlighting via highlight.js. Wired as
2+
* `<FileTree codeRenderer={renderCode}>`. The lib calls this with
3+
* `(source, lang)` where `lang` is the language id from `CODE_LANG`. */
4+
import hljs from 'highlight.js/lib/core'
5+
import bash from 'highlight.js/lib/languages/bash'
6+
import c from 'highlight.js/lib/languages/c'
7+
import cpp from 'highlight.js/lib/languages/cpp'
8+
import css from 'highlight.js/lib/languages/css'
9+
import go from 'highlight.js/lib/languages/go'
10+
import ini from 'highlight.js/lib/languages/ini'
11+
import java from 'highlight.js/lib/languages/java'
12+
import javascript from 'highlight.js/lib/languages/javascript'
13+
import python from 'highlight.js/lib/languages/python'
14+
import ruby from 'highlight.js/lib/languages/ruby'
15+
import rust from 'highlight.js/lib/languages/rust'
16+
import scss from 'highlight.js/lib/languages/scss'
17+
import sql from 'highlight.js/lib/languages/sql'
18+
import typescript from 'highlight.js/lib/languages/typescript'
19+
import xml from 'highlight.js/lib/languages/xml'
20+
import yaml from 'highlight.js/lib/languages/yaml'
21+
import 'highlight.js/styles/github-dark.css'
22+
23+
hljs.registerLanguage('bash', bash)
24+
hljs.registerLanguage('c', c)
25+
hljs.registerLanguage('cpp', cpp)
26+
hljs.registerLanguage('css', css)
27+
hljs.registerLanguage('go', go)
28+
hljs.registerLanguage('html', xml)
29+
hljs.registerLanguage('ini', ini)
30+
hljs.registerLanguage('java', java)
31+
hljs.registerLanguage('javascript', javascript)
32+
hljs.registerLanguage('jsx', javascript)
33+
hljs.registerLanguage('python', python)
34+
hljs.registerLanguage('ruby', ruby)
35+
hljs.registerLanguage('rust', rust)
36+
hljs.registerLanguage('scss', scss)
37+
hljs.registerLanguage('sql', sql)
38+
hljs.registerLanguage('toml', ini) // close enough for highlighting
39+
hljs.registerLanguage('typescript', typescript)
40+
hljs.registerLanguage('tsx', typescript)
41+
hljs.registerLanguage('yaml', yaml)
42+
43+
export function renderCode(source: string, lang: string) {
44+
const html = hljs.getLanguage(lang)
45+
? hljs.highlight(source, { language: lang, ignoreIllegals: true }).value
46+
: hljs.highlightAuto(source).value
47+
return (
48+
<pre style={{
49+
background: 'rgba(127,127,127,0.08)',
50+
padding: '0.6em 0.8em',
51+
borderRadius: 4,
52+
overflow: 'auto',
53+
maxHeight: '80vh',
54+
fontSize: '0.85em',
55+
fontFamily: 'ui-monospace, monospace',
56+
margin: 0,
57+
}}>
58+
<code className={`hljs language-${lang}`} dangerouslySetInnerHTML={{ __html: html }} />
59+
</pre>
60+
)
61+
}

site/src/CsvViewer.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/** Range-paginated CSV/TSV table. Header is fetched once on mount;
2+
* body pages are independent 256 KB range-reads from the underlying
3+
* `Store`. Drops the partial first/last line on each page to avoid
4+
* splitting rows across chunk boundaries.
5+
*
6+
* Wired as `<FileTree csvRenderer={CsvViewer}>`. Doesn't handle multi-
7+
* line quoted fields (a quote opening on one line and closing on the
8+
* next) — those would need a streaming parser since byte-paginated
9+
* chunks can split mid-row. */
10+
import { useEffect, useState } from 'react'
11+
import type { Store } from '@rdub/file-tree'
12+
import { fmtSize } from '@rdub/file-tree/react'
13+
14+
const PAGE_BYTES = 256 * 1024
15+
const HEADER_PROBE_BYTES = 32 * 1024
16+
17+
export function CsvViewer({ store, path, delimiter }: { store: Store; path: string; delimiter: string }) {
18+
const [total, setTotal] = useState<number | null>(null)
19+
const [header, setHeader] = useState<string[] | null>(null)
20+
const [page, setPage] = useState(0)
21+
const [rows, setRows] = useState<string[][] | null>(null)
22+
const [error, setError] = useState<string | null>(null)
23+
24+
useEffect(() => {
25+
let cancelled = false
26+
setTotal(null); setHeader(null); setRows(null); setError(null); setPage(0)
27+
store.get(path, { offset: 0, length: HEADER_PROBE_BYTES }).then(r => {
28+
if (cancelled) return
29+
const text = new TextDecoder().decode(r.bytes)
30+
const nl = text.indexOf('\n')
31+
if (nl < 0) { setError(`no newline in first ${HEADER_PROBE_BYTES} bytes — not a CSV?`); return }
32+
setHeader(parseLine(text.slice(0, nl).replace(/\r$/, ''), delimiter))
33+
const ts = r.totalSize
34+
if (ts == null) { setError('CSV viewer needs total file size; store did not report it'); return }
35+
setTotal(ts)
36+
}).catch(e => {
37+
if (!cancelled) setError(String(e))
38+
})
39+
return () => { cancelled = true }
40+
}, [store, path, delimiter])
41+
42+
useEffect(() => {
43+
if (total === null || header === null) return
44+
let cancelled = false
45+
setRows(null)
46+
const offset = page * PAGE_BYTES
47+
const length = Math.min(PAGE_BYTES, total - offset)
48+
if (length <= 0) { setRows([]); return }
49+
store.get(path, { offset, length }).then(r => {
50+
if (cancelled) return
51+
const text = new TextDecoder().decode(r.bytes)
52+
let lines = text.split('\n')
53+
// Page 0: drop header line. Subsequent pages: drop partial first line.
54+
lines = lines.slice(1)
55+
// Drop partial last line unless we're at EOF.
56+
const atEof = offset + length >= total
57+
if (!atEof && lines.length > 0) lines = lines.slice(0, -1)
58+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
59+
setRows(lines.map(line => parseLine(line.replace(/\r$/, ''), delimiter)))
60+
}).catch(e => {
61+
if (!cancelled) setError(String(e))
62+
})
63+
return () => { cancelled = true }
64+
}, [store, path, delimiter, page, total, header])
65+
66+
if (error) return <div style={{ color: 'salmon' }}>error: {error}</div>
67+
if (total === null || header === null) return <div style={{ opacity: 0.6 }}>reading CSV header…</div>
68+
69+
const pages = Math.max(1, Math.ceil(total / PAGE_BYTES))
70+
const offsetStart = page * PAGE_BYTES
71+
const offsetEnd = Math.min(total, offsetStart + PAGE_BYTES)
72+
73+
return (
74+
<>
75+
<p style={{ opacity: 0.7, fontSize: '0.95em', margin: '0 0 0.6em' }}>
76+
<b>{header.length}</b> columns · {fmtSize(total)}
77+
</p>
78+
{pages > 1 && (
79+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5em', margin: '0.4em 0', fontSize: '0.9em', flexWrap: 'wrap' }}>
80+
<button disabled={page === 0} onClick={() => setPage(0)}>«</button>
81+
<button disabled={page === 0} onClick={() => setPage(page - 1)}></button>
82+
<span style={{ opacity: 0.8 }}>
83+
page <b>{page + 1}</b> / {pages.toLocaleString()} · bytes {offsetStart.toLocaleString()}{offsetEnd.toLocaleString()} / {total.toLocaleString()}
84+
</span>
85+
<button disabled={page === pages - 1} onClick={() => setPage(page + 1)}></button>
86+
<button disabled={page === pages - 1} onClick={() => setPage(pages - 1)}>»</button>
87+
</div>
88+
)}
89+
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto', border: '1px solid rgba(127,127,127,0.3)', borderRadius: 4 }}>
90+
<table style={{ borderCollapse: 'collapse', fontSize: '0.82em', fontFamily: 'ui-monospace, monospace' }}>
91+
<thead>
92+
<tr style={{ position: 'sticky', top: 0, background: 'var(--bg, #181818)' }}>
93+
{header.map((c, i) => (
94+
<th key={i} style={{ padding: '0.3em 0.6em', textAlign: 'left', borderBottom: '1px solid rgba(127,127,127,0.4)', fontWeight: 500, whiteSpace: 'nowrap' }}>
95+
{c}
96+
</th>
97+
))}
98+
</tr>
99+
</thead>
100+
<tbody>
101+
{rows === null ? (
102+
<tr><td colSpan={header.length} style={{ padding: '0.5em', opacity: 0.6 }}>loading…</td></tr>
103+
) : (
104+
rows.map((r, i) => (
105+
<tr key={i} style={{ borderTop: '1px solid rgba(127,127,127,0.15)' }}>
106+
{header.map((_, j) => (
107+
<td key={j} style={{ padding: '0.2em 0.6em', whiteSpace: 'nowrap', maxWidth: '30em', overflow: 'hidden', textOverflow: 'ellipsis' }}>
108+
{r[j] ?? ''}
109+
</td>
110+
))}
111+
</tr>
112+
))
113+
)}
114+
</tbody>
115+
</table>
116+
</div>
117+
</>
118+
)
119+
}
120+
121+
/** Minimal CSV/TSV line parser. Handles quoted fields with embedded
122+
* delimiters and escaped quotes (`""` → `"`). Does NOT handle
123+
* multi-line quoted fields (rare; would need a streaming parser). */
124+
function parseLine(line: string, delimiter: string): string[] {
125+
const out: string[] = []
126+
let cur = ''
127+
let inQuotes = false
128+
let i = 0
129+
while (i < line.length) {
130+
const c = line[i]
131+
if (inQuotes) {
132+
if (c === '"') {
133+
if (line[i + 1] === '"') { cur += '"'; i += 2; continue }
134+
inQuotes = false
135+
i++
136+
} else {
137+
cur += c
138+
i++
139+
}
140+
} else {
141+
if (c === delimiter) { out.push(cur); cur = ''; i++ }
142+
else if (c === '"' && cur === '') { inQuotes = true; i++ }
143+
else { cur += c; i++ }
144+
}
145+
}
146+
out.push(cur)
147+
return out
148+
}

0 commit comments

Comments
 (0)