projview 0.6.0 → 0.8.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/public/live.js CHANGED
@@ -4,6 +4,7 @@ import { $ } from './dom.js';
4
4
  import { state } from './store.js';
5
5
  import { loadTree } from './tree.js';
6
6
  import { openFile } from './viewer.js';
7
+ import { refreshComments } from './comments.js';
7
8
 
8
9
  const versionEl = $('version');
9
10
 
@@ -14,6 +15,10 @@ export function connectEvents() {
14
15
  if (path === state.currentPath) { pulse(); openFile(path); }
15
16
  });
16
17
  es.addEventListener('tree', async () => { pulse(); await loadTree(); });
18
+ es.addEventListener('comments', (e) => {
19
+ const { file } = JSON.parse(e.data || '{}');
20
+ if (!file || file === state.currentPath) refreshComments();
21
+ });
17
22
  }
18
23
 
19
24
  // brief flash of the version line as live-reload feedback
package/public/main.js CHANGED
@@ -10,6 +10,7 @@ import './links.js';
10
10
  import './toc.js';
11
11
  import './mermaid.js';
12
12
  import './search.js';
13
+ import './comments.js';
13
14
 
14
15
  // browser back / forward
15
16
  window.addEventListener('hashchange', () => {
package/public/store.js CHANGED
@@ -4,5 +4,6 @@ export const state = {
4
4
  currentPath: null, // path of the file currently shown
5
5
  expanded: new Set(), // dir paths the user has opened
6
6
  treeData: [], // last tree payload from the server
7
- rootName: 'projview' // indexed root's display name
7
+ rootName: 'projview', // indexed root's display name
8
+ writable: false // can this client create/edit comments? (loopback only)
8
9
  };
package/public/style.css CHANGED
@@ -530,3 +530,107 @@ mark.search-hit {
530
530
  ::-webkit-scrollbar-thumb { background: var(--surface1); border-radius: 20px; border: 2px solid var(--mantle); }
531
531
  ::-webkit-scrollbar-thumb:hover { background: var(--surface2); }
532
532
  ::-webkit-scrollbar-track { background: transparent; }
533
+
534
+ /* ---------------- comments ---------------- */
535
+
536
+ ::highlight(pv-comment) { background-color: color-mix(in srgb, var(--mauve) 26%, transparent); border-radius: 3px; }
537
+ ::highlight(pv-comment-active) { background-color: color-mix(in srgb, var(--peach) 42%, transparent); }
538
+
539
+ .cmt-fab {
540
+ position: fixed; bottom: 22px; right: 22px; z-index: 30;
541
+ width: 52px; height: 52px; border-radius: 50%;
542
+ border: 2px solid var(--surface1); background: var(--mauve); color: var(--crust);
543
+ font-size: 21px; cursor: pointer; box-shadow: var(--shadow);
544
+ display: flex; align-items: center; justify-content: center;
545
+ transition: transform 0.15s ease, background 0.15s ease;
546
+ }
547
+ .cmt-fab:hover { transform: translateY(-2px); }
548
+ .cmt-fab.open { background: var(--lavender); }
549
+ .cmt-fab[hidden] { display: none; }
550
+ .cmt-badge {
551
+ position: absolute; top: -4px; right: -4px;
552
+ min-width: 20px; height: 20px; padding: 0 5px; border-radius: 10px;
553
+ background: var(--red); color: var(--crust);
554
+ font-size: 11px; font-weight: 800; line-height: 20px; border: 2px solid var(--base);
555
+ }
556
+
557
+ .cmt-drawer {
558
+ position: fixed; top: 0; right: 0; z-index: 29;
559
+ width: 360px; max-width: 90vw; height: 100vh;
560
+ background: var(--mantle); border-left: 2px solid var(--surface0);
561
+ box-shadow: -8px 0 28px rgba(0, 0, 0, 0.4);
562
+ display: flex; flex-direction: column;
563
+ }
564
+ .cmt-drawer[hidden] { display: none; }
565
+ .cmt-drawer-head {
566
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
567
+ padding: 16px 16px 12px; border-bottom: 2px solid var(--surface0);
568
+ }
569
+ .cmt-drawer-head strong { color: var(--mauve); }
570
+ .cmt-file { color: var(--overlay); font-size: 12px; }
571
+ .cmt-head-btns { display: flex; gap: 6px; }
572
+ .cmt-add-file, .cmt-close {
573
+ background: var(--surface0); border: none; color: var(--subtext);
574
+ border-radius: 8px; padding: 5px 9px; cursor: pointer; font: inherit; font-size: 12px;
575
+ }
576
+ .cmt-add-file:hover { background: var(--surface1); color: var(--green); }
577
+ .cmt-close:hover { background: var(--surface1); color: var(--text); }
578
+
579
+ .cmt-composer-slot:not(:empty) { padding: 12px; border-bottom: 1px solid var(--surface0); }
580
+ .cmt-composer { display: flex; flex-direction: column; gap: 8px; }
581
+ .cmt-composer textarea {
582
+ width: 100%; resize: vertical; background: var(--base); color: var(--text);
583
+ border: 2px solid var(--surface1); border-radius: var(--radius-sm);
584
+ padding: 8px 10px; font: inherit; font-size: 13px;
585
+ }
586
+ .cmt-composer textarea:focus { outline: none; border-color: var(--mauve); }
587
+ .cmt-composer-actions { display: flex; gap: 8px; }
588
+ .cmt-btn {
589
+ border: 2px solid var(--surface1); background: var(--surface0); color: var(--subtext);
590
+ border-radius: 8px; padding: 5px 12px; cursor: pointer; font: inherit; font-size: 12.5px;
591
+ }
592
+ .cmt-btn:hover { color: var(--text); }
593
+ .cmt-btn.primary { background: var(--mauve); border-color: var(--mauve); color: var(--crust); font-weight: 700; }
594
+ .cmt-btn.primary:hover { background: var(--lavender); border-color: var(--lavender); }
595
+
596
+ .cmt-list { flex: 1; overflow-y: auto; padding: 10px 12px 24px; display: flex; flex-direction: column; gap: 10px; }
597
+ .cmt-empty { text-align: center; color: var(--overlay); padding: 36px 10px; font-size: 13px; line-height: 1.9; }
598
+ .cmt-card {
599
+ background: var(--base); border: 2px solid var(--surface0); border-radius: var(--radius-sm);
600
+ padding: 10px 12px; cursor: pointer; transition: border-color 0.12s ease;
601
+ }
602
+ .cmt-card:hover { border-color: var(--surface2); }
603
+ .cmt-card.active { border-color: var(--peach); }
604
+ .cmt-card.resolved { opacity: 0.6; }
605
+ .cmt-card.resolved .cmt-body { text-decoration: line-through; text-decoration-color: var(--overlay); }
606
+ .cmt-card.orphan { border-style: dashed; }
607
+ .cmt-meta { display: flex; justify-content: space-between; align-items: baseline; font-size: 11.5px; margin-bottom: 6px; }
608
+ .cmt-author { color: var(--blue); font-weight: 700; }
609
+ .cmt-time { color: var(--overlay); }
610
+ .cmt-anchor-chip {
611
+ display: block; background: var(--surface0); color: var(--subtext);
612
+ font-size: 11px; border-radius: 6px; padding: 3px 8px; margin-bottom: 7px;
613
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
614
+ }
615
+ .cmt-anchor-chip.orphan { color: var(--peach); }
616
+ .cmt-body { font-size: 13px; color: var(--text); word-break: break-word; }
617
+ .cmt-actions { display: flex; gap: 14px; margin-top: 9px; }
618
+ .cmt-link { background: none; border: none; color: var(--overlay); cursor: pointer; font: inherit; font-size: 11.5px; padding: 0; }
619
+ .cmt-link:hover { color: var(--lavender); }
620
+ .cmt-link.danger:hover { color: var(--red); }
621
+
622
+ .cmt-selchip {
623
+ position: fixed; z-index: 40; background: var(--mauve); color: var(--crust);
624
+ border: none; border-radius: 8px; padding: 5px 10px;
625
+ font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; box-shadow: var(--shadow-sm);
626
+ }
627
+ .cmt-selchip:hover { background: var(--lavender); }
628
+
629
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 { position: relative; }
630
+ .cmt-head-add {
631
+ border: none; background: none; cursor: pointer; font-size: 0.62em;
632
+ opacity: 0; margin-left: 8px; vertical-align: middle; transition: opacity 0.12s ease;
633
+ }
634
+ .cmt-head-add::before { content: '💬'; }
635
+ h1:hover > .cmt-head-add, h2:hover > .cmt-head-add, h3:hover > .cmt-head-add { opacity: 0.5; }
636
+ .cmt-head-add:hover { opacity: 1 !important; }
package/public/tree.js CHANGED
@@ -12,9 +12,10 @@ let fileCount = 0;
12
12
 
13
13
  export async function loadTree() {
14
14
  const res = await fetch('/api/tree');
15
- const { root, tree, version } = await res.json();
15
+ const { root, tree, version, writable } = await res.json();
16
16
  state.treeData = tree;
17
17
  state.rootName = root;
18
+ state.writable = !!writable;
18
19
  document.title = `${root} · projview`;
19
20
  if (version) versionEl.textContent = `version ${version}`;
20
21
  renderTree();
package/public/viewer.js CHANGED
@@ -7,6 +7,7 @@ import { renderBreadcrumb } from './breadcrumb.js';
7
7
  import { runMermaid } from './mermaid.js';
8
8
  import { buildToc } from './toc.js';
9
9
  import { toast } from './toast.js';
10
+ import { onFileRendered } from './comments.js';
10
11
 
11
12
  const contentEl = $('content');
12
13
  const viewerEl = $('viewer');
@@ -31,6 +32,7 @@ export async function openFile(p, fragment, highlight) {
31
32
  contentEl.innerHTML = html;
32
33
  await runMermaid();
33
34
  buildToc();
35
+ onFileRendered(p); // load + anchor + paint comments for this file
34
36
  if (fragment) scrollToFragment(fragment);
35
37
  else if (highlight) highlightInContent(highlight);
36
38
  else viewerEl.scrollTop = 0;
@@ -81,5 +83,6 @@ function showNotFound(p) {
81
83
  `<p>can't find <code>${esc(p)}</code></p>` +
82
84
  `<p class="muted">the link may be broken, or the file was renamed / removed</p></div>`;
83
85
  buildToc();
86
+ onFileRendered(null); // no file -> hide the comments affordances
84
87
  toast(`not found: ${p}`);
85
88
  }
package/src/server.js CHANGED
@@ -8,7 +8,7 @@ import chokidar from 'chokidar';
8
8
  import { buildTree, isIgnoredDir, isPreviewable } from './walk.js';
9
9
  import { renderFile } from './render.js';
10
10
  import { buildIndex, updateFile, removeFile, search } from './search.js';
11
- import { createStore } from './store.js';
11
+ import { createStore, gitOrOsUser } from './store.js';
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -47,8 +47,33 @@ function resolveInRoot(root, relPath) {
47
47
  return abs;
48
48
  }
49
49
 
50
+ // Comment writes are allowed only when bound to loopback. A non-loopback bind
51
+ // (e.g. 0.0.0.0 on the launchpad / in Docker) is treated as exposed -> read-only.
52
+ export function isLoopbackHost(host) {
53
+ return ['127.0.0.1', '::1', 'localhost', ''].includes(host);
54
+ }
55
+
56
+ // Read + JSON-parse a request body, capped to guard against abuse.
57
+ // Resolves null on parse error or oversize (handler treats that as a 400).
58
+ function readBody(req, limit = 1_000_000) {
59
+ return new Promise((resolve) => {
60
+ let data = '';
61
+ let size = 0;
62
+ req.on('data', (chunk) => {
63
+ size += chunk.length;
64
+ if (size > limit) { req.destroy(); resolve(null); return; }
65
+ data += chunk;
66
+ });
67
+ req.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch { resolve(null); } });
68
+ req.on('error', () => resolve(null));
69
+ });
70
+ }
71
+
50
72
  export async function startServer(root, { port = 4321, host = '127.0.0.1', store: storeConfig } = {}) {
51
73
  const sseClients = new Set();
74
+ const allowWrites = isLoopbackHost(host); // comments are read-only on an exposed host
75
+ const denyWrite = (res) => sendJson(res, { error: 'read-only: projview is exposed on a non-loopback host' }, 403);
76
+ const localAuthor = gitOrOsUser(); // default author for UI-created comments
52
77
 
53
78
  function broadcast(event, payload) {
54
79
  const frame = `event: ${event}\ndata: ${JSON.stringify(payload || {})}\n\n`;
@@ -74,13 +99,16 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
74
99
 
75
100
  // --- file tree ---
76
101
  if (pathname === '/api/tree') {
77
- sendJson(res, { root: path.basename(root) || root, tree: buildTree(root), version: VERSION });
102
+ sendJson(res, { root: path.basename(root) || root, tree: buildTree(root), version: VERSION, writable: allowWrites });
78
103
  return;
79
104
  }
80
105
 
81
106
  // --- search (fuzzy filenames + full-text contents) ---
82
107
  if (pathname === '/api/search') {
83
- sendJson(res, search(url.searchParams.get('q') || ''));
108
+ const q = url.searchParams.get('q') || '';
109
+ const results = search(q);
110
+ if (q.trim()) { try { await store.recordEvent({ type: 'search', query: q.trim(), count: results.files.length + results.content.length }); } catch { /* usage is best-effort */ } }
111
+ sendJson(res, results);
84
112
  return;
85
113
  }
86
114
 
@@ -94,6 +122,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
94
122
  }
95
123
  try {
96
124
  const content = await fsp.readFile(abs, 'utf8');
125
+ try { await store.recordEvent({ type: 'view', file: rel }); } catch { /* usage is best-effort */ }
97
126
  sendJson(res, { path: rel, ext: path.extname(abs).toLowerCase(), html: renderFile(path.extname(abs).toLowerCase(), content) });
98
127
  } catch {
99
128
  sendJson(res, { error: 'not found' }, 404);
@@ -101,6 +130,54 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
101
130
  return;
102
131
  }
103
132
 
133
+ // --- comments: list (GET) + create (POST) ---
134
+ if (pathname === '/api/comments') {
135
+ if (req.method === 'POST') {
136
+ if (!allowWrites) return denyWrite(res);
137
+ const body = await readBody(req);
138
+ if (!body || !body.file || !body.body) { sendJson(res, { error: 'file and body are required' }, 400); return; }
139
+ const c = await store.addComment({ file: body.file, body: body.body, anchor: body.anchor, author: body.author || localAuthor });
140
+ broadcast('comments', { file: c.file });
141
+ sendJson(res, c, 201);
142
+ return;
143
+ }
144
+ sendJson(res, await store.listComments(url.searchParams.get('file') || undefined));
145
+ return;
146
+ }
147
+
148
+ // --- a single comment by id: resolve/edit (PATCH) + delete (DELETE) ---
149
+ if (pathname.startsWith('/api/comments/')) {
150
+ const id = decodeURIComponent(pathname.slice('/api/comments/'.length));
151
+ if (req.method === 'PATCH') {
152
+ if (!allowWrites) return denyWrite(res);
153
+ const body = await readBody(req);
154
+ if (!body) { sendJson(res, { error: 'invalid body' }, 400); return; }
155
+ const patch = {};
156
+ if (typeof body.resolved === 'boolean') patch.resolved = body.resolved;
157
+ if (typeof body.body === 'string') patch.body = body.body;
158
+ const c = await store.updateComment(id, patch);
159
+ if (!c) { sendJson(res, { error: 'not found' }, 404); return; }
160
+ broadcast('comments', { file: c.file });
161
+ sendJson(res, c);
162
+ return;
163
+ }
164
+ if (req.method === 'DELETE') {
165
+ if (!allowWrites) return denyWrite(res);
166
+ if (!(await store.deleteComment(id))) { sendJson(res, { error: 'not found' }, 404); return; }
167
+ broadcast('comments', {});
168
+ sendJson(res, { deleted: id });
169
+ return;
170
+ }
171
+ sendJson(res, { error: 'use PATCH or DELETE' }, 405);
172
+ return;
173
+ }
174
+
175
+ // --- usage stats ---
176
+ if (pathname === '/api/usage') {
177
+ sendJson(res, await store.usageStats());
178
+ return;
179
+ }
180
+
104
181
  // --- static SPA assets (incl. /vendor/*) ---
105
182
  const assetName = pathname === '/' ? 'index.html' : pathname.slice(1);
106
183
  const assetPath = resolveInRoot(PUBLIC_DIR, assetName);
@@ -152,6 +229,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
152
229
  server,
153
230
  port: listenPort,
154
231
  close,
232
+ writable: allowWrites,
155
233
  storeInfo: { describe: store.describe(), warm: indexInfo.warm, reindexed: indexInfo.reindexed, total: indexInfo.total }
156
234
  };
157
235
  }
package/src/store.js CHANGED
@@ -1,20 +1,31 @@
1
- /* pluggable persistence for the index.
1
+ /* pluggable persistence for the index, comments + usage.
2
2
  *
3
- * A Store persists indexed entries so a re-spawn can warm-start. Backends:
4
- * memory - no persistence (ephemeral default; nothing written)
3
+ * A Store persists three things so a re-spawn can warm-start and so comments /
4
+ * usage survive a session:
5
+ * entries - indexed files { path, name, ext, mtime, content }
6
+ * comments - notes on a file or a portion of it (see anchor model below)
7
+ * events - usage signals { ts, type, file?, query?, count? }
8
+ *
9
+ * Backends:
10
+ * memory - no disk persistence (ephemeral default; lives only this session)
5
11
  * superlite - a JSON file
6
12
  * lite - sqlite via node:sqlite (built-in)
7
13
  * custom - a connection URL: sqlite://<file> or postgres://<...> (lazy `pg`)
8
14
  *
9
- * An entry is { path, name, ext, mtime, content }. Everything above the store
10
- * stays storage-agnostic - search still runs in memory over loaded entries.
15
+ * A comment's `anchor` is one of:
16
+ * { type: 'file' } whole document
17
+ * { type: 'heading', id } a section (stable pv- id)
18
+ * { type: 'text', exact, prefix, suffix, section } a quoted range (re-anchored
19
+ * client-side; see 3c)
20
+ * Everything above the store stays storage-agnostic.
11
21
  */
12
22
 
13
23
  import fs from 'node:fs';
14
24
  import fsp from 'node:fs/promises';
15
25
  import path from 'node:path';
16
26
  import os from 'node:os';
17
- import { createHash } from 'node:crypto';
27
+ import { execSync } from 'node:child_process';
28
+ import { createHash, randomUUID } from 'node:crypto';
18
29
 
19
30
  // node:sqlite is flagged "experimental" and emits a noisy warning on use; we
20
31
  // opted into it deliberately, so quiet just that one notice (others pass through).
@@ -33,6 +44,48 @@ const keyFor = (root) => createHash('sha256').update(path.resolve(root)).digest(
33
44
  const ensureDir = (d) => fs.mkdirSync(d, { recursive: true });
34
45
  const redact = (url) => url.replace(/\/\/[^@/]*@/, '//***@'); // hide creds in logs
35
46
 
47
+ // Best-effort identity for comment authorship: git user.name, else OS username.
48
+ export function gitOrOsUser() {
49
+ try { const n = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); if (n) return n; } catch { /* no git / no user.name */ }
50
+ try { return os.userInfo().username || null; } catch { return null; }
51
+ }
52
+
53
+ // Normalize loose input into a full comment / event record (defaults + ids).
54
+ const newComment = (c) => ({
55
+ id: c.id || randomUUID(),
56
+ file: c.file,
57
+ anchor: c.anchor || { type: 'file' },
58
+ body: c.body,
59
+ author: c.author ?? null,
60
+ createdAt: c.createdAt || Date.now(),
61
+ resolved: !!c.resolved
62
+ });
63
+ const newEvent = (e) => ({
64
+ id: randomUUID(),
65
+ ts: e.ts || Date.now(),
66
+ type: e.type,
67
+ file: e.file ?? null,
68
+ query: e.query ?? null,
69
+ count: e.count ?? null
70
+ });
71
+
72
+ // Aggregate raw events into something a human / agent can read at a glance.
73
+ function summarizeEvents(events) {
74
+ const byType = {};
75
+ const fileCounts = {};
76
+ const searches = [];
77
+ for (const e of events) {
78
+ byType[e.type] = (byType[e.type] || 0) + 1;
79
+ if (e.file) fileCounts[e.file] = (fileCounts[e.file] || 0) + 1;
80
+ if (e.type === 'search' && e.query) searches.push(e.query);
81
+ }
82
+ const topFiles = Object.entries(fileCounts)
83
+ .sort((a, b) => b[1] - a[1])
84
+ .slice(0, 10)
85
+ .map(([file, count]) => ({ file, count }));
86
+ return { total: events.length, byType, topFiles, recentSearches: searches.slice(-10).reverse() };
87
+ }
88
+
36
89
  function readConfigFile() {
37
90
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
38
91
  }
@@ -65,21 +118,77 @@ export function createStore(config, root) {
65
118
  }
66
119
  }
67
120
 
121
+ // True when a store keeps comments/usage across restarts. The CLI/server warn
122
+ // when it's false so a comment isn't silently lost on exit.
123
+ export function isPersistent(config) {
124
+ const cfg = config || { kind: 'memory' };
125
+ if (cfg.kind === 'memory') return false;
126
+ if (cfg.kind === 'custom') return true; // the user's own DB
127
+ return !!cfg.persist; // superlite/lite: only with --persist
128
+ }
129
+
68
130
  function fileFor(root, persist, ext) {
69
131
  const dir = persist ? CACHE_DIR : EPHEMERAL_DIR;
70
132
  ensureDir(dir);
71
133
  return path.join(dir, `${keyFor(root)}.${ext}`);
72
134
  }
73
135
 
136
+ /* ---------- shared in-memory comment/event layer (memory + superlite) ---------- */
137
+
138
+ // Holds comments + events in memory; calls flush() after each mutation so a
139
+ // disk-backed wrapper (superlite) can persist. memory passes a no-op flush.
140
+ function inMemoryDocs(flush = async () => {}) {
141
+ const comments = new Map(); // id -> comment
142
+ const events = []; // append-only (stable ref - never reassigned)
143
+ return {
144
+ comments,
145
+ events,
146
+ async addComment(c) {
147
+ const comment = newComment(c);
148
+ comments.set(comment.id, comment);
149
+ await flush();
150
+ return comment;
151
+ },
152
+ async listComments(file) {
153
+ const all = [...comments.values()].sort((a, b) => a.createdAt - b.createdAt);
154
+ return file ? all.filter((c) => c.file === file) : all;
155
+ },
156
+ async updateComment(id, patch) {
157
+ const c = comments.get(id);
158
+ if (!c) return null;
159
+ Object.assign(c, patch, { id: c.id }); // id is immutable
160
+ await flush();
161
+ return c;
162
+ },
163
+ async deleteComment(id) {
164
+ const ok = comments.delete(id);
165
+ await flush();
166
+ return ok;
167
+ },
168
+ async recordEvent(e) {
169
+ events.push(newEvent(e));
170
+ await flush();
171
+ },
172
+ async usageStats() { return summarizeEvents(events); }
173
+ };
174
+ }
175
+
74
176
  /* ---------- backends ---------- */
75
177
 
76
178
  function memoryStore() {
179
+ const docs = inMemoryDocs();
77
180
  return {
78
181
  kind: 'memory',
79
182
  async load() { return null; },
80
183
  async save() {},
81
184
  async update() {},
82
185
  async remove() {},
186
+ addComment: docs.addComment,
187
+ listComments: docs.listComments,
188
+ updateComment: docs.updateComment,
189
+ deleteComment: docs.deleteComment,
190
+ recordEvent: docs.recordEvent,
191
+ usageStats: docs.usageStats,
83
192
  async close() {},
84
193
  describe() { return 'in-memory (ephemeral - nothing persisted)'; }
85
194
  };
@@ -87,29 +196,38 @@ function memoryStore() {
87
196
 
88
197
  function superliteStore(root, persist) {
89
198
  const file = fileFor(root, persist, 'json');
90
- let map = null; // path -> entry, lazily loaded
199
+ const entries = new Map(); // path -> entry
200
+ let loaded = false;
201
+ const flush = () => fsp.writeFile(file, JSON.stringify({
202
+ builtAt: Date.now(),
203
+ entries: [...entries.values()],
204
+ comments: [...docs.comments.values()],
205
+ events: docs.events
206
+ }));
207
+ const docs = inMemoryDocs(() => (loaded ? flush() : Promise.resolve()));
91
208
  const ensure = async () => {
92
- if (map) return map;
209
+ if (loaded) return;
93
210
  try {
94
211
  const d = JSON.parse(await fsp.readFile(file, 'utf8'));
95
- map = new Map((d.entries || []).map((e) => [e.path, e]));
96
- } catch { map = new Map(); }
97
- return map;
212
+ for (const e of d.entries || []) entries.set(e.path, e);
213
+ for (const c of d.comments || []) docs.comments.set(c.id, c);
214
+ for (const ev of d.events || []) docs.events.push(ev);
215
+ } catch { /* fresh */ }
216
+ loaded = true;
98
217
  };
99
- const flush = () => fsp.writeFile(file, JSON.stringify({ builtAt: Date.now(), entries: [...map.values()] }));
100
218
  return {
101
219
  kind: 'superlite',
102
220
  file,
103
- async load() {
104
- const m = await ensure();
105
- return m.size ? { entries: [...m.values()] } : null;
106
- },
107
- async save(entries) {
108
- map = new Map(entries.map((e) => [e.path, e]));
109
- await flush();
110
- },
111
- async update(entry) { (await ensure()).set(entry.path, entry); await flush(); },
112
- async remove(p) { (await ensure()).delete(p); await flush(); },
221
+ async load() { await ensure(); return entries.size ? { entries: [...entries.values()] } : null; },
222
+ async save(es) { await ensure(); entries.clear(); for (const e of es) entries.set(e.path, e); await flush(); },
223
+ async update(entry) { await ensure(); entries.set(entry.path, entry); await flush(); },
224
+ async remove(p) { await ensure(); entries.delete(p); await flush(); },
225
+ async addComment(c) { await ensure(); return docs.addComment(c); },
226
+ async listComments(f) { await ensure(); return docs.listComments(f); },
227
+ async updateComment(id, patch) { await ensure(); return docs.updateComment(id, patch); },
228
+ async deleteComment(id) { await ensure(); return docs.deleteComment(id); },
229
+ async recordEvent(e) { await ensure(); return docs.recordEvent(e); },
230
+ async usageStats() { await ensure(); return docs.usageStats(); },
113
231
  async close({ keep } = {}) {
114
232
  if (!persist && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
115
233
  },
@@ -120,13 +238,22 @@ function superliteStore(root, persist) {
120
238
  // sqlite-backed store at an explicit file. cleanup=true removes the file on close.
121
239
  function sqliteAt(file, { cleanup }) {
122
240
  let db = null;
241
+ const toComment = (r) => ({
242
+ id: r.id, file: r.file, anchor: JSON.parse(r.anchor || '{"type":"file"}'),
243
+ body: r.body, author: r.author, createdAt: r.createdAt, resolved: !!r.resolved
244
+ });
123
245
  const open = async () => {
124
246
  if (db) return db;
125
247
  const { DatabaseSync } = await import('node:sqlite');
126
248
  db = new DatabaseSync(file);
127
249
  db.exec('CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime REAL, content TEXT)');
250
+ db.exec('CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdAt REAL, resolved INTEGER)');
251
+ db.exec('CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, ts REAL, type TEXT, file TEXT, query TEXT, count INTEGER)');
128
252
  return db;
129
253
  };
254
+ const writeComment = (d, c) => d
255
+ .prepare('INSERT OR REPLACE INTO comments (id, file, anchor, body, author, createdAt, resolved) VALUES (?, ?, ?, ?, ?, ?, ?)')
256
+ .run(c.id, c.file, JSON.stringify(c.anchor), c.body, c.author, c.createdAt, c.resolved ? 1 : 0);
130
257
  return {
131
258
  kind: 'lite',
132
259
  file,
@@ -152,6 +279,41 @@ function sqliteAt(file, { cleanup }) {
152
279
  const d = await open();
153
280
  d.prepare('DELETE FROM files WHERE path = ?').run(p);
154
281
  },
282
+ async addComment(c) {
283
+ const d = await open();
284
+ const comment = newComment(c);
285
+ writeComment(d, comment);
286
+ return comment;
287
+ },
288
+ async listComments(file) {
289
+ const d = await open();
290
+ const rows = file
291
+ ? d.prepare('SELECT * FROM comments WHERE file = ? ORDER BY createdAt').all(file)
292
+ : d.prepare('SELECT * FROM comments ORDER BY createdAt').all();
293
+ return rows.map(toComment);
294
+ },
295
+ async updateComment(id, patch) {
296
+ const d = await open();
297
+ const row = d.prepare('SELECT * FROM comments WHERE id = ?').get(id);
298
+ if (!row) return null;
299
+ const c = { ...toComment(row), ...patch, id };
300
+ writeComment(d, c);
301
+ return c;
302
+ },
303
+ async deleteComment(id) {
304
+ const d = await open();
305
+ return d.prepare('DELETE FROM comments WHERE id = ?').run(id).changes > 0;
306
+ },
307
+ async recordEvent(e) {
308
+ const d = await open();
309
+ const ev = newEvent(e);
310
+ d.prepare('INSERT INTO events (id, ts, type, file, query, count) VALUES (?, ?, ?, ?, ?, ?)')
311
+ .run(ev.id, ev.ts, ev.type, ev.file, ev.query, ev.count);
312
+ },
313
+ async usageStats() {
314
+ const d = await open();
315
+ return summarizeEvents(d.prepare('SELECT type, file, query FROM events').all());
316
+ },
155
317
  async close({ keep } = {}) {
156
318
  if (db) { db.close(); db = null; }
157
319
  if (cleanup && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
@@ -176,6 +338,11 @@ function customStore(url) {
176
338
 
177
339
  function postgresStore(url) {
178
340
  let client = null;
341
+ const toComment = (r) => ({
342
+ id: r.id, file: r.file,
343
+ anchor: typeof r.anchor === 'string' ? JSON.parse(r.anchor) : (r.anchor || { type: 'file' }),
344
+ body: r.body, author: r.author, createdAt: Number(r.createdat), resolved: !!r.resolved
345
+ });
179
346
  const open = async () => {
180
347
  if (client) return client;
181
348
  let pg;
@@ -183,11 +350,16 @@ function postgresStore(url) {
183
350
  catch { throw new Error('the postgres store needs the "pg" package - run: npm i pg'); }
184
351
  client = new pg.Client({ connectionString: url });
185
352
  await client.connect();
186
- await client.query(
187
- 'CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)'
188
- );
353
+ await client.query('CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)');
354
+ await client.query('CREATE TABLE IF NOT EXISTS projview_comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdat DOUBLE PRECISION, resolved BOOLEAN)');
355
+ await client.query('CREATE TABLE IF NOT EXISTS projview_events (id TEXT PRIMARY KEY, ts DOUBLE PRECISION, type TEXT, file TEXT, query TEXT, count INTEGER)');
189
356
  return client;
190
357
  };
358
+ const writeComment = (c) => client.query(
359
+ 'INSERT INTO projview_comments (id, file, anchor, body, author, createdat, resolved) VALUES ($1, $2, $3, $4, $5, $6, $7)' +
360
+ ' ON CONFLICT (id) DO UPDATE SET file = $2, anchor = $3, body = $4, author = $5, createdat = $6, resolved = $7',
361
+ [c.id, c.file, JSON.stringify(c.anchor), c.body, c.author, c.createdAt, c.resolved]
362
+ );
191
363
  return {
192
364
  kind: 'custom',
193
365
  async load() {
@@ -220,7 +392,43 @@ function postgresStore(url) {
220
392
  const c = await open();
221
393
  await c.query('DELETE FROM projview_files WHERE path = $1', [p]);
222
394
  },
223
- async close() { if (client) { await client.end(); client = null; } }, // never drop the user's table
395
+ async addComment(c) {
396
+ await open();
397
+ const comment = newComment(c);
398
+ await writeComment(comment);
399
+ return comment;
400
+ },
401
+ async listComments(file) {
402
+ const c = await open();
403
+ const { rows } = file
404
+ ? await c.query('SELECT * FROM projview_comments WHERE file = $1 ORDER BY createdat', [file])
405
+ : await c.query('SELECT * FROM projview_comments ORDER BY createdat');
406
+ return rows.map(toComment);
407
+ },
408
+ async updateComment(id, patch) {
409
+ const c = await open();
410
+ const { rows } = await c.query('SELECT * FROM projview_comments WHERE id = $1', [id]);
411
+ if (!rows.length) return null;
412
+ const merged = { ...toComment(rows[0]), ...patch, id };
413
+ await writeComment(merged);
414
+ return merged;
415
+ },
416
+ async deleteComment(id) {
417
+ const c = await open();
418
+ return (await c.query('DELETE FROM projview_comments WHERE id = $1', [id])).rowCount > 0;
419
+ },
420
+ async recordEvent(e) {
421
+ const c = await open();
422
+ const ev = newEvent(e);
423
+ await c.query('INSERT INTO projview_events (id, ts, type, file, query, count) VALUES ($1, $2, $3, $4, $5, $6)',
424
+ [ev.id, ev.ts, ev.type, ev.file, ev.query, ev.count]);
425
+ },
426
+ async usageStats() {
427
+ const c = await open();
428
+ const { rows } = await c.query('SELECT type, file, query FROM projview_events');
429
+ return summarizeEvents(rows);
430
+ },
431
+ async close() { if (client) { await client.end(); client = null; } }, // never drop the user's tables
224
432
  describe() { return `custom/postgres ${redact(url)}`; }
225
433
  };
226
434
  }