folderview 0.1.0 → 0.2.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/dist/git.js CHANGED
@@ -159,8 +159,8 @@ export class Git {
159
159
  execFile('git', ['show', `${rev}:${this.repoPath(rel)}`], { cwd: this.top, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, windowsHide: true }, (err, stdout) => err ? reject(err) : resolve(stdout));
160
160
  });
161
161
  }
162
- async log(rel, limit = 30) {
163
- const out = await this.git(['log', '--follow', `-n${limit}`, '--format=%h%x1f%an%x1f%at%x1f%s%x1e', '--', this.repoPath(rel)]).catch(() => '');
162
+ async log(rel, limit = 30, skip = 0) {
163
+ const out = await this.git(['log', '--follow', `-n${limit}`, `--skip=${skip}`, '--format=%h%x1f%an%x1f%at%x1f%s%x1e', '--', this.repoPath(rel)]).catch(() => '');
164
164
  return out
165
165
  .split('\x1e')
166
166
  .map((r) => r.trim())
@@ -174,6 +174,125 @@ export class Git {
174
174
  commitDiff(rel, hash) {
175
175
  return this.git(['show', '--no-color', '--format=%H%n%an <%ae>%n%ad%n%n%B', '-M', hash, '--', this.repoPath(rel)]);
176
176
  }
177
+ /**
178
+ * The repository's commits (those touching the served folder), newest first, each marked pushed or local, plus
179
+ * the upstream's commits not pulled yet (on the first page).
180
+ */
181
+ async history(skip = 0, limit = 50, q = '') {
182
+ const scope = this.prefix ? ['--', this.prefix] : [];
183
+ const none = () => '';
184
+ // a search: words in the message (any case), or the start of a hash
185
+ const find = q ? ['-i', '-F', `--grep=${q}`] : [];
186
+ const first = !skip && !q;
187
+ // every question at once (each git call costs a process start, slow on Windows); the ones that can't apply
188
+ // (no upstream, no remote, no commits yet) just fail to ''
189
+ const [remoteList, up, out, hashOut, inRaw, inCount, localRaw, localScoped] = await Promise.all([
190
+ this.git(['remote']).catch(none),
191
+ this.git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(none),
192
+ this.git(['log', `-n${limit + 1}`, `--skip=${skip}`, `--format=${LOG_FORMAT}`, ...find, 'HEAD', ...scope]).catch(none),
193
+ /^[0-9a-f]{4,40}$/i.test(q) && !skip ? this.git(['log', '-n1', `--format=${LOG_FORMAT}`, q, '--']).catch(none) : '',
194
+ first ? this.git(['log', '-n50', `--format=${LOG_FORMAT}`, 'HEAD..@{u}', ...scope]).catch(none) : '',
195
+ first ? this.git(['rev-list', '--count', 'HEAD..@{u}', ...scope]).catch(none) : '',
196
+ // the commits only here (up to a limit, so a never-pushed giant history stays quick)
197
+ this.git(['rev-list', `--max-count=${LOCAL_CAP + 1}`, 'HEAD', '--not', '--remotes']).catch(none),
198
+ first && this.prefix ? this.git(['rev-list', '--count', `--max-count=${LOCAL_CAP + 1}`, 'HEAD', '--not', '--remotes', ...scope]).catch(none) : '',
199
+ ]);
200
+ const remotes = remoteList.trim() !== '';
201
+ const upstream = up.trim() || undefined;
202
+ const h = { commits: [], incoming: [], incomingTotal: 0, more: false, remotes, ...(upstream ? { upstream } : {}) };
203
+ let all = parseLog(out);
204
+ h.more = all.length > limit;
205
+ all = all.slice(0, limit);
206
+ const hit = parseLog(hashOut)[0];
207
+ if (hit && !all.some((c) => c.full === hit.full))
208
+ all.unshift(hit);
209
+ h.commits = all.map((c) => ({ ...c, state: remotes ? 'pushed' : 'unknown' }));
210
+ const localList = localRaw.split('\n').filter(Boolean);
211
+ if (remotes && all.length) {
212
+ let local = new Set(localList);
213
+ if (localList.length > LOCAL_CAP) {
214
+ // more unpushed commits than the list holds: ask again for just this page's stretch of time (the unpushed
215
+ // ones no older than its oldest commit, a day's slack for clock skew)
216
+ const oldest = Math.floor(Math.min(...all.map((c) => c.time)) / 1000 - 86400);
217
+ local = new Set((await this.git(['rev-list', `--since=${oldest}`, 'HEAD', '--not', '--remotes']).catch(none)).split('\n').filter(Boolean));
218
+ }
219
+ for (const c of h.commits)
220
+ if (local.has(c.full))
221
+ c.state = 'local';
222
+ }
223
+ if (upstream) {
224
+ h.incoming = parseLog(inRaw).map((c) => ({ ...c, state: 'incoming' }));
225
+ h.incomingTotal = Number(inCount.trim()) || h.incoming.length;
226
+ }
227
+ if (remotes && first) {
228
+ const n = this.prefix ? Number(localScoped.trim()) || 0 : localList.length;
229
+ h.localTotal = n > LOCAL_CAP ? `${LOCAL_CAP}+` : String(n);
230
+ }
231
+ return h;
232
+ }
233
+ /** what a commit was, and which files (in the served folder) it changed, against its first parent */
234
+ async commitInfo(rev) {
235
+ const raw = await this.git(['show', '-s', '--format=%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s%x1f%b', rev]);
236
+ const [full = '', hash = '', author = '', email = '', time = '0', parents = '', subject = '', ...rest] = raw.replace(/\n$/, '').split('\x1f');
237
+ const base = await this.parentOf(full);
238
+ const scope = this.prefix ? ['--', this.prefix] : [];
239
+ const [names, nums] = await Promise.all([
240
+ this.git(['diff', '--name-status', '-z', '-M', base, full, ...scope]),
241
+ this.git(['diff', '--numstat', '-z', '-M', base, full, ...scope]),
242
+ ]);
243
+ const files = [];
244
+ const recs = names.split('\0');
245
+ for (let i = 0; i < recs.length; i++) {
246
+ const s = recs[i];
247
+ if (!s)
248
+ continue;
249
+ let from;
250
+ let file = recs[++i] ?? '';
251
+ if (s[0] === 'R' || s[0] === 'C') {
252
+ from = file;
253
+ file = recs[++i] ?? '';
254
+ }
255
+ const rel = this.fromRepo(file);
256
+ if (rel === undefined)
257
+ continue;
258
+ const state = s[0] === 'A' || s[0] === 'C' ? 'added' : s[0] === 'D' ? 'deleted' : s[0] === 'R' ? 'renamed' : 'modified';
259
+ const f = { path: rel, state };
260
+ if (from)
261
+ f.from = this.fromRepo(from) ?? from;
262
+ files.push(f);
263
+ }
264
+ const byPath = new Map(files.map((f) => [f.path, f]));
265
+ const nr = nums.split('\0');
266
+ for (let i = 0; i < nr.length; i++) {
267
+ const m = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(nr[i] ?? '');
268
+ if (!m)
269
+ continue;
270
+ let file = m[3] ?? '';
271
+ if (!file) {
272
+ i++;
273
+ file = nr[++i] ?? '';
274
+ }
275
+ const f = byPath.get(this.fromRepo(file) ?? '');
276
+ if (f && m[1] !== '-') {
277
+ f.add = Number(m[1]);
278
+ f.del = Number(m[2]);
279
+ }
280
+ }
281
+ return {
282
+ commit: { full, hash, author, email, time: Number(time) * 1000, parents: parents.split(' ').filter(Boolean), subject, body: rest.join('\x1f').trim() },
283
+ files,
284
+ };
285
+ }
286
+ /** one file's diff in a commit, against the commit's first parent (a rename needs its old path too) */
287
+ async commitFileDiff(rev, rel, fromRel) {
288
+ const full = (await this.git(['rev-parse', '--verify', rev + '^{commit}'])).trim();
289
+ const base = await this.parentOf(full);
290
+ const paths = [this.repoPath(rel), ...(fromRel ? [this.repoPath(fromRel)] : [])];
291
+ return this.git(['diff', '--no-color', '-M', base, full, '--', ...paths]);
292
+ }
293
+ async parentOf(full) {
294
+ return (await this.git(['rev-parse', '--verify', '-q', full + '^1']).catch(() => '')).trim() || EMPTY_TREE;
295
+ }
177
296
  async userName() {
178
297
  return (await this.git(['config', 'user.name']).catch(() => '')).trim();
179
298
  }
@@ -218,6 +337,20 @@ function stateOf(x, y) {
218
337
  return 'added';
219
338
  return 'modified';
220
339
  }
340
+ const LOCAL_CAP = 1000;
341
+ const LOG_FORMAT = '%H%x1f%h%x1f%an%x1f%at%x1f%P%x1f%s%x1e';
342
+ /** git's empty tree: what a first commit is compared with */
343
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
344
+ function parseLog(out) {
345
+ return out
346
+ .split('\x1e')
347
+ .map((r) => r.trim())
348
+ .filter(Boolean)
349
+ .map((r) => {
350
+ const [full = '', hash = '', author = '', time = '0', parents = '', subject = ''] = r.split('\x1f');
351
+ return { full, hash, author, time: Number(time) * 1000, parents: parents.split(' ').filter(Boolean), subject };
352
+ });
353
+ }
221
354
  const nullDevice = () => (process.platform === 'win32' ? 'NUL' : '/dev/null');
222
355
  function run(cwd, args, ok = [0]) {
223
356
  return new Promise((resolve, reject) => {
package/dist/review.js CHANGED
@@ -107,6 +107,15 @@ export class Review {
107
107
  if (quote !== undefined)
108
108
  c.quote = quote;
109
109
  }
110
+ if (input.area)
111
+ c.area = input.area;
112
+ if (input.strokes?.length)
113
+ c.strokes = input.strokes;
114
+ if (input.snapshot) {
115
+ fs.mkdirSync(path.join(this.dir, 'images'), { recursive: true });
116
+ c.image = `images/${c.id}.png`;
117
+ fs.writeFileSync(path.join(this.dir, c.image), input.snapshot);
118
+ }
110
119
  if (input.commit)
111
120
  c.commit = input.commit;
112
121
  this.comments.push(c);
@@ -135,19 +144,35 @@ export class Review {
135
144
  return c;
136
145
  }
137
146
  remove(id) {
138
- const n = this.comments.length;
139
- this.comments = this.comments.filter((c) => c.id !== id);
140
- if (this.comments.length === n)
147
+ const gone = this.comments.find((c) => c.id === id);
148
+ if (!gone)
141
149
  return false;
150
+ this.comments = this.comments.filter((c) => c.id !== id);
151
+ this.dropImage(gone);
142
152
  this.save();
143
153
  return true;
144
154
  }
145
155
  clearResolved() {
146
156
  const n = this.comments.length;
157
+ for (const c of this.comments)
158
+ if (c.status === 'resolved')
159
+ this.dropImage(c);
147
160
  this.comments = this.comments.filter((c) => c.status !== 'resolved');
148
161
  this.save();
149
162
  return n - this.comments.length;
150
163
  }
164
+ /** the marked-up picture of an image comment, as a path inside the review folder (or undefined) */
165
+ imagePath(id) {
166
+ const c = this.comments.find((x) => x.id === id);
167
+ if (!c?.image || !/^images\/[0-9a-f]+\.png$/.test(c.image))
168
+ return undefined;
169
+ const abs = path.join(this.dir, c.image);
170
+ return fs.existsSync(abs) ? abs : undefined;
171
+ }
172
+ dropImage(c) {
173
+ if (c.image && /^images\/[0-9a-f]+\.png$/.test(c.image))
174
+ fs.rmSync(path.join(this.dir, c.image), { force: true });
175
+ }
151
176
  quote(rel, from, to) {
152
177
  try {
153
178
  const abs = path.join(this.root, rel);
@@ -202,7 +227,7 @@ export function toMarkdown(comments, jsonRel, { openOnly = false } = {}) {
202
227
  '',
203
228
  '## How to work through this (for an agent)',
204
229
  '',
205
- '- Each comment is on a file, or on lines of it (`path:line`). Paths are relative to the folder folderview serves.',
230
+ '- Each comment is on a file, on lines of it (`path:line`), or on an area of an image (marked by hand; `images/<id>.png` beside this file shows the marks). Paths are relative to the folder folderview serves.',
206
231
  '- The quoted code is what the lines said when the comment was left; the file may have moved on since, so find the code, not just the line number.',
207
232
  `- When a comment is dealt with, set its \`"status"\` to \`"resolved"\` in \`${jsonRel}\` and, if there is something to say, add a reply:`,
208
233
  ' `"replies": [{ "id": "a1", "author": "agent", "body": "Done: …", "at": "<ISO time>" }]`. The reviewer sees it live.',
@@ -219,10 +244,14 @@ export function toMarkdown(comments, jsonRel, { openOnly = false } = {}) {
219
244
  file = c.path;
220
245
  out.push(`### ${file || '(the whole folder)'}`, '');
221
246
  }
222
- const where = c.line ? `${c.path}:${c.line}${c.endLine ? '-' + c.endLine : ''}` : `${c.path || '.'} (whole file)`;
247
+ const where = c.line ? `${c.path}:${c.line}${c.endLine ? '-' + c.endLine : ''}` : c.area ? `${c.path} (marked area)` : `${c.path || '.'} (whole file)`;
223
248
  out.push(`#### \`${where}\` — ${c.author}${c.commit ? ` @ ${c.commit}` : ''} · id \`${c.id}\``, '');
224
249
  if (c.quote)
225
250
  out.push('```' + langOf(c.path), c.quote, '```', '');
251
+ if (c.area)
252
+ out.push(`Marked area: x ${c.area.x}, y ${c.area.y}, ${c.area.w} × ${c.area.h} px of the image.` + (c.image ? ` The marks are drawn on a copy: ${c.image} (beside this file).` : ''), '');
253
+ if (c.image)
254
+ out.push(`![The marked area](${c.image})`, '');
226
255
  out.push(c.body, '');
227
256
  for (const r of c.replies ?? [])
228
257
  out.push(`> **${r.author}**: ${r.body.replace(/\n/g, '\n> ')}`, '');
package/dist/server.js CHANGED
@@ -219,7 +219,30 @@ export async function start(opts) {
219
219
  return void res.end(buf);
220
220
  }
221
221
  case '/api/git/log':
222
- return json(res, 200, { commits: await git.log(rel) });
222
+ {
223
+ // a page of the file's commits, and whether there are more
224
+ const all = await git.log(rel, 31, Math.max(0, Number(q('skip')) || 0));
225
+ return json(res, 200, { commits: all.slice(0, 30), more: all.length > 30 });
226
+ }
227
+ case '/api/git/history':
228
+ return json(res, 200, await git.history(Math.max(0, Number(q('skip')) || 0), 50, q('q').trim().slice(0, 200)));
229
+ case '/api/git/commit-info': {
230
+ const h = q('h');
231
+ if (!isRev(h))
232
+ return json(res, 400, { error: 'bad revision' });
233
+ const info = await git.commitInfo(h);
234
+ info.files = info.files.filter((f) => visible(f.path) && (!reviewPrefix || !f.path.startsWith(reviewPrefix)));
235
+ return json(res, 200, info);
236
+ }
237
+ case '/api/git/commit-diff': {
238
+ const h = q('h');
239
+ const from = q('from');
240
+ if (!isRev(h))
241
+ return json(res, 400, { error: 'bad revision' });
242
+ if (from && inside(scope, from) === undefined)
243
+ return json(res, 404, { error: 'not found' });
244
+ return text(res, await git.commitFileDiff(h, rel, from || undefined));
245
+ }
223
246
  case '/api/git/commit': {
224
247
  const h = q('h');
225
248
  if (!isRev(h))
@@ -234,8 +257,16 @@ export async function start(opts) {
234
257
  }
235
258
  if (p === '/api/comments' && method === 'GET')
236
259
  return json(res, 200, { comments: review.all() });
260
+ const img = /^\/api\/comments\/([0-9a-f]+)\/image\.png$/.exec(p);
261
+ if (img) {
262
+ const abs = review.imagePath(img[1] ?? '');
263
+ if (!abs)
264
+ return json(res, 404, { error: 'not found' });
265
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
266
+ return void fs.createReadStream(abs).pipe(res);
267
+ }
237
268
  if (p === '/api/comments' && method === 'POST') {
238
- const b = await body(req);
269
+ const b = await body(req, 16_000_000);
239
270
  const rel = str(b['path']);
240
271
  if (inside(scope, rel) === undefined)
241
272
  return json(res, 400, { error: 'No such file' });
@@ -248,6 +279,7 @@ export async function start(opts) {
248
279
  author: str(b['author']) || author,
249
280
  ...(line ? { line } : {}),
250
281
  ...(endLine ? { endLine } : {}),
282
+ ...imageMarks(b),
251
283
  ...(git ? { commit: (await gitStatus().catch(() => undefined))?.head ?? '' } : {}),
252
284
  });
253
285
  return json(res, 201, c);
@@ -390,7 +422,37 @@ function json(res, code, data) {
390
422
  function text(res, body, type = 'text/plain; charset=utf-8') {
391
423
  res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }).end(body);
392
424
  }
393
- async function body(req) {
425
+ /** an image comment's marks: the area, the strokes, and the marked-up picture (a PNG data URL) */
426
+ function imageMarks(b) {
427
+ const out = {};
428
+ const a = b['area'];
429
+ if (a && typeof a === 'object' && !Array.isArray(a)) {
430
+ const r = Object.fromEntries(Object.entries(a));
431
+ const n = (k) => (typeof r[k] === 'number' && Number.isFinite(r[k]) && Number(r[k]) >= 0 ? Math.round(Number(r[k])) : undefined);
432
+ const [x, y, w, h] = [n('x'), n('y'), n('w'), n('h')];
433
+ if (x !== undefined && y !== undefined && w && h)
434
+ out.area = { x, y, w, h };
435
+ }
436
+ const s = b['strokes'];
437
+ if (Array.isArray(s)) {
438
+ let budget = 20_000;
439
+ const strokes = s
440
+ .filter((st) => Array.isArray(st))
441
+ .map((st) => st.filter((v) => typeof v === 'number' && v >= 0 && v <= 1).map((v) => Math.round(v * 10000) / 10000))
442
+ .filter((st) => st.length >= 2 && st.length % 2 === 0 && (budget -= st.length) >= 0);
443
+ if (strokes.length)
444
+ out.strokes = strokes;
445
+ }
446
+ const snap = typeof b['snapshot'] === 'string' ? /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(b['snapshot']) : null;
447
+ if (snap && out.strokes) {
448
+ const buf = Buffer.from(snap[1] ?? '', 'base64');
449
+ // a PNG, and nothing else
450
+ if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
451
+ out.snapshot = buf;
452
+ }
453
+ return out;
454
+ }
455
+ async function body(req, max = 1_000_000) {
394
456
  if (!String(req.headers['content-type'] ?? '').includes('application/json'))
395
457
  throw new Error('Send JSON');
396
458
  const chunks = [];
@@ -398,7 +460,7 @@ async function body(req) {
398
460
  for await (const c of req) {
399
461
  const b = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
400
462
  size += b.length;
401
- if (size > 1_000_000)
463
+ if (size > max)
402
464
  throw new Error('Too large');
403
465
  chunks.push(b);
404
466
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "folderview",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Browse, search and review any folder in your browser: highlighted code, rendered markdown with mermaid and math, git status and diffs, full-text search, downloads, and review comments saved where an LLM can read them.",
5
5
  "type": "module",
6
6
  "bin": {