projview 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # projview
2
2
 
3
3
  a fast, zero-config local viewer for **markdown** + **mermaid** files. run it in
4
- any directory, get a soft-dark cartoonish localhost view of every `.md` / `.mmd`
5
- under it - rendered diagrams, search, live reload - close it and nothing's left
6
- behind.
4
+ any directory, get a quiet localhost view - light or dark, following your OS - of
5
+ every `.md` / `.mmd` (plus `.json` / `.yaml`) under it: rendered diagrams, search,
6
+ live reload. close it and nothing's left behind.
7
7
 
8
8
  that's the product. point it at a docs folder, read comfortably, ctrl-c, gone.
9
9
 
@@ -33,10 +33,13 @@ projview /path/to/notes --no-open
33
33
 
34
34
  | | |
35
35
  | --- | --- |
36
- | tree nav | every `.md` / `.mmd` under the root (skips `node_modules`, `dist`, dot-dirs) |
36
+ | tree nav | every `.md` / `.mmd` / `.json` / `.yml` / `.yaml` under the root (skips `node_modules`, `dist`, dot-dirs, lockfiles) |
37
37
  | rendering | markdown + syntax-highlighted code + **mermaid diagrams** (with pan/zoom) |
38
+ | data files | JSON pretty-printed (literals kept exact), YAML shown as written - both with line numbers + indent guides |
38
39
  | search | `⌘K` palette - fuzzy filenames + full-text content with line snippets |
39
40
  | live reload | edit a file, the browser updates |
41
+ | source editor | `edit` (or press `e`) opens the raw text beside a live preview; `⌘S` saves to disk. local runs only - read-only when exposed on a network |
42
+ | history | the last 5 earlier versions of each file - from any change, not just projview's editor - to view and restore. kept until projview stops, or across runs with `--persist` |
40
43
  | ephemeral | in-memory by default; nothing written to disk, nothing left behind |
41
44
 
42
45
  see [samples/architecture.md](samples/architecture.md) for a mermaid demo.
package/docs/README.md CHANGED
@@ -16,6 +16,9 @@ faults. use this tree while iterating on the tool.
16
16
  | diagrams | [architecture](diagrams/architecture.mmd) | standalone valid flowchart |
17
17
  | diagrams | [sequence](diagrams/sequence.mmd) | standalone valid sequence |
18
18
  | diagrams | [broken diagram](diagrams/broken-diagram.mmd) | **invalid mermaid** - error card test |
19
+ | data | [minified json](data/minified.json) | one-line JSON -> pretty-printed, big int kept exact |
20
+ | data | [not-strict json](data/not-strict.jsonc.json) | JSONC with comments - shown as-is with a notice |
21
+ | data | [values.yaml](data/values.yaml) | helm-style YAML - indent guides, block scalar, multi-doc |
19
22
  | reference | [api](reference/api.md) | long doc - TOC + scroll-spy |
20
23
  | reference | [glossary](reference/glossary.md) | cross-links + external links |
21
24
  | edge | [syntax stress](edge-cases/syntax-stress.md) | gnarly markdown + inline broken fence |
@@ -0,0 +1 @@
1
+ {"name":"lgtm","version":"2.4.1","replicas":3,"bigId":12345678901234567890,"enabled":true,"labels":{"app.kubernetes.io/name":"lgtm","tier":"observability"},"ports":[{"name":"http","port":3000},{"name":"grpc","port":9095}],"empty":{},"tags":[],"note":"commas, colons: and {braces} inside strings stay put"}
@@ -0,0 +1,4 @@
1
+ {
2
+ // comments make this JSONC, not JSON - shown as-is with a note
3
+ "compilerOptions": { "strict": true },
4
+ }
@@ -0,0 +1,36 @@
1
+ # a helm-style values file - indentation is meaning, so it's shown as written
2
+ replicaCount: 2
3
+
4
+ image:
5
+ repository: ghcr.io/example/alloy
6
+ tag: "v1.4.2"
7
+ pullPolicy: IfNotPresent
8
+
9
+ resources:
10
+ limits:
11
+ cpu: 500m
12
+ memory: 512Mi
13
+ requests:
14
+ cpu: 100m
15
+ memory: 128Mi
16
+
17
+ config: |
18
+ logging {
19
+ level = "info"
20
+ }
21
+
22
+ discovery.kubernetes "pods" {
23
+ role = "pod"
24
+ }
25
+
26
+ tolerations:
27
+ - key: dedicated
28
+ operator: Equal
29
+ value: observability
30
+ effect: NoSchedule
31
+ ---
32
+ # second document in the same file
33
+ apiVersion: v1
34
+ kind: ConfigMap
35
+ metadata:
36
+ name: {{ .Release.Name }}-extra
@@ -5,7 +5,8 @@ and scroll-spy (the active section should track as you scroll).
5
5
 
