From c0533273f602ebbcc0372218184c3bbbae8b4051 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Wed, 2 Sep 2026 19:40:55 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20JSON=20viewing=20=E2=80=94=20collap?= =?UTF-8?q?sible,=20syntax-highlighted=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .json files now appear in the file tree and render client-side as a collapsible, syntax-highlighted tree via a new viewers.js viewer: - object keys / array indices / typed primitives (string/number/boolean/null) are colour-coded; objects and arrays fold via a ▾/▸ toggle. - no external library (unlike the pdf/docx/xlsx/pptx viewers) — decode + parse happen in-browser; invalid JSON falls back to raw text (best-effort, per the viewer contract). - tree.go isViewable now lists .json; server serves it as application/json. Tests: 6 new node:test cases (registry + pure jsonPrimitiveText + jsonNode DOM structure via a stub) and a tree_test.go case; runtime-verified end to end (/api/tree lists .json, /api/file serves application/json). --- CHANGELOG.md | 10 +++ internal/server/server.go | 2 + internal/server/tree.go | 9 +-- internal/server/tree_test.go | 19 ++++++ web/app.css | 24 +++++++ web/viewers.js | 128 +++++++++++++++++++++++++++++++++++ web/viewers.test.js | 63 ++++++++++++++++- 7 files changed, 250 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b337055..3da43e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to reefdoc are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **JSON viewing**: `.json` files now appear in the file-tree navigator and + render as a collapsible, syntax-highlighted tree (object keys, array indices, + and typed primitives are colour-coded; objects/arrays fold via a ▾/▸ toggle). + No external library — the viewer decodes and parses the file client-side, and + falls back to raw text for invalid JSON. Files are served with + `Content-Type: application/json`. + ## [0.15.0] - 2026-08-01 ### Added diff --git a/internal/server/server.go b/internal/server/server.go index 644a561..e5e3600 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -185,6 +185,8 @@ func contentType(path string) string { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case ".json": + return "application/json; charset=utf-8" default: return "text/plain; charset=utf-8" } diff --git a/internal/server/tree.go b/internal/server/tree.go index 4d0467e..e898360 100644 --- a/internal/server/tree.go +++ b/internal/server/tree.go @@ -37,15 +37,16 @@ func isMedia(name string) bool { // isViewable reports whether a file should appear in the tree: the text // formats reefdoc renders inline, the binary document formats it previews -// client-side (pdf/docx/xlsx/pptx), and the media formats it streams -// (video/image/audio). It also gates live-reload "change" events -// (see watcher.go), so narrowing it affects both tree listing and auto-update. +// client-side (pdf/docx/xlsx/pptx), JSON (rendered as a collapsible tree), and +// the media formats it streams (video/image/audio). It also gates live-reload +// "change" events (see watcher.go), so narrowing it affects both tree listing +// and auto-update. func isViewable(name string) bool { if isMarkdown(name) || isMedia(name) { return true } switch strings.ToLower(filepath.Ext(name)) { - case ".pdf", ".docx", ".xlsx", ".pptx": + case ".pdf", ".docx", ".xlsx", ".pptx", ".json": return true } return false diff --git a/internal/server/tree_test.go b/internal/server/tree_test.go index 21d2ef4..ef7f098 100644 --- a/internal/server/tree_test.go +++ b/internal/server/tree_test.go @@ -212,3 +212,22 @@ func TestListDir_ListsMediaFiles(t *testing.T) { t.Fatalf("got %v want %v", names, want) } } + +func TestListDir_ListsJSON(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "config.json")) + writeFile(t, filepath.Join(root, "notes.md")) + + nodes, err := ListDir(root, "") + if err != nil { + t.Fatal(err) + } + var names []string + for _, n := range nodes { + names = append(names, n.Name) + } + want := []string{"config.json", "notes.md"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("got %v want %v", names, want) + } +} diff --git a/web/app.css b/web/app.css index dab4a10..573e031 100644 --- a/web/app.css +++ b/web/app.css @@ -156,3 +156,27 @@ body[data-theme="dark"] .allium-block--contract { border-left-color:#a97ee8; } font-size:15px; line-height:1; cursor:pointer; background:var(--bg); color:var(--fg); border:1px solid var(--border); border-radius:4px; } #download-btn:hover { background:var(--border); } + +/* JSON viewer — collapsible, syntax-highlighted tree (viewers.js viewJson) */ +.json-doc { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size:13px; line-height:1.5; padding:8px 4px; } +.json-children { padding-left:1.4em; border-left:1px solid var(--border); margin-left:.3em; } +.json-line { white-space:pre-wrap; word-break:break-word; } +.json-toggle { display:inline-block; width:1.1em; margin-left:-1.1em; text-align:center; + cursor:pointer; color:var(--muted); user-select:none; } +.json-toggle:hover { color:var(--fg); } +.json-toggle:focus-visible { outline:2px solid var(--accent); border-radius:2px; } +.json-key { color:var(--accent); } +.json-index { color:var(--muted); } +.json-punct { color:var(--muted); } +.json-summary { color:var(--muted); font-style:italic; } +.json-string { color:#0a7d33; } +.json-number { color:#b5670b; } +.json-boolean { color:#8250df; } +.json-null { color:var(--muted); font-style:italic; } +.json-error { color:#c00; font-family:inherit; } +.json-raw { overflow:auto; } +body[data-theme="dark"] .json-string { color:#7ec98f; } +body[data-theme="dark"] .json-number { color:#e0a060; } +body[data-theme="dark"] .json-boolean { color:#c9a6ff; } +body[data-theme="dark"] .json-error { color:#ff8080; } diff --git a/web/viewers.js b/web/viewers.js index 5aa435b..75a9f90 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -30,6 +30,7 @@ const registry = { '.docx': viewDocx, '.xlsx': viewXlsx, '.pptx': viewPptx, + '.json': viewJson, }; // getViewer returns the viewer function for a path, or null for text/unknown. @@ -187,3 +188,130 @@ async function viewPptx(bytes, container) { }); ro.observe(container); } + +// --- JSON (pretty, syntax-highlighted, collapsible) --- +// Not a CDN-backed viewer: JSON needs no library. Decodes the raw bytes, +// parses, and builds a collapsible, syntax-coloured tree. Invalid JSON falls +// back to the raw text (best-effort, per the viewer contract at the top). +async function viewJson(bytes, container) { + const doc = container.ownerDocument; + container.innerHTML = ''; + const text = new TextDecoder('utf-8').decode(bytes); + const wrap = doc.createElement('div'); + wrap.className = 'json-doc'; + container.appendChild(wrap); + + let data; + try { + data = JSON.parse(text); + } catch (err) { + wrap.className = 'json-doc json-invalid'; + const note = doc.createElement('p'); + note.className = 'json-error'; + note.textContent = 'Invalid JSON (' + err.message + ') — showing raw text.'; + const pre = doc.createElement('pre'); + pre.className = 'json-raw'; + pre.textContent = text; + wrap.appendChild(note); + wrap.appendChild(pre); + return; + } + + wrap.appendChild(jsonNode(doc, data, null, false)); +} + +// jsonText makes a with textContent s. +function jsonText(doc, s, cls) { + const el = doc.createElement('span'); + el.className = cls; + el.textContent = s; + return el; +} + +// jsonPrimitiveText maps a JSON primitive to its display class + text. Pure +// (no DOM) so the type/colour mapping is unit-testable on its own. +export function jsonPrimitiveText(value) { + if (value === null) return { cls: 'json-null', text: 'null' }; + switch (typeof value) { + case 'string': return { cls: 'json-string', text: JSON.stringify(value) }; + case 'number': return { cls: 'json-number', text: String(value) }; + case 'boolean': return { cls: 'json-boolean', text: String(value) }; + default: return { cls: 'json-unknown', text: String(value) }; + } +} + +// jsonNode builds the DOM for one JSON value. Objects and arrays render as a +// collapsible block (▾/▸ toggle); primitives as a type-coloured span. keyLabel +// (or null for the root) is the property name / array index shown before the +// value; isIndex distinguishes an array index from an object key. +export function jsonNode(doc, value, keyLabel, isIndex) { + const node = doc.createElement('div'); + node.className = 'json-node'; + const head = doc.createElement('div'); + head.className = 'json-line'; + node.appendChild(head); + + const isArr = Array.isArray(value); + const isObj = value !== null && typeof value === 'object'; + const entries = isObj + ? (isArr ? value.map((v, i) => [String(i), v, true]) : Object.entries(value).map(([k, v]) => [k, v, false])) + : []; + + // Collapse toggle comes first, before the key, for object/array values. + let toggle = null; + if (isObj && entries.length > 0) { + toggle = doc.createElement('span'); + toggle.className = 'json-toggle'; + toggle.setAttribute('role', 'button'); + toggle.tabIndex = 0; + toggle.textContent = '▾'; + head.appendChild(toggle); + } + + if (keyLabel !== null) { + head.appendChild(jsonText(doc, isIndex ? keyLabel : JSON.stringify(keyLabel), isIndex ? 'json-index' : 'json-key')); + head.appendChild(jsonText(doc, ': ', 'json-punct')); + } + + if (!isObj) { + const p = jsonPrimitiveText(value); + head.appendChild(jsonText(doc, p.text, p.cls)); + return node; + } + + const open = isArr ? '[' : '{'; + const close = isArr ? ']' : '}'; + if (entries.length === 0) { + head.appendChild(jsonText(doc, open + close, 'json-punct')); + return node; + } + + head.appendChild(jsonText(doc, open, 'json-punct')); + const summary = jsonText(doc, ' … ' + entries.length + (isArr ? ' items ' : ' keys ') + close, 'json-summary'); + summary.hidden = true; + head.appendChild(summary); + + const kids = doc.createElement('div'); + kids.className = 'json-children'; + for (const [k, v, idx] of entries) kids.appendChild(jsonNode(doc, v, k, idx)); + node.appendChild(kids); + + const closer = doc.createElement('div'); + closer.className = 'json-line json-closer'; + closer.appendChild(jsonText(doc, close, 'json-punct')); + node.appendChild(closer); + + let collapsed = false; + const setCollapsed = (c) => { + collapsed = c; + toggle.textContent = c ? '▸' : '▾'; + kids.hidden = c; + closer.hidden = c; + summary.hidden = !c; + }; + toggle.addEventListener('click', () => setCollapsed(!collapsed)); + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCollapsed(!collapsed); } + }); + return node; +} diff --git a/web/viewers.test.js b/web/viewers.test.js index 7f69b4a..5a99b7a 100644 --- a/web/viewers.test.js +++ b/web/viewers.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { getViewer, isBinaryDoc, mediaKind, isMedia, renderMedia } from './viewers.js'; +import { getViewer, isBinaryDoc, mediaKind, isMedia, renderMedia, jsonPrimitiveText, jsonNode } from './viewers.js'; test('getViewer returns a function for each binary doc type', () => { for (const path of ['a.pdf', 'b.docx', 'c.xlsx', 'd.pptx']) { @@ -97,3 +97,64 @@ test('renderMedia builds an