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/bin/projview.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
6
|
import { startServer } from '../src/server.js';
|
|
7
7
|
import { buildTree, countFiles } from '../src/walk.js';
|
|
8
8
|
import { buildIndex, search } from '../src/search.js';
|
|
9
|
-
import { resolveStoreConfig, createStore } from '../src/store.js';
|
|
9
|
+
import { resolveStoreConfig, createStore, isPersistent, gitOrOsUser } from '../src/store.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const DEMO_DIR = path.join(__dirname, '..', 'docs'); // bundled sample docs
|
|
@@ -50,6 +50,8 @@ function parseSubArgs(args) {
|
|
|
50
50
|
else if (a === '--store-url') o.storeUrl = args[++i];
|
|
51
51
|
else if (a === '--persist') o.persist = true;
|
|
52
52
|
else if (a === '--demo') o.demo = true;
|
|
53
|
+
else if (a === '--heading') o.heading = args[++i];
|
|
54
|
+
else if (a === '--author') o.author = args[++i];
|
|
53
55
|
else if (a.startsWith('-')) { /* ignore unknown flag in a subcommand */ }
|
|
54
56
|
else o.positional.push(a);
|
|
55
57
|
}
|
|
@@ -80,6 +82,77 @@ function cmdTree(args) {
|
|
|
80
82
|
process.stdout.write(JSON.stringify({ root: path.basename(root) || root, files: countFiles(tree), tree }, null, 2) + '\n');
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const print = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
86
|
+
|
|
87
|
+
// Resolve the comment author: explicit flag > git user.name > OS username.
|
|
88
|
+
// Agents pass --author (e.g. "claude") so AI-authored comments are distinguishable.
|
|
89
|
+
const resolveAuthor = (explicit) => explicit || gitOrOsUser();
|
|
90
|
+
|
|
91
|
+
// projview comment <add|resolve|reopen|rm> ... (writes ride the store)
|
|
92
|
+
async function cmdComment(args) {
|
|
93
|
+
const sub = args[0];
|
|
94
|
+
const o = parseSubArgs(args.slice(1));
|
|
95
|
+
const cfg = resolveStoreConfig(o);
|
|
96
|
+
const root = o.demo ? DEMO_DIR : process.cwd();
|
|
97
|
+
const store = createStore(cfg, root);
|
|
98
|
+
try {
|
|
99
|
+
if (sub === 'add') {
|
|
100
|
+
const [file, body] = o.positional;
|
|
101
|
+
if (!file || !body) {
|
|
102
|
+
console.error('usage: projview comment add <file> "<body>" [--heading <id>] [--author <name>] [--persist | --store-url <url>]');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
if (!isPersistent(cfg)) {
|
|
106
|
+
console.error('⚠ no persistent store - this comment will NOT be saved on exit. Re-run with --persist or --store-url <url>.');
|
|
107
|
+
}
|
|
108
|
+
if (!existsSync(path.resolve(root, file))) {
|
|
109
|
+
console.error(`note: "${file}" not found under ${root} - saving the comment anyway.`);
|
|
110
|
+
}
|
|
111
|
+
const anchor = o.heading ? { type: 'heading', id: o.heading } : { type: 'file' };
|
|
112
|
+
print(await store.addComment({ file, body, anchor, author: resolveAuthor(o.author) }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (sub === 'resolve' || sub === 'reopen') {
|
|
116
|
+
const id = o.positional[0];
|
|
117
|
+
if (!id) { console.error(`usage: projview comment ${sub} <id> [--persist | --store-url <url>]`); process.exit(1); }
|
|
118
|
+
const c = await store.updateComment(id, { resolved: sub === 'resolve' });
|
|
119
|
+
if (!c) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
|
|
120
|
+
print(c);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (sub === 'rm' || sub === 'delete') {
|
|
124
|
+
const id = o.positional[0];
|
|
125
|
+
if (!id) { console.error('usage: projview comment rm <id> [--persist | --store-url <url>]'); process.exit(1); }
|
|
126
|
+
if (!(await store.deleteComment(id))) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
|
|
127
|
+
print({ deleted: id });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
console.error('usage: projview comment <add|resolve|reopen|rm> ...');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
} finally {
|
|
133
|
+
await store.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// projview comments [file] -> list comments (all, or for one file) as JSON
|
|
138
|
+
async function cmdComments(args) {
|
|
139
|
+
const o = parseSubArgs(args);
|
|
140
|
+
const root = o.demo ? DEMO_DIR : process.cwd();
|
|
141
|
+
const store = createStore(resolveStoreConfig(o), root);
|
|
142
|
+
const list = await store.listComments(o.positional[0]);
|
|
143
|
+
await store.close();
|
|
144
|
+
print(list);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// projview usage [path] -> aggregated usage stats (views / searches) as JSON
|
|
148
|
+
async function cmdUsage(args) {
|
|
149
|
+
const o = parseSubArgs(args);
|
|
150
|
+
const store = createStore(resolveStoreConfig(o), subRoot(o, 0));
|
|
151
|
+
const stats = await store.usageStats();
|
|
152
|
+
await store.close();
|
|
153
|
+
print(stats);
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
function openBrowser(url) {
|
|
84
157
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
85
158
|
: process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -92,9 +165,13 @@ function openBrowser(url) {
|
|
|
92
165
|
const HELP = `projview - ephemeral previewer for markdown + mermaid
|
|
93
166
|
|
|
94
167
|
usage:
|
|
95
|
-
projview [path] [options]
|
|
96
|
-
projview search <query> [path]
|
|
97
|
-
projview tree [path]
|
|
168
|
+
projview [path] [options] start the viewer (default)
|
|
169
|
+
projview search <query> [path] print ranked search results as JSON
|
|
170
|
+
projview tree [path] print the file tree as JSON
|
|
171
|
+
projview comment add <file> "<text>" leave a comment (--heading <id> for a section)
|
|
172
|
+
projview comments [file] list comments as JSON
|
|
173
|
+
projview comment resolve|reopen|rm <id> resolve, reopen, or delete a comment
|
|
174
|
+
projview usage [path] print usage stats (views / searches) as JSON
|
|
98
175
|
|
|
99
176
|
(subcommands print to stdout and exit - no server. --demo works with them too,
|
|
100
177
|
e.g. projview search "websockets" --demo)
|
|
@@ -109,11 +186,14 @@ options:
|
|
|
109
186
|
--persist keep the index in ~/.projview/cache and reuse it next run
|
|
110
187
|
--store <kind> store backend: memory | superlite | lite (default: memory)
|
|
111
188
|
--store-url <url> custom store: sqlite://<file> or postgres://<...> (or set DATABASE_URL)
|
|
189
|
+
--heading <id> (comment add) anchor the comment to a heading/section id
|
|
190
|
+
--author <name> (comment add) author to record (default: git/OS user)
|
|
112
191
|
-v, --version print the version and exit
|
|
113
192
|
-h, --help show this help
|
|
114
193
|
|
|
115
194
|
ephemeral by default - nothing is persisted unless you opt in with --persist /
|
|
116
|
-
--store / --store-url. ctrl-c removes any ephemeral store
|
|
195
|
+
--store / --store-url. ctrl-c removes any ephemeral store. Comments + usage need a
|
|
196
|
+
persistent store to survive (you'll be warned when adding a comment without one).`;
|
|
117
197
|
|
|
118
198
|
async function main() {
|
|
119
199
|
const argv = process.argv.slice(2);
|
|
@@ -121,6 +201,9 @@ async function main() {
|
|
|
121
201
|
// machine subcommands -> JSON to stdout, then exit (no server)
|
|
122
202
|
if (argv[0] === 'search') return cmdSearch(argv.slice(1));
|
|
123
203
|
if (argv[0] === 'tree') return cmdTree(argv.slice(1));
|
|
204
|
+
if (argv[0] === 'comment') return cmdComment(argv.slice(1));
|
|
205
|
+
if (argv[0] === 'comments') return cmdComments(argv.slice(1));
|
|
206
|
+
if (argv[0] === 'usage') return cmdUsage(argv.slice(1));
|
|
124
207
|
|
|
125
208
|
const opts = parseArgs(argv);
|
|
126
209
|
if (opts.help) { console.log(HELP); return; }
|
|
@@ -131,13 +214,14 @@ async function main() {
|
|
|
131
214
|
}
|
|
132
215
|
|
|
133
216
|
const storeConfig = resolveStoreConfig(opts);
|
|
134
|
-
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 });
|
|
135
218
|
const url = `http://${opts.host}:${port}`;
|
|
136
219
|
|
|
137
220
|
console.log(`\n projview -> ${url}`);
|
|
138
221
|
console.log(` indexing -> ${opts.root}`);
|
|
139
222
|
console.log(` store -> ${storeInfo.describe}`);
|
|
140
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})`);
|
|
141
225
|
console.log(` (ctrl-c to stop)\n`);
|
|
142
226
|
|
|
143
227
|
if (opts.open) openBrowser(url);
|
package/docs/how-to-set-up.md
CHANGED
|
@@ -11,7 +11,7 @@ previewer for markdown + mermaid files.
|
|
|
11
11
|
|
|
12
12
|
| need | version |
|
|
13
13
|
| --- | --- |
|
|
14
|
-
| Node.js | **
|
|
14
|
+
| Node.js | **22+** (the `lite` sqlite store uses the built-in `node:sqlite`) |
|
|
15
15
|
| npm | ships with Node |
|
|
16
16
|
|
|
17
17
|
No build step. By default indexing is in-memory and nothing is written to disk -
|
|
@@ -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, 45 tests)
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
See the [project README](README.md) for the architecture and the
|
|
@@ -82,10 +82,15 @@ See the [project README](README.md) for the architecture and the
|
|
|
82
82
|
| `projview [path]` | start the viewer (default) |
|
|
83
83
|
| `projview search <query> [path]` | print ranked search results as JSON, then exit |
|
|
84
84
|
| `projview tree [path]` | print the file tree as JSON, then exit |
|
|
85
|
+
| `projview comment add <file> "<body>"` | leave a comment (`--heading <id>` for a section) |
|
|
86
|
+
| `projview comments [file]` | list comments as JSON |
|
|
87
|
+
| `projview comment resolve\|reopen\|rm <id>` | resolve, reopen, or delete a comment |
|
|
88
|
+
| `projview usage [path]` | print usage stats (views / searches) as JSON |
|
|
85
89
|
|
|
86
|
-
The
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
The subcommands are projview's **machine interface** - JSON on stdout, no server,
|
|
91
|
+
no browser - built for agents and scripts. `search`/`tree` are read-only;
|
|
92
|
+
`comment` writes ride a persistent store (you're warned if you comment without
|
|
93
|
+
one). See [ai-tests/search.md](ai-tests/search.md) for a worked test.
|
|
89
94
|
|
|
90
95
|
**Options**
|
|
91
96
|
|
|
@@ -98,6 +103,8 @@ stdout, no server, no browser - built for agents and scripts. See
|
|
|
98
103
|
| `--persist` | keep the index in `~/.projview/cache` and reuse it next run |
|
|
99
104
|
| `--store <kind>` | store backend: `memory` \| `superlite` \| `lite` (default: `memory`; `lite` when `--persist`) |
|
|
100
105
|
| `--store-url <url>` | use a custom store: `sqlite://<file>` or `postgres://<...>` |
|
|
106
|
+
| `--heading <id>` | (`comment add`) anchor the comment to a heading/section id |
|
|
107
|
+
| `--author <name>` | (`comment add`) author to record (default: git/OS user) |
|
|
101
108
|
| `-v`, `--version` | print the version and exit |
|
|
102
109
|
| `-h`, `--help` | show help |
|
|
103
110
|
|
|
@@ -113,8 +120,9 @@ stdout, no server, no browser - built for agents and scripts. See
|
|
|
113
120
|
|
|
114
121
|
## persistence & stores
|
|
115
122
|
|
|
116
|
-
By default projview is **ephemeral** - the index
|
|
117
|
-
written to disk. Opt in to persistence
|
|
123
|
+
By default projview is **ephemeral** - the index (plus any comments and usage
|
|
124
|
+
events) lives in memory and nothing is written to disk. Opt in to persistence
|
|
125
|
+
when you want a faster warm start, comments that survive a restart, or a
|
|
118
126
|
queryable store an AI can read:
|
|
119
127
|
|
|
120
128
|
| backend | what | how |
|
package/docs/reference/api.md
CHANGED
|
@@ -5,7 +5,8 @@ and scroll-spy (the active section should track as you scroll).
|
|
|
5
5
|
|
|
6
6
|
## overview
|
|
7
7
|
|
|
8
|
-
projview exposes a tiny HTTP surface.
|
|
8
|
+
projview exposes a tiny HTTP surface. It never writes your files - the only
|
|
9
|
+
state it records is its own usage events (views / searches) into the store.
|
|
9
10
|
|
|
10
11
|
## endpoints
|
|
11
12
|
|
|
@@ -37,6 +38,35 @@ line-numbered snippets. See [the AI search test](../ai-tests/search.md).
|
|
|
37
38
|
`projview tree [path]` returns `{ root, files, tree[] }` - the same structure
|
|
38
39
|
`/api/tree` serves.
|
|
39
40
|
|
|
41
|
+
### projview comment
|
|
42
|
+
|
|
43
|
+
Leave / manage comments (they ride a persistent store - use `--persist` or
|
|
44
|
+
`--store-url`, else you'll be warned the comment won't survive exit):
|
|
45
|
+
|
|
46
|
+
| command | meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `projview comment add <file> "<body>"` | add a comment (whole file) |
|
|
49
|
+
| `projview comment add <file> "<body>" --heading <id>` | anchor it to a section |
|
|
50
|
+
| `projview comment resolve\|reopen <id>` | toggle resolved |
|
|
51
|
+
| `projview comment rm <id>` | delete it |
|
|
52
|
+
|
|
53
|
+
`--author <name>` overrides the recorded author (default: git/OS user). Agents
|
|
54
|
+
pass e.g. `--author claude` so AI-authored comments are distinguishable. A
|
|
55
|
+
comment is `{ id, file, anchor, body, author, createdAt, resolved }`; the
|
|
56
|
+
`anchor` is `{type:'file'}`, `{type:'heading', id}`, or a `{type:'text', exact,
|
|
57
|
+
prefix, suffix, section}` quote selector (text anchors come from the viewer).
|
|
58
|
+
|
|
59
|
+
### projview comments
|
|
60
|
+
|
|
61
|
+
`projview comments [file]` lists comments as JSON - all of them, or just one
|
|
62
|
+
file's.
|
|
63
|
+
|
|
64
|
+
### projview usage
|
|
65
|
+
|
|
66
|
+
`projview usage [path]` returns `{ total, byType, topFiles[], recentSearches[] }`.
|
|
67
|
+
The viewer records a `view` per file opened and a `search` per query, so usage
|
|
68
|
+
accumulates over a session (persisted only with a persistent store).
|
|
69
|
+
|
|
40
70
|
## the walker
|
|
41
71
|
|
|
42
72
|
### ignored directories
|
|
@@ -87,8 +117,9 @@ A chokidar watcher broadcasts change/tree events over SSE.
|
|
|
87
117
|
|
|
88
118
|
## stores (persistence)
|
|
89
119
|
|
|
90
|
-
The index
|
|
91
|
-
`--persist` / `--store` /
|
|
120
|
+
The index - plus **comments** and **usage events** - is in-memory by default
|
|
121
|
+
(ephemeral). Opt into a pluggable store with `--persist` / `--store` /
|
|
122
|
+
`--store-url` so comments and usage survive a restart:
|
|
92
123
|
|
|
93
124
|
### backends
|
|
94
125
|
|
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
|
+
}
|