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/bin/projview.js +91 -7
- package/docs/how-to-set-up.md +15 -7
- package/docs/reference/api.md +34 -3
- package/package.json +1 -1
- package/public/comments.js +400 -0
- package/public/live.js +5 -0
- package/public/main.js +1 -0
- package/public/store.js +2 -1
- package/public/style.css +104 -0
- package/public/tree.js +2 -1
- package/public/viewer.js +3 -0
- package/src/server.js +81 -3
- package/src/store.js +234 -26
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
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'
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
209
|
+
if (loaded) return;
|
|
93
210
|
try {
|
|
94
211
|
const d = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
107
|
-
async
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
async
|
|
112
|
-
async
|
|
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
|
-
|
|
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
|
|
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
|
}
|