6
6
  ## overview
7
7
 
8
- projview exposes a tiny HTTP surface. It never writes your files - the only
8
+ projview exposes a tiny HTTP surface. The only file it writes is one you save
9
+ from the source editor (`PUT /api/source`, loopback only); otherwise the only
9
10
  state it records is its own usage events (views / searches) into the store.
10
11
 
11
12
  ## endpoints
@@ -20,6 +21,36 @@ flag (false when projview is exposed on a non-loopback host).
20
21
  Renders a single file to HTML on demand. Query param `p` is the relative path.
21
22
  Records a `view` usage event.
22
23
 
24
+ ### GET /api/source · PUT /api/source
25
+
26
+ `GET ?p=<path>` returns `{ path, content, mtime }` - the raw text the editor
27
+ opens. `PUT` saves `{ path, content, mtime, force? }`:
28
+
29
+ | response | when |
30
+ | --- | --- |
31
+ | `200 { path, mtime }` | saved |
32
+ | `400` | not a previewable file, outside the root, or under a hidden dir |
33
+ | `403` | bound to a non-loopback host, or the `Host` header isn't a local name |
34
+ | `409 { mtime }` | the file changed on disk since `mtime` - resend with `force: true` to overwrite |
35
+
36
+ ### POST /api/preview
37
+
38
+ Renders `{ path, content }` - unsaved editor text - exactly as `/api/file` would
39
+ render that file, and returns `{ html }`. Never writes; `400` for a path that
40
+ isn't a previewable type.
41
+
42
+ ### GET /api/versions · GET /api/versions/:id
43
+
44
+ `GET ?p=<path>` lists a file's earlier versions, newest first:
45
+ `{ path, limit, versions: [{ id, file, mtime, replacedAt, size }] }`.
46
+ `GET /api/versions/:id` returns one with its `content`.
47
+
48
+ A version is the text a file had just before it changed - whether the change
49
+ came from projview's editor or anywhere else (an IDE, `git pull`, an agent). The
50
+ newest 5 are kept per file, in the same store as comments: gone when projview
51
+ stops by default, kept across runs with `--persist` or a `--store-url`. Files over
52
+ 5 MB aren't versioned. Restoring is an ordinary `PUT /api/source` of the old text.
53
+
23
54
  ### GET /api/events
24
55
 
25
56
  Server-sent events stream: `change` / `tree` for live reload, `comments` when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projview",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Ephemeral local previewer for markdown + mermaid files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@ drawer.innerHTML =
54
54
  <div class="cmt-composer-slot"></div>
