projview 0.7.0 → 0.8.1
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 +13 -0
- package/bin/projview.js +5 -12
- package/docs/how-to-set-up.md +1 -1
- package/docs/reference/api.md +30 -2
- 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 +76 -2
- package/src/store.js +7 -0
package/README.md
CHANGED
|
@@ -34,6 +34,9 @@ and exit (no server, no browser), so an agent or script can query your docs:
|
|
|
34
34
|
```bash
|
|
35
35
|
projview search "<query>" [path] # ranked: filename matches + in-content hits w/ line snippets
|
|
36
36
|
projview tree [path] # the file tree as JSON
|
|
37
|
+
projview comment add <file> "…" # leave a comment (--heading <id> to anchor a section)
|
|
38
|
+
projview comments [file] # list comments as JSON
|
|
39
|
+
projview usage [path] # views + searches recorded by the viewer
|
|
37
40
|
projview search "mermaid" --demo # quick test on the bundled docs
|
|
38
41
|
```
|
|
39
42
|
|
|
@@ -53,9 +56,19 @@ projview search "mermaid" --demo # quick test on the bundled docs
|
|
|
53
56
|
| index | builds an in-memory tree (nothing written to disk by default) |
|
|
54
57
|
| serve | spawns a localhost site + opens your browser |
|
|
55
58
|
| live | edits to files reload in the browser automatically |
|
|
59
|
+
| comment | leave notes on a file, a section, or a text selection (browser or CLI) |
|
|
56
60
|
|
|
57
61
|
> ephemeral by default - ctrl-c and it's gone. opt into persistence below.
|
|
58
62
|
|
|
63
|
+
## comments
|
|
64
|
+
|
|
65
|
+
Leave notes on a whole file, a heading/section, or a selected passage - from the
|
|
66
|
+
viewer (the 💬 button, bottom-right) or via the CLI / HTTP API. Text comments
|
|
67
|
+
**re-anchor** as the doc changes and only flag themselves *orphaned* if the quoted
|
|
68
|
+
text truly disappears. Comments persist when you opt into a store
|
|
69
|
+
(`--persist` / `--store-url`); writes are **localhost-only** (an exposed instance
|
|
70
|
+
is read-only).
|
|
71
|
+
|
|
59
72
|
## persistence (opt-in)
|
|
60
73
|
|
|
61
74
|
By default the index is in-memory and nothing is written to disk. Opt in for a
|
package/bin/projview.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import { spawn, execSync } from 'node:child_process';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { readFileSync, existsSync } from 'node:fs';
|
|
7
6
|
import { startServer } from '../src/server.js';
|
|
8
7
|
import { buildTree, countFiles } from '../src/walk.js';
|
|
9
8
|
import { buildIndex, search } from '../src/search.js';
|
|
10
|
-
import { resolveStoreConfig, createStore, isPersistent } from '../src/store.js';
|
|
9
|
+
import { resolveStoreConfig, createStore, isPersistent, gitOrOsUser } from '../src/store.js';
|
|
11
10
|
|
|
12
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
12
|
const DEMO_DIR = path.join(__dirname, '..', 'docs'); // bundled sample docs
|
|
@@ -87,14 +86,7 @@ const print = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
|
|
|
87
86
|
|
|
88
87
|
// Resolve the comment author: explicit flag > git user.name > OS username.
|
|
89
88
|
// Agents pass --author (e.g. "claude") so AI-authored comments are distinguishable.
|
|
90
|
-
|
|
91
|
-
if (explicit) return explicit;
|
|
92
|
-
try {
|
|
93
|
-
const name = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
94
|
-
if (name) return name;
|
|
95
|
-
} catch { /* no git / no user.name */ }
|
|
96
|
-
try { return os.userInfo().username || null; } catch { return null; }
|
|
97
|
-
}
|
|
89
|
+
const resolveAuthor = (explicit) => explicit || gitOrOsUser();
|
|
98
90
|
|
|
99
91
|
// projview comment <add|resolve|reopen|rm> ... (writes ride the store)
|
|
100
92
|
async function cmdComment(args) {
|
|
@@ -222,13 +214,14 @@ async function main() {
|
|
|
222
214
|
}
|
|
223
215
|
|
|
224
216
|
const storeConfig = resolveStoreConfig(opts);
|
|
225
|
-
const { port, close, storeInfo } = await startServer(opts.root, { port: opts.port, host: opts.host, store: storeConfig });
|
|
217
|
+
const { port, close, storeInfo, writable } = await startServer(opts.root, { port: opts.port, host: opts.host, store: storeConfig });
|
|
226
218
|
const url = `http://${opts.host}:${port}`;
|
|
227
219
|
|
|
228
220
|
console.log(`\n projview -> ${url}`);
|
|
229
221
|
console.log(` indexing -> ${opts.root}`);
|
|
230
222
|
console.log(` store -> ${storeInfo.describe}`);
|
|
231
223
|
if (storeInfo.warm) console.log(` warm -> reused cache, reindexed ${storeInfo.reindexed}/${storeInfo.total} changed`);
|
|
224
|
+
if (!writable) console.log(` comments -> read-only (exposed on ${opts.host})`);
|
|
232
225
|
console.log(` (ctrl-c to stop)\n`);
|
|
233
226
|
|
|
234
227
|
if (opts.open) openBrowser(url);
|
package/docs/how-to-set-up.md
CHANGED
|
@@ -67,7 +67,7 @@ npm install
|
|
|
67
67
|
npm run demo # preview the bundled docs/ (alias for --demo)
|
|
68
68
|
npm start # index the current directory
|
|
69
69
|
node bin/projview.js ./docs # or call the CLI directly
|
|
70
|
-
npm test # run the test suite (node:test,
|
|
70
|
+
npm test # run the test suite (node:test, 49 tests)
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
See the [project README](README.md) for the architecture and the
|
package/docs/reference/api.md
CHANGED
|
@@ -12,15 +12,36 @@ state it records is its own usage events (views / searches) into the store.
|
|
|
12
12
|
|
|
13
13
|
### GET /api/tree
|
|
14
14
|
|
|
15
|
-
Returns the in-memory file tree as JSON
|
|
15
|
+
Returns the in-memory file tree as JSON, plus the `version` and a `writable`
|
|
16
|
+
flag (false when projview is exposed on a non-loopback host).
|
|
16
17
|
|
|
17
18
|
### GET /api/file
|
|
18
19
|
|
|
19
20
|
Renders a single file to HTML on demand. Query param `p` is the relative path.
|
|
21
|
+
Records a `view` usage event.
|
|
20
22
|
|
|
21
23
|
### GET /api/events
|
|
22
24
|
|
|
23
|
-
Server-sent events stream for live reload
|
|
25
|
+
Server-sent events stream: `change` / `tree` for live reload, `comments` when a
|
|
26
|
+
comment is created/edited/deleted.
|
|
27
|
+
|
|
28
|
+
### GET /api/comments
|
|
29
|
+
|
|
30
|
+
Lists comments as JSON. Optional `?file=<path>` filters to a single file.
|
|
31
|
+
|
|
32
|
+
### POST /api/comments
|
|
33
|
+
|
|
34
|
+
Creates a comment from `{ file, body, anchor?, author? }`. **Loopback only** -
|
|
35
|
+
returns `403` when bound to a non-loopback host. Author defaults to the server's
|
|
36
|
+
git/OS user when omitted.
|
|
37
|
+
|
|
38
|
+
### PATCH /api/comments/:id · DELETE /api/comments/:id
|
|
39
|
+
|
|
40
|
+
Resolve/edit (`{ resolved?, body? }`) or delete a comment. Loopback only.
|
|
41
|
+
|
|
42
|
+
### GET /api/usage
|
|
43
|
+
|
|
44
|
+
Aggregated usage: `{ total, byType, topFiles[], recentSearches[] }`.
|
|
24
45
|
|
|
25
46
|
## machine interface (CLI)
|
|
26
47
|
|
|
@@ -115,6 +136,13 @@ On-demand fetch + render, in-app link routing, anchor jumps.
|
|
|
115
136
|
|
|
116
137
|
A chokidar watcher broadcasts change/tree events over SSE.
|
|
117
138
|
|
|
139
|
+
### comments
|
|
140
|
+
|
|
141
|
+
A drawer (the 💬 button, bottom-right) lists comments for the open file. Add one
|
|
142
|
+
by selecting text, hovering a heading, or "+ note". Text anchors re-anchor on
|
|
143
|
+
each render and paint via the CSS Custom Highlight API; create/resolve/delete
|
|
144
|
+
controls hide when the server isn't writable.
|
|
145
|
+
|
|
118
146
|
## stores (persistence)
|
|
119
147
|
|
|
120
148
|
The index - plus **comments** and **usage events** - is in-memory by default
|
package/package.json
CHANGED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/* comments: a right-side drawer + inline anchoring.
|
|
2
|
+
*
|
|
3
|
+
* Anchors come in three flavours (see src/store.js): whole-file, a heading
|
|
4
|
+
* (section), or a text quote. Text quotes are re-anchored on every render via a
|
|
5
|
+
* {exact, prefix, suffix} selector - if the text moved we re-find it, if it
|
|
6
|
+
* vanished the comment is flagged "orphaned" (never lost). Highlights paint with
|
|
7
|
+
* the CSS Custom Highlight API (no DOM mutation), so the TOC / search / mermaid
|
|
8
|
+
* are untouched. Writes are hidden when the server says it isn't writable. */
|
|
9
|
+
|
|
10
|
+
import { $, esc } from './dom.js';
|
|
11
|
+
import { state } from './store.js';
|
|
12
|
+
import { toast } from './toast.js';
|
|
13
|
+
|
|
14
|
+
const contentEl = $('content');
|
|
15
|
+
const viewerEl = $('viewer');
|
|
16
|
+
const HL = typeof CSS !== 'undefined' && CSS.highlights; // Custom Highlight API?
|
|
17
|
+
|
|
18
|
+
let current = []; // comments for the current file
|
|
19
|
+
const ranges = new Map(); // id -> resolved Range (text anchors only)
|
|
20
|
+
let activeId = null;
|
|
21
|
+
let pending = null; // { anchor, quote } while composing a new comment
|
|
22
|
+
|
|
23
|
+
/* ---------------- scaffold (built once) ---------------- */
|
|
24
|
+
|
|
25
|
+
const cre = (tag, cls, html) => {
|
|
26
|
+
const e = document.createElement(tag);
|
|
27
|
+
if (cls) e.className = cls;
|
|
28
|
+
if (html != null) e.innerHTML = html;
|
|
29
|
+
return e;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const fab = cre('button', 'cmt-fab', '💬<span class="cmt-badge" hidden>0</span>');
|
|
33
|
+
fab.title = 'comments';
|
|
34
|
+
fab.hidden = true;
|
|
35
|
+
|
|
36
|
+
const drawer = cre('aside', 'cmt-drawer');
|
|
37
|
+
drawer.hidden = true;
|
|
38
|
+
drawer.innerHTML =
|
|
39
|
+
`<header class="cmt-drawer-head">
|
|
40
|
+
<div><strong>comments</strong> <span class="cmt-file"></span></div>
|
|
41
|
+
<div class="cmt-head-btns">
|
|
42
|
+
<button class="cmt-add-file" title="note on the whole file">+ note</button>
|
|
43
|
+
<button class="cmt-close" title="close">✕</button>
|
|
44
|
+
</div>
|
|
45
|
+
</header>
|
|
46
|
+
<div class="cmt-composer-slot"></div>
|
|
47
|
+
<div class="cmt-list"></div>`;
|
|
48
|
+
|
|
49
|
+
const chip = cre('button', 'cmt-selchip', '💬 comment');
|
|
50
|
+
chip.hidden = true;
|
|
51
|
+
|
|
52
|
+
document.body.append(fab, drawer, chip);
|
|
53
|
+
|
|
54
|
+
const listEl = drawer.querySelector('.cmt-list');
|
|
55
|
+
const composerSlot = drawer.querySelector('.cmt-composer-slot');
|
|
56
|
+
const fileEl = drawer.querySelector('.cmt-file');
|
|
57
|
+
const addFileBtn = drawer.querySelector('.cmt-add-file');
|
|
58
|
+
|
|
59
|
+
fab.addEventListener('click', () => toggleDrawer());
|
|
60
|
+
drawer.querySelector('.cmt-close').addEventListener('click', () => toggleDrawer(false));
|
|
61
|
+
addFileBtn.addEventListener('click', () => startCompose({ type: 'file' }, null));
|
|
62
|
+
|
|
63
|
+
/* ---------------- public entry points ---------------- */
|
|
64
|
+
|
|
65
|
+
// Called by the viewer after a file renders: load + anchor + paint + list.
|
|
66
|
+
export async function onFileRendered(path) {
|
|
67
|
+
pending = null;
|
|
68
|
+
activeId = null;
|
|
69
|
+
if (!path) { fab.hidden = true; clearPaint(); return; }
|
|
70
|
+
await loadComments(path);
|
|
71
|
+
fileEl.textContent = path.split('/').pop();
|
|
72
|
+
reanchor();
|
|
73
|
+
renderComposer();
|
|
74
|
+
renderList();
|
|
75
|
+
addHeadingAffordances();
|
|
76
|
+
fab.hidden = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Called on an SSE 'comments' event: refresh data + markers without touching the
|
|
80
|
+
// document render or an open composer.
|
|
81
|
+
export async function refreshComments() {
|
|
82
|
+
if (!state.currentPath) return;
|
|
83
|
+
await loadComments(state.currentPath);
|
|
84
|
+
reanchor();
|
|
85
|
+
renderList();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ---------------- data ---------------- */
|
|
89
|
+
|
|
90
|
+
async function loadComments(path) {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`/api/comments?file=${encodeURIComponent(path)}`);
|
|
93
|
+
current = res.ok ? await res.json() : [];
|
|
94
|
+
} catch { current = []; }
|
|
95
|
+
updateBadge();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function api(method, url, body) {
|
|
99
|
+
const res = await fetch(url, {
|
|
100
|
+
method,
|
|
101
|
+
headers: body ? { 'content-type': 'application/json' } : undefined,
|
|
102
|
+
body: body ? JSON.stringify(body) : undefined
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) { toast(res.status === 403 ? 'comments are read-only here' : 'comment failed'); return null; }
|
|
105
|
+
return res.json();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function submitCompose(body) {
|
|
109
|
+
if (!body.trim() || !pending) return;
|
|
110
|
+
const created = await api('POST', '/api/comments', { file: state.currentPath, body: body.trim(), anchor: pending.anchor });
|
|
111
|
+
if (!created) return;
|
|
112
|
+
pending = null;
|
|
113
|
+
await refreshComments();
|
|
114
|
+
renderComposer();
|
|
115
|
+
openComment(created.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function setResolved(id, resolved) {
|
|
119
|
+
const c = await api('PATCH', `/api/comments/${encodeURIComponent(id)}`, { resolved });
|
|
120
|
+
if (c) await refreshComments();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function remove(id) {
|
|
124
|
+
const r = await api('DELETE', `/api/comments/${encodeURIComponent(id)}`);
|
|
125
|
+
if (r) { if (activeId === id) activeId = null; await refreshComments(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ---------------- re-anchoring engine ---------------- */
|
|
129
|
+
|
|
130
|
+
// Flat text of the content + a map from string offset -> text node.
|
|
131
|
+
function segments() {
|
|
132
|
+
const segs = [];
|
|
133
|
+
let text = '';
|
|
134
|
+
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
|
|
135
|
+
let node;
|
|
136
|
+
while ((node = walker.nextNode())) {
|
|
137
|
+
segs.push({ node, start: text.length });
|
|
138
|
+
text += node.nodeValue;
|
|
139
|
+
}
|
|
140
|
+
return { text, segs };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pointAt(segs, offset) {
|
|
144
|
+
for (const s of segs) {
|
|
145
|
+
if (offset <= s.start + s.node.nodeValue.length) return { node: s.node, offset: Math.max(0, offset - s.start) };
|
|
146
|
+
}
|
|
147
|
+
const last = segs[segs.length - 1];
|
|
148
|
+
return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const commonPrefix = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i; };
|
|
152
|
+
const commonSuffix = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[a.length - 1 - i] === b[b.length - 1 - i]) i++; return i; };
|
|
153
|
+
|
|
154
|
+
// Resolve a {exact, prefix, suffix} quote selector to a Range, disambiguating
|
|
155
|
+
// repeats by surrounding context. Returns null if `exact` isn't present.
|
|
156
|
+
function resolveText(anchor, ctx) {
|
|
157
|
+
const { text } = ctx;
|
|
158
|
+
const exact = anchor.exact || '';
|
|
159
|
+
if (!exact) return null;
|
|
160
|
+
let best = -1, bestScore = -1;
|
|
161
|
+
let idx = text.indexOf(exact);
|
|
162
|
+
while (idx !== -1) {
|
|
163
|
+
const before = text.slice(Math.max(0, idx - (anchor.prefix?.length || 0)), idx);
|
|
164
|
+
const after = text.slice(idx + exact.length, idx + exact.length + (anchor.suffix?.length || 0));
|
|
165
|
+
const score = commonSuffix(before, anchor.prefix || '') + commonPrefix(after, anchor.suffix || '');
|
|
166
|
+
if (score > bestScore) { bestScore = score; best = idx; }
|
|
167
|
+
idx = text.indexOf(exact, idx + 1);
|
|
168
|
+
}
|
|
169
|
+
if (best === -1) return null;
|
|
170
|
+
const a = pointAt(ctx.segs, best);
|
|
171
|
+
const b = pointAt(ctx.segs, best + exact.length);
|
|
172
|
+
if (!a || !b) return null;
|
|
173
|
+
const range = document.createRange();
|
|
174
|
+
try { range.setStart(a.node, a.offset); range.setEnd(b.node, b.offset); } catch { return null; }
|
|
175
|
+
return range;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function reanchor() {
|
|
179
|
+
ranges.clear();
|
|
180
|
+
const ctx = segments();
|
|
181
|
+
for (const c of current) {
|
|
182
|
+
c._orphan = false;
|
|
183
|
+
if (c.anchor?.type === 'text') {
|
|
184
|
+
const r = resolveText(c.anchor, ctx);
|
|
185
|
+
if (r) ranges.set(c.id, r); else c._orphan = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
paint();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function paint() {
|
|
192
|
+
if (!HL) return;
|
|
193
|
+
CSS.highlights.set('pv-comment', new Highlight(...[...ranges.values()]));
|
|
194
|
+
const act = activeId && ranges.get(activeId);
|
|
195
|
+
CSS.highlights.set('pv-comment-active', act ? new Highlight(act) : new Highlight());
|
|
196
|
+
}
|
|
197
|
+
function clearPaint() { if (HL) { CSS.highlights.delete('pv-comment'); CSS.highlights.delete('pv-comment-active'); } ranges.clear(); }
|
|
198
|
+
|
|
199
|
+
/* ---------------- inline affordances ---------------- */
|
|
200
|
+
|
|
201
|
+
// Click inside the content: if it lands on a highlighted quote, open that comment.
|
|
202
|
+
contentEl.addEventListener('click', (e) => {
|
|
203
|
+
if (!ranges.size || !document.caretRangeFromPoint) return;
|
|
204
|
+
const caret = document.caretRangeFromPoint(e.clientX, e.clientY);
|
|
205
|
+
if (!caret) return;
|
|
206
|
+
for (const [id, r] of ranges) {
|
|
207
|
+
try { if (r.isPointInRange(caret.startContainer, caret.startOffset)) { openComment(id); return; } } catch { /* different root */ }
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// A hover "💬" at the end of every heading -> comment on that section.
|
|
212
|
+
function addHeadingAffordances() {
|
|
213
|
+
if (!state.writable) return;
|
|
214
|
+
for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
|
|
215
|
+
if (!h.id || h.querySelector('.cmt-head-add')) continue;
|
|
216
|
+
const label = h.textContent;
|
|
217
|
+
const b = cre('button', 'cmt-head-add'); // text comes from CSS (keeps h.textContent clean)
|
|
218
|
+
b.title = 'comment on this section';
|
|
219
|
+
b.addEventListener('click', (ev) => { ev.stopPropagation(); startCompose({ type: 'heading', id: h.id }, label); });
|
|
220
|
+
h.appendChild(b);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Text selection -> a floating "comment" chip near the selection.
|
|
225
|
+
viewerEl.addEventListener('mouseup', () => {
|
|
226
|
+
if (!state.writable) { chip.hidden = true; return; }
|
|
227
|
+
const sel = window.getSelection();
|
|
228
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) { chip.hidden = true; return; }
|
|
229
|
+
const range = sel.getRangeAt(0);
|
|
230
|
+
if (!contentEl.contains(range.commonAncestorContainer)) { chip.hidden = true; return; }
|
|
231
|
+
const rect = range.getBoundingClientRect();
|
|
232
|
+
chip.style.top = `${Math.max(8, rect.top - 40)}px`;
|
|
233
|
+
chip.style.left = `${rect.left}px`;
|
|
234
|
+
chip.hidden = false;
|
|
235
|
+
});
|
|
236
|
+
chip.addEventListener('mousedown', (e) => e.preventDefault()); // don't clear the selection
|
|
237
|
+
chip.addEventListener('click', () => {
|
|
238
|
+
const anchor = captureTextAnchor();
|
|
239
|
+
chip.hidden = true;
|
|
240
|
+
if (anchor) startCompose(anchor, anchor.exact);
|
|
241
|
+
else toast('select some text first');
|
|
242
|
+
});
|
|
243
|
+
document.addEventListener('mousedown', (e) => { if (e.target !== chip && !chip.hidden) chip.hidden = true; });
|
|
244
|
+
|
|
245
|
+
function captureTextAnchor() {
|
|
246
|
+
const sel = window.getSelection();
|
|
247
|
+
if (!sel || sel.isCollapsed) return null;
|
|
248
|
+
const range = sel.getRangeAt(0);
|
|
249
|
+
if (!contentEl.contains(range.commonAncestorContainer)) return null;
|
|
250
|
+
const exact = sel.toString();
|
|
251
|
+
const flat = contentEl.textContent;
|
|
252
|
+
const pre = document.createRange();
|
|
253
|
+
pre.setStart(contentEl, 0);
|
|
254
|
+
pre.setEnd(range.startContainer, range.startOffset);
|
|
255
|
+
const startOff = pre.toString().length;
|
|
256
|
+
const endOff = startOff + exact.length;
|
|
257
|
+
return {
|
|
258
|
+
type: 'text',
|
|
259
|
+
exact,
|
|
260
|
+
prefix: flat.slice(Math.max(0, startOff - 32), startOff),
|
|
261
|
+
suffix: flat.slice(endOff, endOff + 32),
|
|
262
|
+
section: nearestHeadingId(range)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nearestHeadingId(range) {
|
|
267
|
+
const top = range.getBoundingClientRect().top;
|
|
268
|
+
let id = null;
|
|
269
|
+
for (const h of contentEl.querySelectorAll('h1, h2, h3')) {
|
|
270
|
+
if (!h.id) continue;
|
|
271
|
+
if (h.getBoundingClientRect().top <= top) id = h.id; else break;
|
|
272
|
+
}
|
|
273
|
+
return id;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ---------------- composer + list ---------------- */
|
|
277
|
+
|
|
278
|
+
function startCompose(anchor, quote) {
|
|
279
|
+
if (!state.writable) { toast('comments are read-only here'); return; }
|
|
280
|
+
pending = { anchor, quote };
|
|
281
|
+
toggleDrawer(true);
|
|
282
|
+
renderComposer();
|
|
283
|
+
const ta = composerSlot.querySelector('textarea');
|
|
284
|
+
if (ta) ta.focus();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderComposer() {
|
|
288
|
+
composerSlot.replaceChildren();
|
|
289
|
+
if (!pending || !state.writable) return;
|
|
290
|
+
const box = cre('div', 'cmt-composer');
|
|
291
|
+
box.append(cre('div', 'cmt-anchor-chip', anchorLabel(pending.anchor, pending.quote)));
|
|
292
|
+
const ta = cre('textarea');
|
|
293
|
+
ta.placeholder = 'add a comment…';
|
|
294
|
+
ta.rows = 3;
|
|
295
|
+
const actions = cre('div', 'cmt-composer-actions');
|
|
296
|
+
const save = cre('button', 'cmt-btn primary', 'comment');
|
|
297
|
+
const cancel = cre('button', 'cmt-btn', 'cancel');
|
|
298
|
+
save.addEventListener('click', () => submitCompose(ta.value));
|
|
299
|
+
cancel.addEventListener('click', () => { pending = null; renderComposer(); });
|
|
300
|
+
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitCompose(ta.value); });
|
|
301
|
+
actions.append(save, cancel);
|
|
302
|
+
box.append(ta, actions);
|
|
303
|
+
composerSlot.append(box);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderList() {
|
|
307
|
+
listEl.replaceChildren();
|
|
308
|
+
if (!current.length) {
|
|
309
|
+
listEl.append(cre('div', 'cmt-empty', '💭<br>no comments yet' + (state.writable ? '<br><small>select text, hover a heading, or “+ note”</small>' : '')));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
for (const c of current) {
|
|
313
|
+
const card = cre('div', 'cmt-card' + (c.resolved ? ' resolved' : '') + (c._orphan ? ' orphan' : '') + (c.id === activeId ? ' active' : ''));
|
|
314
|
+
card.dataset.id = c.id;
|
|
315
|
+
const meta = cre('div', 'cmt-meta',
|
|
316
|
+
`<span class="cmt-author">${esc(c.author || 'anon')}</span><span class="cmt-time">${ago(c.createdAt)}</span>`);
|
|
317
|
+
const chipEl = cre('div', 'cmt-anchor-chip' + (c._orphan ? ' orphan' : ''), anchorLabel(c.anchor) + (c._orphan ? ' · not found' : ''));
|
|
318
|
+
const bodyEl = cre('div', 'cmt-body', esc(c.body).replace(/\n/g, '<br>'));
|
|
319
|
+
card.append(meta, chipEl, bodyEl);
|
|
320
|
+
if (state.writable) {
|
|
321
|
+
const actions = cre('div', 'cmt-actions');
|
|
322
|
+
const res = cre('button', 'cmt-link', c.resolved ? 'reopen' : 'resolve');
|
|
323
|
+
const del = cre('button', 'cmt-link danger', 'delete');
|
|
324
|
+
res.addEventListener('click', (e) => { e.stopPropagation(); setResolved(c.id, !c.resolved); });
|
|
325
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); remove(c.id); });
|
|
326
|
+
actions.append(res, del);
|
|
327
|
+
card.append(actions);
|
|
328
|
+
}
|
|
329
|
+
card.addEventListener('click', () => openComment(c.id));
|
|
330
|
+
listEl.append(card);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* ---------------- navigation ---------------- */
|
|
335
|
+
|
|
336
|
+
function openComment(id) {
|
|
337
|
+
activeId = id;
|
|
338
|
+
toggleDrawer(true);
|
|
339
|
+
paint();
|
|
340
|
+
renderList();
|
|
341
|
+
const card = listEl.querySelector(`.cmt-card[data-id="${CSS.escape(id)}"]`);
|
|
342
|
+
if (card) card.scrollIntoView({ block: 'nearest' });
|
|
343
|
+
const c = current.find((x) => x.id === id);
|
|
344
|
+
if (c) jumpToAnchor(c);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function jumpToAnchor(c) {
|
|
348
|
+
const a = c.anchor || {};
|
|
349
|
+
if (a.type === 'text') {
|
|
350
|
+
const r = ranges.get(c.id);
|
|
351
|
+
if (r) { scrollRangeIntoView(r); return; }
|
|
352
|
+
if (a.section) { document.getElementById(a.section)?.scrollIntoView({ block: 'start' }); return; }
|
|
353
|
+
toast('original text not found - comment is orphaned');
|
|
354
|
+
} else if (a.type === 'heading') {
|
|
355
|
+
const el = document.getElementById(a.id);
|
|
356
|
+
if (el) el.scrollIntoView({ block: 'start' }); else toast('section no longer exists');
|
|
357
|
+
} else {
|
|
358
|
+
viewerEl.scrollTop = 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function scrollRangeIntoView(range) {
|
|
363
|
+
const r = range.getBoundingClientRect();
|
|
364
|
+
const v = viewerEl.getBoundingClientRect();
|
|
365
|
+
if (r.height || r.width) viewerEl.scrollTop += (r.top - v.top) - 120;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ---------------- bits ---------------- */
|
|
369
|
+
|
|
370
|
+
function toggleDrawer(force) {
|
|
371
|
+
const open = force === undefined ? drawer.hidden : force;
|
|
372
|
+
drawer.hidden = !open;
|
|
373
|
+
fab.classList.toggle('open', open);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function updateBadge() {
|
|
377
|
+
const open = current.filter((c) => !c.resolved).length;
|
|
378
|
+
const badge = fab.querySelector('.cmt-badge');
|
|
379
|
+
badge.textContent = String(open);
|
|
380
|
+
badge.hidden = open === 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function anchorLabel(a, quoteOverride) {
|
|
384
|
+
if (!a) return 'file';
|
|
385
|
+
if (a.type === 'file') return '📄 whole file';
|
|
386
|
+
if (a.type === 'heading') {
|
|
387
|
+
const el = document.getElementById(a.id);
|
|
388
|
+
return '§ ' + (quoteOverride || (el ? el.textContent : a.id));
|
|
389
|
+
}
|
|
390
|
+
const q = (quoteOverride || a.exact || '').replace(/\s+/g, ' ').trim();
|
|
391
|
+
return '“' + (q.length > 60 ? q.slice(0, 60) + '…' : q) + '”';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function ago(ts) {
|
|
395
|
+
const s = Math.max(0, (Date.now() - ts) / 1000);
|
|
396
|
+
if (s < 60) return 'just now';
|
|
397
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
398
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
399
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
400
|
+
}
|
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,7 +99,7 @@ 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
|
|
|
@@ -105,6 +130,54 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
105
130
|
return;
|
|
106
131
|
}
|
|
107
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
|
+
|
|
108
181
|
// --- static SPA assets (incl. /vendor/*) ---
|
|
109
182
|
const assetName = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
110
183
|
const assetPath = resolveInRoot(PUBLIC_DIR, assetName);
|
|
@@ -156,6 +229,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
156
229
|
server,
|
|
157
230
|
port: listenPort,
|
|
158
231
|
close,
|
|
232
|
+
writable: allowWrites,
|
|
159
233
|
storeInfo: { describe: store.describe(), warm: indexInfo.warm, reindexed: indexInfo.reindexed, total: indexInfo.total }
|
|
160
234
|
};
|
|
161
235
|
}
|
package/src/store.js
CHANGED
|
@@ -24,6 +24,7 @@ import fs from 'node:fs';
|
|
|
24
24
|
import fsp from 'node:fs/promises';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import os from 'node:os';
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
27
28
|
import { createHash, randomUUID } from 'node:crypto';
|
|
28
29
|
|
|
29
30
|
// node:sqlite is flagged "experimental" and emits a noisy warning on use; we
|
|
@@ -43,6 +44,12 @@ const keyFor = (root) => createHash('sha256').update(path.resolve(root)).digest(
|
|
|
43
44
|
const ensureDir = (d) => fs.mkdirSync(d, { recursive: true });
|
|
44
45
|
const redact = (url) => url.replace(/\/\/[^@/]*@/, '//***@'); // hide creds in logs
|
|
45
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
|
+
|
|
46
53
|
// Normalize loose input into a full comment / event record (defaults + ids).
|
|
47
54
|
const newComment = (c) => ({
|
|
48
55
|
id: c.id || randomUUID(),
|