55
55
  <div class="cmt-list"></div>`;
56
56
 
57
- const chip = cre('button', 'cmt-selchip', '💬 comment');
57
+ const chip = cre('button', 'cmt-selchip', 'comment');
58
58
  chip.hidden = true;
59
59
 
60
60
  document.body.append(fab, drawer, chip);
@@ -203,7 +203,7 @@ contentEl.addEventListener('click', (e) => {
203
203
  }
204
204
  });
205
205
 
206
- // A hover "💬" at the end of every heading -> comment on that section.
206
+ // A hover "+ comment" at the end of every heading -> comment on that section.
207
207
  function addHeadingAffordances() {
208
208
  if (!state.writable) return;
209
209
  for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
@@ -301,7 +301,7 @@ function renderComposer() {
301
301
  function renderList() {
302
302
  listEl.replaceChildren();
303
303
  if (!current.length) {
304
- listEl.append(cre('div', 'cmt-empty', '💭<br>no comments yet' + (state.writable ? '<br><small>select text, hover a heading, or “+ note”</small>' : '')));
304
+ listEl.append(cre('div', 'cmt-empty', 'no comments yet' + (state.writable ? '<br><small>select text, hover a heading, or “+ note”</small>' : '')));
305
305
  return;
306
306
  }
307
307
  for (const c of current) {
@@ -377,7 +377,7 @@ function updateBadge() {
377
377
 
378
378
  function anchorLabel(a, quoteOverride) {
379
379
  if (!a) return 'file';
380
- if (a.type === 'file') return '📄 whole file';
380
+ if (a.type === 'file') return 'whole file';
381
381
  if (a.type === 'heading') {
382
382
  const el = document.getElementById(a.id);
383
383
  return '§ ' + (quoteOverride || (el ? el.textContent : a.id));
@@ -0,0 +1,306 @@
1
+ /* source editor: swap the rendered page for its raw text, save back to disk.
2
+ * Loopback-only like comments (state.writable). Saves carry the mtime the file
3
+ * had when it was opened, so a change made elsewhere is never silently clobbered. */
4
+
5
+ import { $, esc } from './dom.js';
6
+ import { state } from './store.js';
7
+ import { openFile } from './viewer.js';
8
+ import { toast } from './toast.js';
9
+ import { runMermaid } from './mermaid.js';
10
+ import { fileKind } from './icons.js';
11
+ import { showHistory } from './history.js';
12
+
13
+ const contentEl = $('content');
14
+ const breadcrumbEl = $('breadcrumb');
15
+
16
+ let session = null; // { path, mtime, original, crlf, textarea, noticeEl } while editing
17
+
18
+ export const isEditing = (p) => !!session && (p === undefined || session.path === p);
19
+ const isDirty = () => !!session && session.textarea.value !== session.original;
20
+
21
+ // Called by the viewer before it navigates away: false keeps the editor open.
22
+ export function confirmLeave() {
23
+ if (!isDirty()) { session = null; document.body.classList.remove('editing'); return true; }
24
+ if (!confirm('discard unsaved changes?')) return false;
25
+ session = null;
26
+ document.body.classList.remove('editing');
27
+ return true;
28
+ }
29
+
30
+ // md / mmd render to something other than their source, so they get a raw view too.
31
+ export const hasRawView = (p) => ['md', 'mmd'].includes(fileKind(p));
32
+
33
+ // Called after every render: the view switcher in the breadcrumb bar.
34
+ // md / mmd: rendered | raw | edit | history. json / yaml (already shown as
35
+ // source): edit | history. "edit" only where this client may write.
36
+ export function onFileShown(p) {
37
+ if (!p) return;
38
+ const views = [...(hasRawView(p) ? ['rendered', 'raw'] : []), ...(state.writable ? ['edit'] : []), 'history'];
39
+ const wrap = document.createElement('div');
40
+ wrap.className = 'view-switch';
41
+ for (const v of views) {
42
+ const btn = document.createElement('button');
43
+ btn.dataset.view = v;
44
+ btn.textContent = v;
45
+ if (v === 'edit') btn.title = 'edit source (e)';
46
+ btn.addEventListener('click', () => switchView(p, v));
47
+ wrap.appendChild(btn);
48
+ }
49
+ breadcrumbEl.appendChild(wrap);
50
+ markView(hasRawView(p) ? state.view : null);
51
+ }
52
+
53
+ function markView(v) {
54
+ for (const b of breadcrumbEl.querySelectorAll('.view-switch button')) b.classList.toggle('on', b.dataset.view === v);
55
+ }
56
+
57
+ function switchView(p, v) {
58
+ if (v === 'edit') { if (!session) startEditing(p); return; }
59
+ if (session && !confirmLeave()) return;
60
+ if (v === 'history') { markView('history'); showHistory(p); return; } // never sticky
61
+ state.view = v;
62
+ openFile(p);
63
+ }
64
+
65
+ // Live reload saw this file change while it was open in the editor.
66
+ export function onDiskChange() {
67
+ if (!session || session.saving) return; // our own save's change event - not a conflict
68
+ showNotice('this file changed on disk since you opened it', [
69
+ ['reload', () => { session.original = session.textarea.value; startEditing(session.path); }],
70
+ ['keep mine', () => hideNotice()]
71
+ ]);
72
+ }
73
+
74
+ async function startEditing(p) {
75
+ let data;
76
+ try {
77
+ const res = await fetch(`/api/source?p=${encodeURIComponent(p)}`);
78
+ if (!res.ok) throw new Error();
79
+ data = await res.json();
80
+ } catch {
81
+ toast(`couldn't load ${p} for editing`);
82
+ return;
83
+ }
84
+ // only a consistently CRLF file is written back as CRLF; a mixed one is saved as LF
85
+ const crlf = data.content.includes('\r\n') && !/(^|[^\r])\n/.test(data.content);
86
+ const text = crlf ? data.content.replace(/\r\n/g, '\n') : data.content;
87
+
88
+ contentEl.innerHTML =
89
+ `<div class="editor">` +
90
+ `<div class="editor-bar">` +
91
+ `<span class="editor-name">${esc(p.split('/').pop())}</span>` +
92
+ `<span class="editor-dirty" hidden>unsaved</span>` +
93
+ `<span class="editor-spacer"></span>` +
94
+ `<span class="editor-hint">⌘S save · esc cancel · ctrl-m tab focus</span>` +
95
+ `<button class="editor-btn" data-act="cancel">cancel</button>` +
96
+ `<button class="editor-btn primary" data-act="save">save</button>` +
97
+ `</div>` +
98
+ `<div class="editor-notice" hidden></div>` +
99
+ `<div class="editor-panes" style="--split:${readSplit()}%">` +
100
+ `<textarea class="editor-text" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>` +
101
+ `<div class="editor-divider" role="separator" aria-orientation="vertical" aria-label="resize editor and preview" tabindex="0" title="drag to resize · double-click to reset"></div>` +
102
+ `<div class="editor-preview markdown-body"></div>` +
103
+ `</div>` +
104
+ `</div>`;
105
+ const textarea = contentEl.querySelector('.editor-text');
106
+ textarea.value = text;
107
+ session = { path: p, mtime: data.mtime, original: text, crlf, textarea, noticeEl: contentEl.querySelector('.editor-notice') };
108
+ document.body.classList.add('editing');
109
+ markView('edit');
110
+
111
+ const dirtyEl = contentEl.querySelector('.editor-dirty');
112
+ const previewEl = contentEl.querySelector('.editor-preview');
113
+ textarea.addEventListener('input', () => { dirtyEl.hidden = !isDirty(); schedulePreview(p, textarea, previewEl); });
114
+ textarea.addEventListener('scroll', () => syncScroll(textarea, previewEl));
115
+ wireDivider(contentEl.querySelector('.editor-panes'), contentEl.querySelector('.editor-divider'));
116
+ renderPreview(p, textarea, previewEl);
117
+ textarea.addEventListener('keydown', onKey);
118
+ contentEl.querySelector('[data-act="save"]').addEventListener('click', () => save());
119
+ contentEl.querySelector('[data-act="cancel"]').addEventListener('click', cancel);
120
+ textarea.focus();
121
+ textarea.setSelectionRange(0, 0);
122
+ }
123
+
124
+ /* ---------- live preview (right pane) ---------- */
125
+
126
+ let previewTimer = 0;
127
+ let previewSeq = 0;
128
+
129
+ function schedulePreview(p, textarea, previewEl) {
130
+ clearTimeout(previewTimer);
131
+ previewTimer = setTimeout(() => renderPreview(p, textarea, previewEl), 250);
132
+ }
133
+
134
+ // Rendered by the server exactly as the viewer renders the file, but from the
135
+ // unsaved text - nothing is written. Out-of-order responses are dropped.
136
+ async function renderPreview(p, textarea, previewEl) {
137
+ const seq = ++previewSeq;
138
+ let html;
139
+ try {
140
+ const res = await fetch('/api/preview', {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify({ path: p, content: textarea.value })
144
+ });
145
+ if (!res.ok) throw new Error();
146
+ ({ html } = await res.json());
147
+ } catch {
148
+ return; // keep the last good preview
149
+ }
150
+ if (seq !== previewSeq || !previewEl.isConnected) return;
151
+ previewEl.innerHTML = html;
152
+ await runMermaid();
153
+ syncScroll(textarea, previewEl);
154
+ }
155
+
156
+ // Proportional: the preview sits at the same fraction of its height as the source.
157
+ function syncScroll(textarea, previewEl) {
158
+ const max = textarea.scrollHeight - textarea.clientHeight;
159
+ const frac = max > 0 ? textarea.scrollTop / max : 0;
160
+ previewEl.scrollTop = frac * (previewEl.scrollHeight - previewEl.clientHeight);
161
+ }
162
+
163
+ /* ---------- resizable split ---------- */
164
+
165
+ const SPLIT_KEY = 'pv-editor-split';
166
+ const clampSplit = (n) => Math.min(80, Math.max(20, n));
167
+
168
+ function readSplit() {
169
+ try { const n = Number(localStorage.getItem(SPLIT_KEY)); return n ? clampSplit(n) : 50; } catch { return 50; }
170
+ }
171
+ function saveSplit(n) {
172
+ try { localStorage.setItem(SPLIT_KEY, String(Math.round(n))); } catch { /* not persisted - fine */ }
173
+ }
174
+
175
+ function wireDivider(panes, divider) {
176
+ let split = readSplit();
177
+ const set = (n) => { split = clampSplit(n); panes.style.setProperty('--split', `${split}%`); };
178
+ divider.addEventListener('pointerdown', (e) => {
179
+ e.preventDefault();
180
+ divider.setPointerCapture(e.pointerId);
181
+ panes.classList.add('resizing');
182
+ const move = (ev) => {
183
+ const r = panes.getBoundingClientRect();
184
+ set(((ev.clientX - r.left) / r.width) * 100);
185
+ };
186
+ const up = () => {
187
+ divider.removeEventListener('pointerup', up);
188
+ divider.removeEventListener('pointercancel', up);
189
+ divider.removeEventListener('pointermove', move);
190
+ panes.classList.remove('resizing');
191
+ saveSplit(split);
192
+ };
193
+ divider.addEventListener('pointermove', move);
194
+ divider.addEventListener('pointerup', up, { once: true });
195
+ divider.addEventListener('pointercancel', up, { once: true });
196
+ });
197
+ divider.addEventListener('dblclick', () => { set(50); saveSplit(50); });
198
+ divider.addEventListener('keydown', (e) => {
199
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
200
+ e.preventDefault();
201
+ set(split + (e.key === 'ArrowLeft' ? -5 : 5));
202
+ saveSplit(split);
203
+ });
204
+ }
205
+
206
+ /* ---------- keys ---------- */
207
+
208
+ let tabMovesFocus = false;
209
+
210
+ function onKey(e) {
211
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); return; }
212
+ if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
213
+ // ctrl-m toggles Tab between indenting and moving focus (so the keyboard can leave)
214
+ if (e.ctrlKey && e.key.toLowerCase() === 'm') {
215
+ e.preventDefault();
216
+ tabMovesFocus = !tabMovesFocus;
217
+ toast(tabMovesFocus ? 'tab moves focus (ctrl-m to indent)' : 'tab indents (ctrl-m to move focus)');
218
+ return;
219
+ }
220
+ if (e.key === 'Tab' && !tabMovesFocus && !e.metaKey && !e.ctrlKey && !e.altKey) {
221
+ // two spaces, never a tab character - YAML forbids tabs for indentation
222
+ e.preventDefault();
223
+ const ta = e.target;
224
+ if (e.shiftKey) { dedentLine(ta); return; }
225
+ ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
226
+ ta.dispatchEvent(new Event('input'));
227
+ }
228
+ }
229
+
230
+ function dedentLine(ta) {
231
+ const start = ta.value.lastIndexOf('\n', ta.selectionStart - 1) + 1;
232
+ const strip = ta.value.slice(start, start + 2).match(/^ {1,2}/)?.[0].length || 0;
233
+ if (!strip) return;
234
+ const caret = ta.selectionStart;
235
+ ta.setRangeText('', start, start + strip, 'preserve');
236
+ ta.setSelectionRange(Math.max(start, caret - strip), Math.max(start, caret - strip));
237
+ ta.dispatchEvent(new Event('input'));
238
+ }
239
+
240
+ async function save(force = false) {
241
+ if (!session) return;
242
+ const { path, crlf, textarea } = session;
243
+ const content = crlf ? textarea.value.replace(/\n/g, '\r\n') : textarea.value;
244
+ let res;
245
+ session.saving = true;
246
+ try {
247
+ res = await fetch('/api/source', {
248
+ method: 'PUT',
249
+ headers: { 'content-type': 'application/json' },
250
+ body: JSON.stringify({ path, content, mtime: session.mtime, force })
251
+ });
252
+ } catch {
253
+ session.saving = false;
254
+ toast('save failed - is projview still running?');
255
+ return;
256
+ }
257
+ session.saving = false;
258
+ if (res.status === 409) {
259
+ showNotice('this file changed on disk since you opened it', [
260
+ ['overwrite', () => save(true)],
261
+ ['reload theirs', () => { session.original = session.textarea.value; startEditing(path); }]
262
+ ]);
263
+ return;
264
+ }
265
+ if (!res.ok) {
266
+ const { error } = await res.json().catch(() => ({}));
267
+ toast(error || `save failed (${res.status})`);
268
+ return;
269
+ }
270
+ session = null;
271
+ document.body.classList.remove('editing');
272
+ toast(`saved ${path.split('/').pop()}`);
273
+ openFile(path);
274
+ }
275
+
276
+ function cancel() {
277
+ if (!session) return;
278
+ const p = session.path;
279
+ if (confirmLeave()) openFile(p);
280
+ }
281
+
282
+ function showNotice(msg, actions) {
283
+ const el = session.noticeEl;
284
+ el.replaceChildren(document.createTextNode(msg));
285
+ for (const [label, fn] of actions) {
286
+ const b = document.createElement('button');
287
+ b.className = 'editor-btn';
288
+ b.textContent = label;
289
+ b.addEventListener('click', fn);
290
+ el.appendChild(b);
291
+ }
292
+ el.hidden = false;
293
+ }
294
+
295
+ function hideNotice() { if (session) session.noticeEl.hidden = true; }
296
+
297
+ // "e" opens the editor on the current page, unless the user is typing somewhere
298
+ document.addEventListener('keydown', (e) => {
299
+ if (e.key !== 'e' || e.metaKey || e.ctrlKey || e.altKey || session) return;
300
+ if (e.target.closest?.('input, textarea, [contenteditable]') || !state.writable || !state.currentPath) return;
301
+ if (!$('palette').hidden || !$('zoom').hidden) return;
302
+ e.preventDefault();
303
+ startEditing(state.currentPath);
304
+ });
305
+
306
+ window.addEventListener('beforeunload', (e) => { if (isDirty()) e.preventDefault(); });
@@ -0,0 +1,162 @@
1
+ /* history view: the file's earlier versions (newest first), each viewable as the
2
+ * file would render, and restorable. Versions are kept by the server whenever a
3
+ * file changes - from projview's editor or anywhere else. Restoring is an
4
+ * ordinary save, so the text it replaces becomes a version too. */
5
+
6
+ import { $, esc } from './dom.js';
7
+ import { state } from './store.js';
8
+ import { openFile } from './viewer.js';
9
+ import { runMermaid } from './mermaid.js';
10
+ import { toast } from './toast.js';
11
+
12
+ const contentEl = $('content');
13
+
14
+ let open = null; // { path, selected, mtime } while the history view is showing - mtime of the file as shown
15
+
16
+ export const isHistory = (p) => !!open && (p === undefined || open.path === p);
17
+
18
+ // The viewer calls this before rendering anything else.
19
+ export function leaveHistory() {
20
+ open = null;
21
+ document.body.classList.remove('history');
22
+ }
23
+
24
+ export async function showHistory(p) {
25
+ open = { path: p, selected: 'current' };
26
+ document.body.classList.add('history');
27
+ await refreshHistory();
28
+ }
29
+
30
+ // Re-list (live reload calls this when the file changes while history is open).
31
+ export async function refreshHistory() {
32
+ if (!open) return;
33
+ const p = open.path;
34
+ let data, mtime = null;
35
+ try {
36
+ const [res, src] = await Promise.all([
37
+ fetch(`/api/versions?p=${encodeURIComponent(p)}`),
38
+ state.writable ? fetch(`/api/source?p=${encodeURIComponent(p)}`) : null
39
+ ]);
40
+ if (!res.ok) throw new Error();
41
+ data = await res.json();
42
+ if (src?.ok) ({ mtime } = await src.json());
43
+ } catch {
44
+ toast(`couldn't load history for ${p}`);
45
+ return;
46
+ }
47
+ if (!isHistory(p)) return;
48
+ open.mtime = mtime; // restore checks the file is still what "current" showed
49
+ const keep = state.persistent ? 'kept across runs' : 'kept until projview stops';
50
+ const rows = [{ id: 'current', label: 'current', when: null }, ...data.versions.map((v) => { const when = v.mtime ?? v.replacedAt; return { id: v.id, label: clock(when), sub: ago(when), when, size: v.size }; })];
51
+ if (!rows.some((r) => r.id === open.selected)) open.selected = 'current';
52
+
53
+ contentEl.innerHTML =
54
+ `<div class="history-view">` +
55
+ `<div class="history-bar">` +
56
+ `<span class="history-name">${esc(p.split('/').pop())}</span>` +
57
+ `<span class="history-hint">last ${data.limit} earlier versions · ${keep}</span>` +
58
+ `</div>` +
59
+ `<div class="history-panes">` +
60
+ `<div class="history-list" aria-label="versions">` +
61
+ rows.map((r) =>
62
+ `<button class="history-item" data-id="${esc(r.id)}"${r.when ? ` title="${esc(new Date(r.when).toLocaleString())}"` : ''}>` +
63
+ `<span class="hi-label">${esc(r.label)}${r.sub ? `<small>${esc(r.sub)}</small>` : ''}</span>` +
64
+ (r.size != null ? `<span class="hi-size">${fmtSize(r.size)}</span>` : '') +
65
+ `</button>`).join('') +
66
+ (data.versions.length ? '' : `<p class="history-empty">no earlier versions yet - they appear here as the file changes</p>`) +
67
+ `</div>` +
68
+ `<div class="history-pane">` +
69
+ `<div class="history-pane-bar"></div>` +
70
+ `<div class="history-preview markdown-body"></div>` +
71
+ `</div>` +
72
+ `</div>` +
73
+ `</div>`;
74
+ for (const b of contentEl.querySelectorAll('.history-item')) b.addEventListener('click', () => select(b.dataset.id));
75
+ await select(open.selected);
76
+ }
77
+
78
+ async function select(id) {
79
+ if (!open) return;
80
+ const p = open.path;
81
+ open.selected = id;
82
+ for (const b of contentEl.querySelectorAll('.history-item')) {
83
+ if (b.dataset.id === id) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
84
+ }
85
+ const bar = contentEl.querySelector('.history-pane-bar');
86
+ const preview = contentEl.querySelector('.history-preview');
87
+
88
+ let html, version = null;
89
+ try {
90
+ if (id === 'current') {
91
+ ({ html } = await (await fetch(`/api/file?p=${encodeURIComponent(p)}`)).json());
92
+ } else {
93
+ version = await (await fetch(`/api/versions/${encodeURIComponent(id)}`)).json();
94
+ ({ html } = await (await fetch('/api/preview', {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ path: p, content: version.content })
98
+ })).json());
99
+ }
100
+ } catch {
101
+ toast('couldn’t load that version');
102
+ return;
103
+ }
104
+ if (!isHistory(p) || open.selected !== id) return; // moved on while loading
105
+
106
+ bar.replaceChildren();
107
+ const label = document.createElement('span');
108
+ label.textContent = version ? `version from ${new Date(version.mtime ?? version.replacedAt).toLocaleString()}` : 'the file as it is now';
109
+ bar.appendChild(label);
110
+ if (version && state.writable) {
111
+ const btn = document.createElement('button');
112
+ btn.className = 'editor-btn primary';
113
+ btn.textContent = 'restore this version';
114
+ btn.addEventListener('click', () => restore(p, version));
115
+ bar.appendChild(btn);
116
+ }
117
+ preview.innerHTML = html;
118
+ await runMermaid();
119
+ }
120
+
121
+ async function restore(p, version) {
122
+ if (!confirm('restore this version? the current text is kept in history, so this can be undone.')) return;
123
+ try {
124
+ // the mtime from when history loaded: if the file changed since, the save is
125
+ // refused (409) instead of overwriting a change the user never saw
126
+ const res = await fetch('/api/source', {
127
+ method: 'PUT',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify({ path: p, content: version.content, mtime: open?.mtime })
130
+ });
131
+ if (res.status === 409) {
132
+ toast('the file changed since you opened history - check the new list and try again');
133
+ refreshHistory();
134
+ return;
135
+ }
136
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
137
+ } catch (err) {
138
+ toast(`restore failed - ${err.message}`);
139
+ return;
140
+ }
141
+ toast('restored - the replaced text is in history');
142
+ openFile(p);
143
+ }
144
+
145
+ // 14:02:31 today, else a short date + time
146
+ function clock(ts) {
147
+ const d = new Date(ts);
148
+ const today = d.toDateString() === new Date().toDateString();
149
+ return today
150
+ ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
151
+ : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
152
+ }
153
+
154
+ function ago(ts) {
155
+ const s = Math.round((Date.now() - ts) / 1000);
156
+ if (s < 60) return 'just now';
157
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
158
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
159
+ return new Date(ts).toLocaleDateString();
160
+ }
161
+
162
+ const fmtSize = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
package/public/icons.js CHANGED
@@ -4,4 +4,10 @@ export const SVG_FOLDER = `<svg viewBox="0 0 24 24" fill="none" stroke="currentC
4
4
  export const SVG_FILE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>`;
5
5
  export const SVG_DIAGRAM = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="19" r="2.6"/><line x1="8.4" y1="10.7" x2="15.6" y2="6.3"/><line x1="8.4" y1="13.3" x2="15.6" y2="17.7"/></svg>`;
6
6
 
7
- export const fileIcon = (ext) => (ext === '.mmd' ? SVG_DIAGRAM : SVG_FILE);
7
+ export const SVG_DATA = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>`;
8
+
9
+ // The client's one list of previewable types: ext -> kind (null if not previewable).
10
+ const KINDS = { '.md': 'md', '.mmd': 'mmd', '.json': 'data', '.yml': 'data', '.yaml': 'data' };
11
+ export const fileKind = (name) => KINDS[(name.match(/\.[^./]+$/)?.[0] || '').toLowerCase()] || null;
12
+
13
+ export const fileIcon = (name) => ({ mmd: SVG_DIAGRAM, data: SVG_DATA }[fileKind(name)] || SVG_FILE);